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
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:
@@ -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) }))
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user