pc setup v2: one-screen layout with a time slider, photo grid and lightbox
All checks were successful
Deploy production / deploy (push) Successful in 1m7s

- hardware: slots, sections, usage, install/removal dates, images and a hidden flag on
  components and devices; setup_photos table; setup_overview view gains the new columns
- data: hide the 2021 friends-PC build and the wrong 140mm / unknown fans (kept, not deleted);
  7x SL-INF + 3 Kraken stock fans; mice, audio, desk extras; MacBook Pro 16 M2 Pro 1 TB;
  dates for every swap so the slider can rebuild the setup on any day
- self-hosted product images in static/hardware (webp + thumbs)
- prices and order details are owner-only (in a popover)
This commit is contained in:
2026-10-25 16:15:51 +00:00
parent d414e7190e
commit eff5a24985
204 changed files with 32374 additions and 776 deletions

View File

@@ -27,6 +27,16 @@ export type Part = {
orderNumber: string | null;
invoiceNumber: string | null;
source: string | null;
/** Time slider fields (see SLOT_META and presentAt). */
slug: string | null;
section: string | null;
slot: string | null;
installedAt: string | null;
removedAt: string | null;
replacesId: number | null;
usage: string | null;
imageUrls: string[];
hidden: boolean;
};
export type Device = {
@@ -48,9 +58,21 @@ export type Device = {
imageUrl: string | null;
sortOrder: number;
notes: string | null;
installedAt: string | null;
removedAt: string | null;
imageUrls: string[];
hidden: boolean;
parts: Part[];
};
export type SetupPhoto = {
id: number;
url: string;
caption: string | null;
takenAt: string | null;
sortOrder: number;
};
export type Game = {
id: number;
game: string;
@@ -82,6 +104,10 @@ export const CATEGORY_META: Record<string, { label: string; from: string; to: st
audio: { label: 'Audio', from: '#fda4af', to: '#e11d48' },
controller: { label: 'Controller', from: '#bef264', to: '#65a30d' },
accessory: { label: 'Accessory', from: '#cbd5e1', to: '#64748b' },
microphone: { label: 'Microphone', from: '#fca5a5', to: '#ef4444' },
mousepad: { label: 'Mousepad', from: '#86efac', to: '#16a34a' },
webcam: { label: 'Webcam', from: '#93c5fd', to: '#3b82f6' },
chair: { label: 'Chair', from: '#fcd34d', to: '#d97706' },
other: { label: 'Other', from: '#d4d4d8', to: '#71717a' }
};
export const CATEGORIES = Object.keys(CATEGORY_META);
@@ -100,6 +126,139 @@ export const DEVICE_KIND_META: Record<string, { label: string; from: string; to:
export const DEVICE_KINDS = Object.keys(DEVICE_KIND_META);
export const DEVICE_STATUSES = ['active', 'spare', 'planned', 'retired', 'sold'] as const;
export const PART_STATUSES = ['in_use', 'planned', 'spare', 'retired', 'sold'] as const;
export const SECTIONS = ['pc', 'desk', 'audio', 'sim'] as const;
export const USAGES = [
'current',
'stored',
'previous',
'work-issued',
'placeholder',
'planned',
'legacy',
'legacy-previous'
] as const;
/**
* Slots on the setup page, in display order. `icon` is a PcIcon category; several DB slots can
* share one card (`card`), e.g. the three small cooler accessories.
*/
export const SLOT_META: Record<string, { label: string; icon: string; card?: string }> = {
case: { label: 'Case', icon: 'case' },
cpu: { label: 'CPU', icon: 'cpu' },
motherboard: { label: 'Motherboard', icon: 'motherboard' },
memory: { label: 'Memory', icon: 'ram' },
gpu: { label: 'Graphics', icon: 'gpu' },
cooler: { label: 'Cooler', icon: 'cpu_cooler' },
psu: { label: 'Power', icon: 'psu' },
storage: { label: 'Storage', icon: 'storage' },
'fans-case': { label: 'Case fans', icon: 'fan' },
'fans-radiator': { label: 'Radiator fans', icon: 'fan' },
lighting: { label: 'Lighting', icon: 'lighting' },
'contact-frame': { label: 'Accessories', icon: 'accessory', card: 'accessories' },
'cooler-mount': { label: 'Accessories', icon: 'accessory', card: 'accessories' },
'thermal-paste': { label: 'Accessories', icon: 'accessory', card: 'accessories' },
monitor: { label: 'Monitor', icon: 'monitor' },
'monitor-arm': { label: 'Arm', icon: 'accessory' },
keyboard: { label: 'Keyboard', icon: 'keyboard' },
keycaps: { label: 'Keycaps', icon: 'keyboard' },
keypad: { label: 'Keypad', icon: 'keyboard' },
'mouse-gaming': { label: 'Mouse', icon: 'mouse' },
'mouse-work': { label: 'Work mouse', icon: 'mouse' },
mousepad: { label: 'Mousepad', icon: 'mousepad' },
webcam: { label: 'Webcam', icon: 'webcam' },
chair: { label: 'Chair', icon: 'chair' },
mic: { label: 'Microphone', icon: 'microphone' },
'mic-arm': { label: 'Mic arm', icon: 'audio' },
headphones: { label: 'Headphones', icon: 'headset' },
'headset-work': { label: 'Work headset', icon: 'headset' },
iems: { label: 'IEMs', icon: 'headset' },
wheel: { label: 'Wheel', icon: 'controller' },
shifter: { label: 'Shifter', icon: 'controller' }
};
const SLOT_ORDER = Object.keys(SLOT_META);
export const slotLabel = (slot: string | null, category: string) =>
(slot && SLOT_META[slot]?.label) ?? categoryLabel(category);
export const slotIcon = (slot: string | null, category: string) =>
(slot && SLOT_META[slot]?.icon) ?? category;
/** Thumbnail next to each self-hosted image: /hardware/x/1.webp -> /hardware/x/1.t.webp. */
export const thumb = (url: string) =>
url.startsWith('/hardware/') ? url.replace(/\.webp$/, '.t.webp') : url;
/** Owned long ago and gone, date unknown: not placeable on the timeline (the "earlier" tray). */
export const isUndatedPast = (p: {
usage?: string | null;
installedAt: string | null;
removedAt: string | null;
}) => p.usage === 'legacy-previous' || (p.usage === 'previous' && !p.removedAt && !p.installedAt);
/** In the setup on day `at` (YYYY-MM-DD). Undated items count as always there. */
export const presentAt = (
p: { installedAt: string | null; removedAt: string | null },
at: string
) => (!p.installedAt || p.installedAt <= at) && (!p.removedAt || p.removedAt > at);
export type SlotCard = { key: string; slot: string; parts: Part[] };
/** Visible parts of one section on day `at`, one card per slot (several parts may share it). */
export function slotCards(parts: Part[], section: string, at: string): SlotCard[] {
const cards = new Map<string, SlotCard>();
for (const p of parts) {
if (p.section !== section || p.hidden || isUndatedPast(p) || !presentAt(p, at)) continue;
const slot = p.slot ?? p.category;
const key = SLOT_META[slot]?.card ?? slot;
const card = cards.get(key) ?? { key, slot, parts: [] };
card.parts.push(p);
cards.set(key, card);
}
const rank = (s: string) => {
const i = SLOT_ORDER.indexOf(s);
return i < 0 ? 999 : i;
};
return [...cards.values()].sort((a, b) => rank(a.slot) - rank(b.slot));
}
export type TimelineEvent = { date: string; added: string[]; removed: string[] };
const shortName = (p: { brand: string | null; model: string; quantity?: number }) => {
const name = `${p.brand ? `${p.brand} ` : ''}${p.model.replace(/\s*\(.*?\)\s*/g, ' ').trim()}`;
return (p.quantity ?? 1) > 1 ? `${p.quantity}× ${name}` : name;
};
/** One event per distinct install/removal date (parts and devices), oldest first. */
export function buildEvents(
items: {
brand: string | null;
model: string | null;
label?: string | null;
quantity?: number;
installedAt: string | null;
removedAt: string | null;
}[]
): TimelineEvent[] {
const byDate = new Map<string, TimelineEvent>();
const at = (d: string) => {
const e = byDate.get(d) ?? { date: d, added: [], removed: [] };
byDate.set(d, e);
return e;
};
for (const it of items) {
const name = shortName({
brand: it.brand,
model: it.model ?? it.label ?? '?',
quantity: it.quantity
});
if (it.installedAt) at(it.installedAt).added.push(name);
if (it.removedAt) at(it.removedAt).removed.push(name);
}
return [...byDate.values()].sort((a, b) => a.date.localeCompare(b.date));
}
export const monthYear = (d: string) =>
new Date(`${d}T12:00:00Z`).toLocaleDateString('en-GB', { month: 'short', year: 'numeric' });
export const eventLabel = (e: TimelineEvent) =>
[...e.added.map((n) => `+ ${n}`), ...e.removed.map((n) => `− ${n}`)].join(', ');
export const statusLabel = (s: string) => s.replace('_', ' ');
@@ -175,3 +334,148 @@ export function cmFor(sens: number | null, yaw: number | null, dpi: number | nul
if (!sens || !yaw || !dpi) return null;
return (360 * 2.54) / (yaw * dpi * sens);
}
/** Physical units: a 3-pack of fans counts as 3. */
export const units = (p: { quantity: number; specs: Specs }) =>
p.quantity * (Number(p.specs.fans_in_pack) || 1);
/** "Lian Li UNI FAN SL-INF 120 Wireless 3-pack White (art 1161338)" -> without packs/brackets. */
export const displayName = (p: { brand: string | null; model: string }) =>
`${p.brand ? `${p.brand} ` : ''}${p.model
.replace(/\s*\(.*?\)\s*/g, ' ')
.replace(/\b\d-pack\s*/gi, '')
.replace(/\s+,/g, ',')
.trim()}`;
const KEY_SPECS: Record<string, [string, (v: string | number | boolean) => string][]> = {
cpu: [
['cores', (v) => `${v} cores`],
['max_turbo_ghz', (v) => `${v} GHz`]
],
gpu: [
['vram_gb', (v) => `${v} GB`],
['architecture', String]
],
ram: [
['capacity_gb', (v) => `${v} GB`],
['speed_mts', (v) => `${v} MT/s`]
],
storage: [
['capacity_gb', (v) => (Number(v) >= 1000 ? `${Number(v) / 1000} TB` : `${v} GB`)],
['interface', String]
],
psu: [
['wattage_w', (v) => `${v} W`],
['efficiency', String]
],
motherboard: [
['chipset', String],
['form_factor', String]
],
case: [
['form_factor', String],
['color', String]
],
cpu_cooler: [
['radiator_mm', (v) => `${v} mm`],
['type', String]
],
fan: [
['size_mm', (v) => `${v} mm`],
['color', String]
],
monitor: [
['size_in', (v) => `${v}"`],
['refresh_hz', (v) => `${v} Hz`],
['resolution', String]
]
};
/** One short spec line for a card, e.g. "16 cores · 5.2 GHz". */
export function keySpec(p: { category: string; specs: Specs }): string | null {
const keys = KEY_SPECS[p.category];
const pairs = keys
? keys.flatMap(([k, f]) => (p.specs[k] != null && p.specs[k] !== '' ? [f(p.specs[k]!)] : []))
: Object.entries(p.specs)
.filter(
([k, v]) =>
v != null &&
v !== '' &&
typeof v !== 'boolean' &&
!k.endsWith('_art') &&
k !== 'sku' &&
k !== 'mfr_part'
)
.slice(0, 1)
.map(([, v]) => String(v));
return pairs.length ? pairs.join(' · ') : null;
}
export type LightboxEntry = {
id: number | null;
kicker: string;
name: string;
qty: number;
specs: Specs;
url: string | null;
since: string | null;
until: string | null;
badges: string[];
note: string | null;
};
export type LightboxItem = { title: string; images: string[]; entries: LightboxEntry[] };
export function usageBadges(p: { usage?: string | null; status: string; model: string | null }) {
const out: string[] = [];
if (p.usage === 'stored' || p.status === 'spare') out.push('stored');
if (p.usage === 'work-issued') out.push('work');
if (p.usage === 'previous' || p.usage === 'legacy-previous' || p.status === 'retired')
out.push('previous');
if (p.usage === 'legacy') out.push('long ago');
if (p.model && /\bTBD\b/.test(p.model)) out.push('model TBD');
if (p.status === 'planned') out.push('planned');
return out;
}
export function partEntry(p: Part): LightboxEntry {
return {
id: p.id,
kicker: slotLabel(p.slot, p.category),
name: displayName(p),
qty: units(p),
specs: Object.fromEntries(
Object.entries(p.specs).filter(
([k, v]) => v != null && v !== '' && !k.endsWith('_art') && k !== 'sku'
)
),
url: p.url,
since: p.installedAt,
until: p.removedAt,
badges: usageBadges(p),
note: null
};
}
export function deviceItem(d: Device): LightboxItem {
return {
title: d.label ?? d.name,
images: d.imageUrls,
entries: [
{
id: null,
kicker: DEVICE_KIND_META[d.kind]?.label ?? d.kind,
name: [d.brand, d.model].filter(Boolean).join(' ') || d.name,
qty: 1,
specs: Object.fromEntries(Object.entries(d.specs).filter(([, v]) => v != null && v !== '')),
url: d.url,
since: d.installedAt ?? d.purchaseDate,
until: d.removedAt,
badges: d.status === 'active' ? [] : [d.status],
note: d.notes
},
...d.parts
.filter((p) => !p.hidden)
.map((p) => ({ ...partEntry(p), kicker: categoryLabel(p.category) }))
]
};
}