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:
435
src/lib/pc/EditDialogs.svelte
Normal file
435
src/lib/pc/EditDialogs.svelte
Normal 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
257
src/lib/pc/Lightbox.svelte
Normal 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} · ×{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} ·
|
||||
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>
|
||||
@@ -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
317
src/lib/pc/SetupCard.svelte
Normal 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>
|
||||
308
src/lib/pc/TimeSlider.svelte
Normal file
308
src/lib/pc/TimeSlider.svelte
Normal 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>
|
||||
@@ -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