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

@@ -0,0 +1,435 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { SubmitFunction } from '@sveltejs/kit';
import Modal from './Modal.svelte';
import {
CATEGORIES,
DEVICE_KINDS,
DEVICE_KIND_META,
DEVICE_STATUSES,
PART_STATUSES,
SECTIONS,
SLOT_META,
USAGES,
categoryLabel,
specsToText,
statusLabel,
type Device,
type Game,
type Part
} from './shared';
// Owner-only edit dialogs for parts, devices and games (the page sets one of them to open it).
let {
editPart = $bindable(null),
editDevice = $bindable(null),
editGame = $bindable(null),
devices,
mice
}: {
editPart?: Partial<Part> | null;
editDevice?: Partial<Device> | null;
editGame?: Partial<Game> | null;
devices: Device[];
mice: Part[];
} = $props();
let problem = $state('');
const submit =
(close: () => void): SubmitFunction =>
() => {
problem = '';
return async ({ result, update }) => {
if (result.type === 'failure')
problem = String((result.data as { message?: string })?.message ?? 'Could not save');
else if (result.type === 'success') {
close();
await update({ reset: false });
} else await update();
};
};
const confirmDelete = (what: string) => (event: SubmitEvent) => {
if (!confirm(`Delete ${what}?`)) event.preventDefault();
};
const euros = (cents: number | null | undefined) =>
cents === null || cents === undefined ? '' : (cents / 100).toFixed(2);
</script>
{#if editPart}
{@const p = editPart}
<Modal
title={p.id ? `Edit ${p.model}` : 'Add a part'}
onclose={() => {
editPart = null;
problem = '';
}}
>
{#snippet children(close)}
<form method="POST" action="?/savePart" use:enhance={submit(close)} class="form">
<input type="hidden" name="id" value={p.id ?? ''} />
<label
>device<select name="deviceId" value={p.deviceId}>
{#each devices as d (d.id)}<option value={d.id}>{d.label ?? d.name}</option>{/each}
</select></label
>
<label
>category<select name="category" value={p.category}>
{#each CATEGORIES as c (c)}<option value={c}>{categoryLabel(c)}</option>{/each}
</select></label
>
<label>brand<input name="brand" value={p.brand ?? ''} maxlength="120" /></label>
<label class="wide"
>model<input name="model" value={p.model ?? ''} required maxlength="300" /></label
>
<label
>quantity<input name="quantity" type="number" min="1" value={p.quantity ?? 1} /></label
>
<label
>price per unit (€)<input
name="unitPrice"
inputmode="decimal"
value={euros(p.unitPriceCents)}
/></label
>
<label>shop<input name="shop" value={p.shop ?? ''} /></label>
<label>order date<input name="orderDate" type="date" value={p.orderDate ?? ''} /></label>
<label
>warranty until<input
name="warrantyUntil"
type="date"
value={p.warrantyUntil ?? ''}
/></label
>
<label
>status<select name="status" value={p.status}>
{#each PART_STATUSES as s (s)}<option value={s}>{statusLabel(s)}</option>{/each}
</select></label
>
<label>order number<input name="orderNumber" value={p.orderNumber ?? ''} /></label>
<label>invoice number<input name="invoiceNumber" value={p.invoiceNumber ?? ''} /></label>
<label>DPI (set)<input name="dpi" type="number" min="1" value={p.dpi ?? ''} /></label>
<label
>DPI (measured)<input
name="measuredDpi"
type="number"
min="1"
value={p.measuredDpi ?? ''}
/></label
>
<label class="wide">link<input name="url" type="url" value={p.url ?? ''} /></label>
<input type="hidden" name="imageUrl" value={p.imageUrl ?? ''} />
<label
>section<select name="section" value={p.section ?? ''}>
<option value="">–</option>
{#each SECTIONS as s (s)}<option value={s}>{s}</option>{/each}
</select></label
>
<label
>slot<input
name="slot"
value={p.slot ?? ''}
list="pc-slots"
placeholder="cpu, fans-case, mouse-gaming…"
/></label
>
<datalist id="pc-slots">
{#each Object.keys(SLOT_META) as s (s)}<option value={s}></option>{/each}
</datalist>
<label
>usage<select name="usage" value={p.usage ?? ''}>
<option value="">–</option>
{#each USAGES as u (u)}<option value={u}>{u}</option>{/each}
</select></label
>
<label
>in setup since<input name="installedAt" type="date" value={p.installedAt ?? ''} /></label
>
<label>removed on<input name="removedAt" type="date" value={p.removedAt ?? ''} /></label>
<label class="wide"
>images (one per line, /hardware/… or https://)<textarea name="imageUrls" rows="3"
>{(p.imageUrls ?? []).join('\n')}</textarea
></label
>
<label class="wide"
>specs (one "key: value" per line)<textarea name="specs" rows="4"
>{specsToText(p.specs ?? {})}</textarea
></label
>
<label class="wide"
>notes (only you)<textarea name="notes" rows="2">{p.notes ?? ''}</textarea></label
>
<label class="check"
><input type="checkbox" name="verified" checked={p.verified ?? true} /> confirmed still in this
setup</label
>
<label class="check"
><input type="checkbox" name="hidden" checked={p.hidden ?? false} /> hidden (kept for history,
not shown)</label
>
{#if problem}<p class="wide text-sm text-red-500" role="alert">{problem}</p>{/if}
<div class="buttons wide">
<button type="button" class="action" onclick={close}>Cancel</button>
<button type="submit" class="action primary">Save</button>
</div>
</form>
{#if p.id}
<form
method="POST"
action="?/deletePart"
use:enhance={submit(close)}
onsubmit={confirmDelete(p.model ?? 'this part')}
class="mt-2"
>
<input type="hidden" name="id" value={p.id} />
<button type="submit" class="text-sm text-red-500 hover:underline">Delete part</button>
</form>
{/if}
{/snippet}
</Modal>
{/if}
{#if editDevice}
{@const d = editDevice}
<Modal
title={d.id ? `Edit ${d.name}` : 'Add a device'}
onclose={() => {
editDevice = null;
problem = '';
}}
>
{#snippet children(close)}
<form method="POST" action="?/saveDevice" use:enhance={submit(close)} class="form">
<input type="hidden" name="id" value={d.id ?? ''} />
<label
>name (hostname)<input
name="name"
value={d.name ?? ''}
required
pattern="[a-z0-9][a-z0-9\-]*"
/></label
>
<label>label<input name="label" value={d.label ?? ''} /></label>
<label
>kind<select name="kind" value={d.kind}>
{#each DEVICE_KINDS as k (k)}<option value={k}>{DEVICE_KIND_META[k].label}</option
>{/each}
</select></label
>
<label
>status<select name="status" value={d.status}>
{#each DEVICE_STATUSES as s (s)}<option value={s}>{s}</option>{/each}
</select></label
>
<label>brand<input name="brand" value={d.brand ?? ''} /></label>
<label>model<input name="model" value={d.model ?? ''} /></label>
<label class="wide"
>other hostnames (comma separated)<input
name="hostnames"
value={(d.hostnames ?? []).join(', ')}
/></label
>
<label
>price (€)<input name="price" inputmode="decimal" value={euros(d.priceCents)} /></label
>
<label
>per month (€)<input
name="monthly"
inputmode="decimal"
value={euros(d.monthlyCents)}
/></label
>
<label
>budget (€)<input name="budget" inputmode="decimal" value={euros(d.budgetCents)} /></label
>
<label>shop<input name="shop" value={d.shop ?? ''} /></label>
<label>since<input name="purchaseDate" type="date" value={d.purchaseDate ?? ''} /></label>
<label
>sort order<input
name="sortOrder"
type="number"
min="0"
value={d.sortOrder ?? 100}
/></label
>
<label class="wide">link<input name="url" type="url" value={d.url ?? ''} /></label>
<label class="wide"
>specs (one "key: value" per line)<textarea name="specs" rows="4"
>{specsToText(d.specs ?? {})}</textarea
></label
>
<label
>in setup since<input name="installedAt" type="date" value={d.installedAt ?? ''} /></label
>
<label>removed on<input name="removedAt" type="date" value={d.removedAt ?? ''} /></label>
<label class="wide"
>images (one per line)<textarea name="imageUrls" rows="2"
>{(d.imageUrls ?? []).join('\n')}</textarea
></label
>
<label class="wide"
>notes (public)<textarea name="notes" rows="3">{d.notes ?? ''}</textarea></label
>
<label class="check"
><input type="checkbox" name="hidden" checked={d.hidden ?? false} /> hidden (kept for history,
not shown)</label
>
{#if problem}<p class="wide text-sm text-red-500" role="alert">{problem}</p>{/if}
<div class="buttons wide">
<button type="button" class="action" onclick={close}>Cancel</button>
<button type="submit" class="action primary">Save</button>
</div>
</form>
{#if d.id}
<form
method="POST"
action="?/deleteDevice"
use:enhance={submit(close)}
onsubmit={confirmDelete(`${d.name} and all its parts`)}
class="mt-2"
>
<input type="hidden" name="id" value={d.id} />
<button type="submit" class="text-sm text-red-500 hover:underline"
>Delete device and its parts</button
>
</form>
{/if}
{/snippet}
</Modal>
{/if}
{#if editGame}
{@const g = editGame}
<Modal
title={g.id ? `Edit ${g.game}` : 'Add a game'}
onclose={() => {
editGame = null;
problem = '';
}}
>
{#snippet children(close)}
<form method="POST" action="?/saveGame" use:enhance={submit(close)} class="form">
<input type="hidden" name="id" value={g.id ?? ''} />
<label class="wide">game<input name="game" value={g.game ?? ''} required /></label>
<label>cm/360<input name="cmPer360" inputmode="decimal" value={g.cmPer360 ?? ''} /></label>
<label
>yaw (°/count at sens 1)<input
name="yaw"
inputmode="decimal"
value={g.yaw ?? ''}
/></label
>
<label
>in-game sens now<input
name="inGameSens"
inputmode="decimal"
value={g.inGameSens ?? ''}
/></label
>
<label
>set with mouse<select name="mouseId" value={g.mouseId ?? ''}>
<option value="">–</option>
{#each mice as m (m.id)}<option value={m.id}>{m.brand} {m.model}</option>{/each}
</select></label
>
<label>FOV<input name="fov" value={g.fov ?? ''} /></label>
<label
>sort order<input
name="sortOrder"
type="number"
min="0"
value={g.sortOrder ?? 100}
/></label
>
<label class="wide"
>notes (only you)<textarea name="notes" rows="2">{g.notes ?? ''}</textarea></label
>
{#if problem}<p class="wide text-sm text-red-500" role="alert">{problem}</p>{/if}
<div class="buttons wide">
<button type="button" class="action" onclick={close}>Cancel</button>
<button type="submit" class="action primary">Save</button>
</div>
</form>
{#if g.id}
<form
method="POST"
action="?/deleteGame"
use:enhance={submit(close)}
onsubmit={confirmDelete(g.game ?? 'this game')}
class="mt-2"
>
<input type="hidden" name="id" value={g.id} />
<button type="submit" class="text-sm text-red-500 hover:underline">Delete game</button>
</form>
{/if}
{/snippet}
</Modal>
{/if}
<style>
.form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.7rem;
}
.form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.78rem;
color: var(--site-muted);
}
.form .wide,
.form .check,
.form .buttons {
grid-column: 1 / -1;
}
.form .check {
flex-direction: row;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: var(--site-text);
}
.form input:not([type='checkbox']),
.form select,
.form textarea {
border: 1px solid var(--site-border);
border-radius: 0.5rem;
background: var(--site-bg);
color: var(--site-text);
padding: 0.4rem 0.6rem;
font-size: 0.9rem;
}
.form textarea {
font-family: ui-monospace, monospace;
font-size: 0.8rem;
}
.form .buttons {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.action {
display: inline-flex;
align-items: center;
gap: 0.3rem;
border: 1px solid var(--site-border);
border-radius: 0.6rem;
padding: 0.35rem 0.7rem;
font-size: 0.85rem;
transition:
background 0.15s,
border-color 0.15s;
}
.action:hover {
background: var(--site-hover);
}
.action.primary {
background: var(--site-text);
color: var(--site-bg);
border-color: var(--site-text);
}
.action.primary:hover {
opacity: 0.85;
}
</style>

257
src/lib/pc/Lightbox.svelte Normal file
View File

@@ -0,0 +1,257 @@
<script lang="ts">
import { onMount } from 'svelte';
import ChevronLeft from 'lucide-svelte/icons/chevron-left';
import ChevronRight from 'lucide-svelte/icons/chevron-right';
import ExternalLink from 'lucide-svelte/icons/external-link';
import X from 'lucide-svelte/icons/x';
import PcIcon from './PcIcon.svelte';
import { shortDate, specKey, type LightboxItem } from './shared';
// Native <dialog>: focus trap, Esc and backdrop for free. Arrow keys / swipe page the photos.
let { item, icon, onclose }: { item: LightboxItem; icon: string; onclose: () => void } = $props();
let dialog = $state<HTMLDialogElement>();
let i = $state(0);
onMount(() => dialog?.showModal());
const n = $derived(item.images.length);
const go = (d: number) => n && (i = (i + d + n) % n);
let touchX = 0;
</script>
<dialog
bind:this={dialog}
{onclose}
onclick={(e) => e.target === dialog && dialog?.close()}
onkeydown={(e) => {
if (e.key === 'ArrowRight') go(1);
if (e.key === 'ArrowLeft') go(-1);
}}
aria-label={item.title}
class="lightbox"
>
<div class="grid gap-0 md:grid-cols-[minmax(0,1.25fr)_minmax(0,1fr)]">
<div
class="stage"
role="region"
aria-label="Photos"
ontouchstart={(e) => (touchX = e.touches[0].clientX)}
ontouchend={(e) => {
const dx = e.changedTouches[0].clientX - touchX;
if (Math.abs(dx) > 40) go(dx < 0 ? 1 : -1);
}}
>
{#if n}
<img src={item.images[i]} alt="{item.title} ({i + 1} of {n})" decoding="async" />
{#if n > 1}
<button type="button" class="nav left" onclick={() => go(-1)} aria-label="Previous photo"
><ChevronLeft size={20} /></button
>
<button type="button" class="nav right" onclick={() => go(1)} aria-label="Next photo"
><ChevronRight size={20} /></button
>
{/if}
{:else}
<div class="flex h-full items-center justify-center">
<PcIcon category={icon} class="size-24" />
</div>
{/if}
</div>
<div class="info">
<div class="mb-3 flex items-start gap-2">
<h2 class="flex-1 text-lg leading-snug font-semibold">{item.title}</h2>
<button type="button" class="close" onclick={() => dialog?.close()} aria-label="Close"
><X size={18} /></button
>
</div>
{#if n > 1}
<div class="thumbs">
{#each item.images as src, j (src)}
<button
type="button"
class:on={j === i}
onclick={() => (i = j)}
aria-label="Photo {j + 1}"
>
<img src={src.replace(/\.webp$/, '.t.webp')} alt="" loading="lazy" />
</button>
{/each}
</div>
{/if}
<div class="space-y-3">
{#each item.entries as e, k (e.id ?? `d${k}`)}
<section class="entry">
<p class="kicker">
{e.kicker}{#if e.qty > 1}&nbsp;· ×{e.qty}{/if}
{#each e.badges as b (b)}<span class="badge">{b}</span>{/each}
</p>
<p class="font-medium">{e.name}</p>
<p class="text-xs text-[var(--site-muted)]">
{#if e.since}in the setup since {shortDate(e.since)}{:else}since ?{/if}{#if e.until}&nbsp;·
until
{shortDate(e.until)}{/if}
</p>
{#if Object.keys(e.specs).length}
<ul class="mt-1.5 flex flex-wrap gap-1">
{#each Object.entries(e.specs) as [key, v] (key)}
<li class="chip">
<span>{specKey(key)}</span>{v === true ? 'yes' : v === false ? 'no' : v}
</li>
{/each}
</ul>
{/if}
{#if e.note}<p class="mt-1.5 text-xs text-[var(--site-muted)]">{e.note}</p>{/if}
{#if e.url}
<a href={e.url} target="_blank" rel="noreferrer" class="link"
>product page <ExternalLink size={12} /></a
>
{/if}
</section>
{/each}
</div>
</div>
</div>
</dialog>
<style>
.lightbox {
margin: auto;
width: min(64rem, calc(100vw - 2rem));
max-height: calc(100dvh - 2rem);
overflow: hidden;
border: 1px solid var(--site-border);
border-radius: 1.25rem;
background: var(--site-bg);
color: var(--site-text);
padding: 0;
box-shadow: 0 30px 80px -20px rgb(0 0 0 / 0.7);
}
.lightbox::backdrop {
background: rgb(0 0 0 / 0.7);
backdrop-filter: blur(6px);
}
.stage {
position: relative;
height: min(34rem, 60dvh);
background: #fff;
}
.stage img {
width: 100%;
height: 100%;
object-fit: contain;
padding: 1.5rem;
}
.nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
display: flex;
padding: 0.5rem;
border-radius: 999px;
background: rgb(0 0 0 / 0.55);
color: #fff;
transition: background 0.15s;
}
.nav:hover {
background: rgb(0 0 0 / 0.8);
}
.nav.left {
left: 0.75rem;
}
.nav.right {
right: 0.75rem;
}
.info {
max-height: min(34rem, 60dvh);
overflow-y: auto;
padding: 1.1rem 1.2rem;
}
@media (max-width: 767px) {
.stage {
height: 45dvh;
}
.info {
max-height: 40dvh;
}
}
.close {
border-radius: 0.5rem;
padding: 0.3rem;
color: var(--site-muted);
}
.close:hover {
background: var(--site-hover);
color: var(--site-text);
}
.thumbs {
display: flex;
gap: 0.4rem;
margin-bottom: 0.9rem;
overflow-x: auto;
}
.thumbs button {
flex-shrink: 0;
width: 3.4rem;
height: 3.4rem;
border-radius: 0.6rem;
background: #fff;
border: 2px solid transparent;
overflow: hidden;
opacity: 0.7;
transition: opacity 0.15s;
}
.thumbs button.on,
.thumbs button:hover {
opacity: 1;
border-color: #7c3aed;
}
.thumbs img {
width: 100%;
height: 100%;
object-fit: contain;
padding: 0.2rem;
}
.entry + .entry {
border-top: 1px solid var(--site-border);
padding-top: 0.75rem;
}
.kicker {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--site-muted);
}
.badge {
border: 1px solid var(--site-border);
border-radius: 999px;
padding: 0 0.4rem;
font-size: 0.62rem;
letter-spacing: 0;
text-transform: none;
}
.chip {
display: inline-flex;
gap: 0.3rem;
border-radius: 999px;
background: var(--site-hover);
padding: 0.05rem 0.5rem;
font-size: 0.72rem;
}
.chip span {
color: var(--site-muted);
}
.link {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-top: 0.4rem;
font-size: 0.78rem;
color: var(--site-muted);
}
.link:hover {
color: var(--site-text);
text-decoration: underline;
}
</style>

View File

@@ -22,6 +22,10 @@
import Speaker from 'lucide-svelte/icons/speaker';
import Tablet from 'lucide-svelte/icons/tablet';
import Zap from 'lucide-svelte/icons/zap';
import Mic from 'lucide-svelte/icons/mic';
import RectangleHorizontal from 'lucide-svelte/icons/rectangle-horizontal';
import Webcam from 'lucide-svelte/icons/webcam';
import Armchair from 'lucide-svelte/icons/armchair';
import { CATEGORY_META, DEVICE_KIND_META } from './shared';
// A gradient tile with a white icon, for a part category or a device kind.
@@ -49,6 +53,10 @@
audio: Speaker,
controller: Gamepad2,
accessory: Package,
microphone: Mic,
mousepad: RectangleHorizontal,
webcam: Webcam,
chair: Armchair,
other: Box,
desktop: PcCase,
laptop: Laptop,
@@ -63,7 +71,9 @@
CATEGORY_META.other
);
const Icon = $derived(icons[key as keyof typeof icons] ?? Box);
const light = $derived(key === 'case' || key === 'psu' || key === 'controller');
const light = $derived(
key === 'case' || key === 'psu' || key === 'controller' || key === 'chair'
);
</script>
<span

317
src/lib/pc/SetupCard.svelte Normal file
View File

@@ -0,0 +1,317 @@
<script lang="ts">
import Ellipsis from 'lucide-svelte/icons/ellipsis';
import Pencil from 'lucide-svelte/icons/pencil';
import PcIcon from './PcIcon.svelte';
import {
displayName,
keySpec,
money,
shortDate,
slotIcon,
slotLabel,
thumb,
units,
usageBadges,
type Part
} from './shared';
// One slot of the setup on the chosen day: thumb, slot label (×qty), name, one key spec.
// The ⋯ popover (owner only, never rendered for visitors) holds the bookkeeping per part.
let {
parts,
slot,
owner = false,
compact = false,
onopen,
onedit
}: {
parts: Part[];
slot: string;
owner?: boolean;
compact?: boolean;
onopen: () => void;
onedit: (p: Part) => void;
} = $props();
const first = $derived(parts[0]);
const qty = $derived(parts.reduce((s, p) => s + units(p), 0));
const image = $derived(parts.flatMap((p) => p.imageUrls)[0] ?? null);
const names = $derived([...new Set(parts.map(displayName))]);
const spec = $derived(keySpec(first));
const badges = $derived([...new Set(parts.flatMap(usageBadges))]);
const flag = $derived(badges.find((b) => b !== 'long ago') ?? null);
const popId = $derived(`pop-${first.id}`);
</script>
<div class="card" class:compact>
<button
type="button"
class="open"
onclick={onopen}
aria-label="{slotLabel(slot, first.category)}: {names.join(', ')}"
>
{#if image}
<span class="thumb"><img src={thumb(image)} alt="" loading="lazy" decoding="async" /></span>
{:else}
<span class="thumb empty"
><PcIcon category={slotIcon(slot, first.category)} class="size-8" /></span
>
{/if}
{#if compact && flag}<span class="flag" class:warn={flag === 'model TBD'}
>{flag.replace('model ', '')}</span
>{/if}
<span class="min-w-0 flex-1">
<span class="label">
{slotLabel(slot, first.category)}{#if qty > 1}<span class="qty">×{qty}</span>{/if}
{#if !compact}{#each badges as b (b)}<span class="badge" class:warn={b === 'model TBD'}
>{b}</span
>{/each}{/if}
</span>
<span class="name" title={names.join(' + ')}>{names.join(' + ')}</span>
{#if spec && !compact}<span class="spec">{spec}</span>{/if}
</span>
</button>
{#if owner}
<button type="button" class="more" popovertarget={popId} aria-label="Owner details"
><Ellipsis size={15} /></button
>
<div id={popId} popover="auto" class="pop">
{#each parts as p (p.id)}
<div class="row">
<div class="flex items-start gap-2">
<p class="flex-1 font-medium">{p.brand} {p.model}</p>
<button type="button" class="edit" onclick={() => onedit(p)} aria-label="Edit {p.model}"
><Pencil size={13} /></button
>
</div>
<dl>
{#if p.totalPriceCents != null}<dt>paid</dt>
<dd>{money(p.totalPriceCents)}</dd>{/if}
{#if p.shop}<dt>shop</dt>
<dd>{p.shop}</dd>{/if}
{#if p.orderDate}<dt>ordered</dt>
<dd>{shortDate(p.orderDate)}</dd>{/if}
{#if p.orderNumber}<dt>order</dt>
<dd>{p.orderNumber}</dd>{/if}
{#if p.invoiceNumber}<dt>invoice</dt>
<dd>{p.invoiceNumber}</dd>{/if}
{#if p.warrantyUntil}<dt>warranty</dt>
<dd>{shortDate(p.warrantyUntil)}</dd>{/if}
<dt>status</dt>
<dd>{p.status}{p.verified ? '' : ' · unverified'}{p.usage ? ` · ${p.usage}` : ''}</dd>
{#if p.source}<dt>source</dt>
<dd>{p.source}</dd>{/if}
</dl>
{#if p.notes}<p class="note">{p.notes}</p>{/if}
</div>
{/each}
</div>
{/if}
</div>
<style>
.card {
position: relative;
height: 100%;
}
.open {
display: flex;
align-items: center;
gap: 0.65rem;
width: 100%;
height: 100%;
min-height: 3.9rem;
padding: 0.45rem 0.6rem 0.45rem 0.45rem;
border: 1px solid var(--site-border);
border-radius: 0.9rem;
background: var(--site-surface);
text-align: left;
transition:
background 0.15s,
border-color 0.15s,
transform 0.15s;
}
.compact .open {
position: relative;
min-height: 3.2rem;
padding: 0.3rem 0.5rem 0.3rem 0.3rem;
}
.open:hover {
background: var(--site-hover);
border-color: color-mix(in srgb, var(--site-text) 30%, var(--site-border));
}
.open:active {
transform: scale(0.985);
}
.open:focus-visible {
outline: 2px solid #7c3aed;
outline-offset: 2px;
}
.thumb {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 3rem;
height: 3rem;
border-radius: 0.65rem;
background: #fff;
overflow: hidden;
}
.compact .thumb {
width: 2.5rem;
height: 2.5rem;
}
.thumb img {
width: 100%;
height: 100%;
object-fit: contain;
padding: 0.2rem;
}
.thumb.empty {
background: transparent;
border: 1px dashed var(--site-border);
}
.label {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--site-muted);
white-space: nowrap;
overflow: hidden;
}
.qty {
color: var(--site-text);
}
.badge {
border: 1px solid var(--site-border);
border-radius: 999px;
padding: 0 0.35rem;
font-size: 0.58rem;
letter-spacing: 0;
text-transform: none;
}
.badge.warn {
border-color: #ca8a04;
color: #fde047;
}
.name {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 0.83rem;
font-weight: 500;
line-height: 1.2;
}
.compact .label {
letter-spacing: 0.03em;
}
.compact .name {
font-size: 0.74rem;
line-height: 1.15;
}
.flag {
position: absolute;
left: 0.3rem;
bottom: 0.3rem;
width: 2.5rem;
border-radius: 0 0 0.5rem 0.5rem;
background: rgb(0 0 0 / 0.72);
color: #fff;
font-size: 0.55rem;
line-height: 1.4;
text-align: center;
pointer-events: none;
}
.flag.warn {
color: #fde047;
}
.spec {
display: block;
margin-top: 0.1rem;
font-size: 0.7rem;
color: var(--site-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.more {
position: absolute;
top: 0.3rem;
right: 0.3rem;
display: flex;
padding: 0.15rem;
border-radius: 0.4rem;
color: var(--site-muted);
opacity: 0;
transition: opacity 0.15s;
}
.card:hover .more,
.more:focus-visible {
opacity: 1;
}
.more:hover {
background: var(--site-bg);
color: var(--site-text);
}
.pop {
position: fixed;
inset: auto;
margin: auto;
width: min(24rem, calc(100vw - 2rem));
max-height: 70dvh;
overflow-y: auto;
padding: 0.8rem 0.9rem;
border: 1px solid var(--site-border);
border-radius: 0.9rem;
background: var(--site-bg);
color: var(--site-text);
font-size: 0.8rem;
box-shadow: 0 20px 50px -15px rgb(0 0 0 / 0.7);
}
.pop:popover-open {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.pop::backdrop {
background: rgb(0 0 0 / 0.35);
}
.row + .row {
margin-top: 0.7rem;
padding-top: 0.7rem;
border-top: 1px solid var(--site-border);
}
dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.1rem 0.7rem;
margin-top: 0.3rem;
}
dt {
color: var(--site-muted);
}
dd {
overflow-wrap: anywhere;
}
.note {
margin-top: 0.35rem;
color: var(--site-muted);
font-size: 0.75rem;
}
.edit {
display: flex;
padding: 0.25rem;
border: 1px solid var(--site-border);
border-radius: 0.4rem;
}
.edit:hover {
background: var(--site-hover);
}
</style>

View File

@@ -0,0 +1,308 @@
<script lang="ts">
import RotateCcw from 'lucide-svelte/icons/rotate-ccw';
import History from 'lucide-svelte/icons/history';
import { eventLabel, monthYear, shortDate, type TimelineEvent } from './shared';
// One timeline for the whole setup: parts were swapped one at a time, so every install or
// removal date is a tick. Drag snaps to ticks within ±10px; arrow keys jump between them.
let {
min,
max,
events,
value = $bindable()
}: { min: string; max: string; events: TimelineEvent[]; value: string } = $props();
const DAY = 86_400_000;
const toDay = (d: string) => Math.round(Date.parse(`${d}T00:00:00Z`) / DAY);
const fromDay = (n: number) => new Date(n * DAY).toISOString().slice(0, 10);
const lo = $derived(toDay(min));
const hi = $derived(toDay(max));
const span = $derived(Math.max(1, hi - lo));
const ticks = $derived(
events
.filter((e) => e.date >= min && e.date <= max)
.map((e) => ({ ...e, day: toDay(e.date), pct: ((toDay(e.date) - lo) / span) * 100 }))
);
const years = $derived.by(() => {
const out: { y: number; pct: number }[] = [];
for (let y = Number(min.slice(0, 4)) + 1; y <= Number(max.slice(0, 4)); y++)
out.push({ y, pct: ((toDay(`${y}-01-01`) - lo) / span) * 100 });
return out;
});
const current = $derived(toDay(value));
/** Latest change on or before the chosen day. */
const last = $derived([...ticks].reverse().find((t) => t.day <= current) ?? null);
let width = $state(0);
let hover = $state<(typeof ticks)[number] | null>(null);
function oninput(e: Event) {
let day = Number((e.currentTarget as HTMLInputElement).value);
const perPx = span / Math.max(1, width);
let best: number | null = null;
for (const t of ticks)
if (
Math.abs(t.day - day) <= 10 * perPx &&
(best === null || Math.abs(t.day - day) < Math.abs(best - day))
)
best = t.day;
if (best !== null) day = best;
value = fromDay(day);
}
function onkeydown(e: KeyboardEvent) {
if (e.shiftKey || (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight')) return;
e.preventDefault();
const next =
e.key === 'ArrowRight'
? (ticks.find((t) => t.day > current)?.day ?? hi)
: ([...ticks].reverse().find((t) => t.day < current)?.day ?? lo);
value = fromDay(next);
}
</script>
<div class="slider">
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 sm:flex-nowrap">
<span class="icon" aria-hidden="true"><History size={16} /></span>
<div class="track order-last sm:order-none" bind:clientWidth={width}>
<div class="rail"></div>
<div class="fill" style:width="{((current - lo) / span) * 100}%"></div>
{#each years.filter((y, i) => width > 520 || i % 2 === 0) as y (y.y)}
<span class="year" style:left="{y.pct}%">{y.y}</span>
{/each}
{#each ticks as t (t.date)}
<button
type="button"
class="tick"
class:past={t.day <= current}
class:on={t.day === current}
style:left="{t.pct}%"
aria-label="{shortDate(t.date)}: {eventLabel(t)}"
onmouseenter={() => (hover = t)}
onmouseleave={() => (hover = null)}
onfocus={() => (hover = t)}
onblur={() => (hover = null)}
onclick={() => (value = t.date)}
></button>
{/each}
<input
type="range"
min={lo}
max={hi}
step="1"
value={current}
{oninput}
{onkeydown}
aria-label="Show the setup on"
aria-valuetext={shortDate(value)}
/>
{#if hover}
<div class="tip" style:left="clamp(8rem, {hover.pct}%, calc(100% - 8rem))">
<strong>{monthYear(hover.date)}</strong>
{eventLabel(hover)}
</div>
{/if}
</div>
<div class="when ml-auto sm:ml-0">
<span class="date tabular-nums">{shortDate(value)}</span>
<button type="button" class="today" disabled={value === max} onclick={() => (value = max)}
><RotateCcw size={13} /> today</button
>
</div>
</div>
<p class="last" aria-live="polite">
{#if last}
<span class="text-[var(--site-text)]">{monthYear(last.date)}</span> · {eventLabel(last)}
{:else}
before the first part
{/if}
</p>
</div>
<style>
.slider {
padding: 0.4rem 0.9rem 0.3rem;
border: 1px solid var(--site-border);
border-radius: 1rem;
background: color-mix(in srgb, var(--site-surface) 92%, transparent);
backdrop-filter: blur(10px);
}
.icon {
display: flex;
align-items: center;
justify-content: center;
width: 1.9rem;
height: 1.9rem;
flex-shrink: 0;
border-radius: 0.6rem;
color: #fff;
background: linear-gradient(135deg, #22d3ee, #7c3aed);
}
.track {
position: relative;
flex: 1 1 100%;
height: 2.1rem;
}
@media (min-width: 640px) {
.track {
flex: 1 1 0;
}
}
.rail,
.fill {
position: absolute;
top: 0.55rem;
left: 0;
height: 0.3rem;
border-radius: 999px;
}
.rail {
right: 0;
background: var(--site-hover);
}
.fill {
background: linear-gradient(90deg, #22d3ee, #7c3aed, #db2777);
}
.year {
position: absolute;
top: 1.15rem;
transform: translateX(-50%);
font-size: 0.66rem;
color: var(--site-muted);
pointer-events: none;
}
.year::before {
content: '';
position: absolute;
left: 50%;
top: -0.35rem;
width: 1px;
height: 0.25rem;
background: var(--site-border);
}
.tick {
position: absolute;
top: 0.3rem;
z-index: 2;
width: 0.5rem;
height: 0.8rem;
margin-left: -0.25rem;
border-radius: 999px;
background: var(--site-muted);
opacity: 0.6;
transition:
transform 0.12s,
opacity 0.12s,
background 0.12s;
}
.tick.past {
background: var(--site-text);
opacity: 0.9;
}
.tick.on,
.tick:hover,
.tick:focus-visible {
transform: scaleY(1.45);
background: #db2777;
opacity: 1;
}
input[type='range'] {
position: absolute;
inset: 0 0 auto 0;
width: 100%;
height: 1.4rem;
margin: 0;
background: transparent;
appearance: none;
cursor: pointer;
z-index: 1;
}
input[type='range']::-webkit-slider-runnable-track {
height: 1.4rem;
background: transparent;
}
input[type='range']::-moz-range-track {
background: transparent;
}
input[type='range']::-webkit-slider-thumb {
appearance: none;
width: 1.15rem;
height: 1.15rem;
margin-top: 0.125rem;
border-radius: 999px;
background: var(--site-text);
border: 3px solid var(--site-bg);
box-shadow:
0 0 0 1px var(--site-border),
0 4px 12px rgb(0 0 0 / 0.4);
}
input[type='range']::-moz-range-thumb {
width: 1rem;
height: 1rem;
border-radius: 999px;
background: var(--site-text);
border: 3px solid var(--site-bg);
}
input[type='range']:focus-visible {
outline: none;
}
input[type='range']:focus-visible::-webkit-slider-thumb {
box-shadow: 0 0 0 3px #7c3aed;
}
.tip {
position: absolute;
bottom: calc(100% + 0.35rem);
z-index: 30;
transform: translateX(-50%);
max-width: 22rem;
width: max-content;
padding: 0.35rem 0.6rem;
border: 1px solid var(--site-border);
border-radius: 0.6rem;
background: var(--site-bg);
font-size: 0.75rem;
color: var(--site-muted);
box-shadow: 0 10px 30px -10px rgb(0 0 0 / 0.6);
pointer-events: none;
}
.tip strong {
color: var(--site-text);
margin-right: 0.3rem;
}
.when {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.date {
min-width: 6.6rem;
text-align: right;
font-weight: 600;
font-size: 0.95rem;
}
.today {
display: inline-flex;
align-items: center;
gap: 0.25rem;
border: 1px solid var(--site-border);
border-radius: 0.6rem;
padding: 0.25rem 0.55rem;
font-size: 0.78rem;
transition: background 0.15s;
}
.today:hover:not(:disabled) {
background: var(--site-hover);
}
.today:disabled {
opacity: 0.4;
}
.last {
margin: 0.05rem 0 0 2.65rem;
font-size: 0.72rem;
color: var(--site-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

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) }))
]
};
}

View File

@@ -73,7 +73,7 @@ const publicPages: Record<string, { title: string; description: string }> = {
'/trackers/pc': {
title: 'PC setup',
description:
'Stan Runge’s PC setup: every part with specs and prices, his devices and servers, the planned next build and mouse sensitivities.'
'Stan Runge’s PC setup: his PC, desk and devices on one screen with a time slider back to 2021, the planned next build and mouse sensitivities.'
},
'/lists': { title: 'Lists', description: 'Lists collected and maintained by Stan Runge.' },
'/projects/game-tracker': {

View File

@@ -1,5 +1,6 @@
import { sql } from 'drizzle-orm';
import {
type AnyPgColumn,
bigint,
boolean,
check,
@@ -49,9 +50,30 @@ export const HARDWARE_CATEGORIES = [
'audio',
'controller',
'accessory',
'microphone',
'mousepad',
'webcam',
'chair',
'other'
] as const;
export const HARDWARE_STATUSES = ['in_use', 'planned', 'spare', 'retired', 'sold'] as const;
/** Page sections on /trackers/pc. */
export const HARDWARE_SECTIONS = ['pc', 'desk', 'audio', 'sim'] as const;
/**
* How a part shows on the time slider: current, stored (owned, not set up), previous (gone,
* dated by removed_at), work-issued, placeholder (model TBD), legacy (owned long before the
* mail history, still owned) and legacy-previous (long ago and gone, date unknown).
*/
export const HARDWARE_USAGES = [
'current',
'stored',
'previous',
'work-issued',
'placeholder',
'planned',
'legacy',
'legacy-previous'
] as const;
const list = (values: readonly string[]) => sql.raw(values.map((v) => `'${v}'`).join(', '));
@@ -85,6 +107,15 @@ export const hardwareDevices = hardwareSchema.table(
imageUrl: text('image_url'),
sortOrder: integer('sort_order').notNull().default(100),
notes: text('notes'),
/** Time slider: when it joined / left the setup (null = unknown / still here). */
installedAt: date('installed_at'),
removedAt: date('removed_at'),
/** Self-hosted product photos (/hardware/...webp), first is the thumbnail. */
imageUrls: text('image_urls').array().notNull().default([]),
/** Where each image was downloaded from (attribution), same order as image_urls. */
imageSources: text('image_sources').array().notNull().default([]),
/** Kept for history but not shown (e.g. a PC built for someone else). */
hidden: boolean('hidden').notNull().default(false),
createdAt: timestamptz('created_at').notNull().defaultNow(),
updatedAt: timestamptz('updated_at')
.notNull()
@@ -133,6 +164,23 @@ export const hardwareComponents = hardwareSchema.table(
notes: text('notes'),
/** user_list | gmail | user_statement | manual | plan. */
source: text('source'),
/** Stable slug for merges/seeds, e.g. cpu-12900kf. */
slug: text('slug').unique(),
/** Page section (pc/desk/audio/sim) and slot within it (cpu, fans-case, mouse-gaming…). */
section: text('section'),
slot: text('slot'),
/** Time slider: in the setup from installed_at until removed_at (null = unknown / still). */
installedAt: date('installed_at'),
removedAt: date('removed_at'),
replacesId: bigint('replaces_id', { mode: 'number' }).references(
(): AnyPgColumn => hardwareComponents.id,
{ onDelete: 'set null' }
),
usage: text('usage'),
imageUrls: text('image_urls').array().notNull().default([]),
imageSources: text('image_sources').array().notNull().default([]),
/** Kept for history but not shown (wrong placeholders, a friend's build). */
hidden: boolean('hidden').notNull().default(false),
createdAt: timestamptz('created_at').notNull().defaultNow(),
updatedAt: timestamptz('updated_at')
.notNull()
@@ -143,6 +191,15 @@ export const hardwareComponents = hardwareSchema.table(
check('components_category_check', sql`category in (${list(HARDWARE_CATEGORIES)})`),
check('components_status_check', sql`status in (${list(HARDWARE_STATUSES)})`),
check('components_quantity_check', sql`quantity > 0`),
check(
'components_section_check',
sql`section is null or section in (${list(HARDWARE_SECTIONS)})`
),
check('components_usage_check', sql`usage is null or usage in (${list(HARDWARE_USAGES)})`),
check(
'components_dates_check',
sql`removed_at is null or installed_at is null or removed_at >= installed_at`
),
check('components_unit_price_cents_check', sql`unit_price_cents >= 0`),
check('components_total_price_cents_check', sql`total_price_cents >= 0`),
check(
@@ -152,10 +209,21 @@ export const hardwareComponents = hardwareSchema.table(
unique('components_dedupe_uq').on(t.deviceId, t.model, t.orderNumber).nullsNotDistinct(),
index('components_device_idx').on(t.deviceId),
index('components_category_idx').on(t.category),
index('components_specs_gin').using('gin', t.specs)
index('components_specs_gin').using('gin', t.specs),
index('components_timeline_idx').on(t.installedAt, t.removedAt)
]
);
/** Stan's own photos of the whole setup (hero carousel); taken_at lets the slider pick one. */
export const hardwareSetupPhotos = hardwareSchema.table('setup_photos', {
id: bigint('id', { mode: 'number' }).primaryKey().generatedAlwaysAsIdentity(),
url: text('url').notNull(),
caption: text('caption'),
takenAt: date('taken_at'),
sortOrder: integer('sort_order').notNull().default(100),
createdAt: timestamptz('created_at').notNull().defaultNow()
});
/**
* One row per game: the physical sensitivity Stan wants (cm per 360° turn) and, where the
* game's yaw is known (degrees per mouse count at sens 1, e.g. CS2 0.022), the in-game value

View File

@@ -5,19 +5,22 @@ import {
HARDWARE_CATEGORIES,
HARDWARE_DEVICE_KINDS,
HARDWARE_DEVICE_STATUSES,
HARDWARE_SECTIONS,
HARDWARE_STATUSES,
HARDWARE_USAGES,
hardwareComponents,
hardwareDevices,
hardwareSensitivities
hardwareSensitivities,
hardwareSetupPhotos
} from '#lib/server/db/schema/index.js';
import { textToSpecs, type Device, type Game, type Part } from '#lib/pc/shared.js';
import { textToSpecs, type Device, type Game, type Part, type SetupPhoto } from '#lib/pc/shared.js';
import { asc, eq, sql } from 'drizzle-orm';
import * as z from 'zod';
const ORDER = [...HARDWARE_CATEGORIES];
export async function loadSetup(owner: boolean) {
const [devices, parts, games] = await Promise.all([
const [devices, parts, games, photos] = await Promise.all([
db
.select()
.from(hardwareDevices)
@@ -33,10 +36,18 @@ export async function loadSetup(owner: boolean) {
db
.select()
.from(hardwareSensitivities)
.orderBy(asc(hardwareSensitivities.sortOrder), asc(hardwareSensitivities.game))
.orderBy(asc(hardwareSensitivities.sortOrder), asc(hardwareSensitivities.game)),
db
.select()
.from(hardwareSetupPhotos)
.orderBy(asc(hardwareSetupPhotos.sortOrder), asc(hardwareSetupPhotos.id))
]);
// Hidden rows (a friend's build, wrong placeholders) are kept for history, owner-only.
const hiddenDevices = new Set(devices.filter((d) => d.hidden).map((d) => d.id));
const byDevice = new Map<number, Part[]>();
for (const p of parts) {
const hidden = p.hidden || hiddenDevices.has(p.deviceId);
if (hidden && !owner) continue;
const part: Part = {
id: p.id,
deviceId: p.deviceId,
@@ -45,11 +56,12 @@ export async function loadSetup(owner: boolean) {
model: p.model,
quantity: p.quantity,
specs: p.specs ?? {},
unitPriceCents: p.unitPriceCents,
totalPriceCents: p.totalPriceCents,
shop: p.shop,
// Prices, shops and warranty are bookkeeping: owner-only (the ⋯ popover).
unitPriceCents: owner ? p.unitPriceCents : null,
totalPriceCents: owner ? p.totalPriceCents : null,
shop: owner ? p.shop : null,
orderDate: p.orderDate,
warrantyUntil: p.warrantyUntil,
warrantyUntil: owner ? p.warrantyUntil : null,
status: p.status,
verified: p.verified,
dpi: p.dpi,
@@ -59,32 +71,54 @@ export async function loadSetup(owner: boolean) {
notes: owner ? p.notes : null,
orderNumber: owner ? p.orderNumber : null,
invoiceNumber: owner ? p.invoiceNumber : null,
source: owner ? p.source : null
source: owner ? p.source : null,
slug: p.slug,
section: p.section,
slot: p.slot,
installedAt: p.installedAt,
removedAt: p.removedAt,
replacesId: p.replacesId,
usage: p.usage,
imageUrls: p.imageUrls ?? [],
hidden
};
const list = byDevice.get(p.deviceId) ?? [];
list.push(part);
byDevice.set(p.deviceId, list);
}
const out: Device[] = devices.map((d) => ({
id: d.id,
name: d.name,
label: d.label,
hostnames: d.hostnames,
kind: d.kind,
status: d.status,
brand: d.brand,
model: d.model,
specs: d.specs ?? {},
priceCents: d.priceCents,
monthlyCents: d.monthlyCents,
budgetCents: d.budgetCents,
shop: d.shop,
purchaseDate: d.purchaseDate,
url: d.url,
imageUrl: d.imageUrl,
sortOrder: d.sortOrder,
notes: d.notes,
parts: byDevice.get(d.id) ?? []
const out: Device[] = devices
.filter((d) => owner || !d.hidden)
.map((d) => ({
id: d.id,
name: d.name,
label: d.label,
hostnames: d.hostnames,
kind: d.kind,
status: d.status,
brand: d.brand,
model: d.model,
specs: d.specs ?? {},
priceCents: owner ? d.priceCents : null,
monthlyCents: owner ? d.monthlyCents : null,
budgetCents: d.budgetCents,
shop: d.shop,
purchaseDate: d.purchaseDate,
url: d.url,
imageUrl: d.imageUrl,
sortOrder: d.sortOrder,
notes: d.notes,
installedAt: d.installedAt,
removedAt: d.removedAt,
imageUrls: d.imageUrls ?? [],
hidden: d.hidden,
parts: byDevice.get(d.id) ?? []
}));
const setupPhotos: SetupPhoto[] = photos.map((p) => ({
id: p.id,
url: p.url,
caption: p.caption,
takenAt: p.takenAt,
sortOrder: p.sortOrder
}));
const sens: Game[] = games.map((g) => ({
id: g.id,
@@ -97,7 +131,7 @@ export async function loadSetup(owner: boolean) {
notes: owner ? g.notes : null,
sortOrder: g.sortOrder
}));
return { devices: out, games: sens };
return { devices: out, games: sens, photos: setupPhotos };
}
// ---- form parsing (owner writes) ----
@@ -192,6 +226,41 @@ const specs = z
.default('')
.transform((v) => textToSpecs(v));
const pick = <T extends readonly [string, ...string[]]>(values: T) => z.enum(values);
const pickOrNull = <T extends readonly [string, ...string[]]>(values: T) =>
z
.string()
.default('')
.transform((v) => (v === '' ? null : v))
.pipe(z.enum(values).nullable());
const checkbox = z
.string()
.optional()
.transform((v) => v === 'on' || v === 'true');
/** One image per line: self-hosted /hardware/... paths or http(s) links. */
const images = z
.string()
.max(10_000)
.default('')
.transform((v) =>
v
.split('\n')
.map((l) => l.trim())
.filter((l) => /^(\/|https?:\/\/)/i.test(l))
.slice(0, 30)
);
const slotText = z
.string()
.trim()
.toLowerCase()
.max(60)
.transform((v) => (v === '' ? null : v))
.pipe(
z
.string()
.regex(/^[a-z0-9-]+$/, 'lowercase letters, digits and dashes')
.nullable()
)
.default(null);
const partSchema = z.object({
id: int(1, 2 ** 31),
@@ -216,7 +285,14 @@ const partSchema = z.object({
measuredDpi: int(1, 100_000),
url: link,
imageUrl: link,
notes: str(4000)
notes: str(4000),
section: pickOrNull(HARDWARE_SECTIONS),
slot: slotText,
usage: pickOrNull(HARDWARE_USAGES),
installedAt: day,
removedAt: day,
imageUrls: images,
hidden: checkbox
});
const deviceSchema = z.object({
@@ -249,7 +325,17 @@ const deviceSchema = z.object({
purchaseDate: day,
url: link,
sortOrder: int(0, 10_000),
notes: str(4000)
notes: str(4000),
installedAt: day,
removedAt: day,
imageUrls: images,
hidden: checkbox
});
const photoSchema = z.object({
caption: str(300),
takenAt: day,
sortOrder: int(0, 10_000)
});
const gameSchema = z.object({
@@ -276,6 +362,8 @@ export async function savePart(form: FormData): Promise<Result> {
const parsed = partSchema.safeParse(fields(form));
if (!parsed.success) return problem(parsed.error);
const { id, unitPrice, ...p } = parsed.data;
if (p.installedAt && p.removedAt && p.removedAt < p.installedAt)
return { ok: false, message: 'removed must be after installed' };
const values = {
...p,
unitPriceCents: unitPrice,
@@ -315,6 +403,27 @@ export async function saveDevice(form: FormData): Promise<Result> {
return { ok: true };
}
/** Owner photo of the whole setup: stored in R2 by the caller, one row here. */
export async function addPhoto(url: string, form: FormData): Promise<Result> {
const parsed = photoSchema.safeParse(fields(form));
if (!parsed.success) return problem(parsed.error);
const { sortOrder, ...p } = parsed.data;
await db.insert(hardwareSetupPhotos).values({ ...p, url, sortOrder: sortOrder ?? 100 });
return { ok: true };
}
export async function deletePhoto(
form: FormData
): Promise<{ ok: true; url: string | null } | { ok: false; message: string }> {
const id = rowId(form);
if (!id) return { ok: false, message: 'Missing id' };
const [row] = await db
.delete(hardwareSetupPhotos)
.where(eq(hardwareSetupPhotos.id, id))
.returning({ url: hardwareSetupPhotos.url });
return { ok: true, url: row?.url ?? null };
}
export async function saveGame(form: FormData): Promise<Result> {
const parsed = gameSchema.safeParse(fields(form));
if (!parsed.success) return problem(parsed.error);

View File

@@ -52,7 +52,7 @@ const EXT: Record<AllowedMimeType, string> = {
/** Upload through the site server so browsers do not need R2 CORS access. */
export async function uploadImage(
file: File,
prefix: 'sightings' | 'lists' | 'locations' = 'sightings'
prefix: 'sightings' | 'lists' | 'locations' | 'setup' = 'sightings'
): Promise<{ key: string; publicUrl: string }> {
if (!isAllowedMimeType(file.type)) throw new Error('Unsupported image type');
const mimeType = file.type;
@@ -93,6 +93,14 @@ export function listImageKey(url: string): string | null {
return listImageUrl(key) === url ? key : null;
}
/** R2 key of an uploaded PC setup photo (setup/<uuid>.ext), or null for anything else. */
export function setupImageKey(url: string): string | null {
const base = R2_PUBLIC_URL?.replace(/\/$/, '');
if (!base || !url.startsWith(`${base}/`)) return null;
const key = url.slice(base.length + 1);
return /^setup\/[0-9a-f-]{36}\.(jpg|png|webp)$/.test(key) ? key : null;
}
/**
* Delete a photo from R2 by its key.
*/