+
+
+ {#each years.filter((y, i) => width > 520 || i % 2 === 0) as y (y.y)}
+ {y.y}
+ {/each}
+ {#each ticks as t (t.date)}
+
+ {/each}
+
+ {#if hover}
+
+ {monthYear(hover.date)}
+ {eventLabel(hover)}
+
+ {/if}
+
+
+ {shortDate(value)}
+
+
+
+
+ {#if last}
+ {monthYear(last.date)} · {eventLabel(last)}
+ {:else}
+ before the first part
+ {/if}
+
+
+
+
diff --git a/src/lib/pc/shared.ts b/src/lib/pc/shared.ts
index 6ace251..1c52c3e 100644
--- a/src/lib/pc/shared.ts
+++ b/src/lib/pc/shared.ts
@@ -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 = {
+ 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();
+ 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();
+ 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][]> = {
+ 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) }))
+ ]
+ };
+}
diff --git a/src/lib/seo.ts b/src/lib/seo.ts
index 39a1183..00a8501 100644
--- a/src/lib/seo.ts
+++ b/src/lib/seo.ts
@@ -73,7 +73,7 @@ const publicPages: Record = {
'/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': {
diff --git a/src/lib/server/db/schema/hardware.ts b/src/lib/server/db/schema/hardware.ts
index 7239827..f963011 100644
--- a/src/lib/server/db/schema/hardware.ts
+++ b/src/lib/server/db/schema/hardware.ts
@@ -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
diff --git a/src/lib/server/hardware.ts b/src/lib/server/hardware.ts
index cead4e7..a223a32 100644
--- a/src/lib/server/hardware.ts
+++ b/src/lib/server/hardware.ts
@@ -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();
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 = (values: T) => z.enum(values);
+const pickOrNull = (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 {
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 {
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 {
+ 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 {
const parsed = gameSchema.safeParse(fields(form));
if (!parsed.success) return problem(parsed.error);
diff --git a/src/lib/server/r2.ts b/src/lib/server/r2.ts
index bd114ed..8ae7ba5 100644
--- a/src/lib/server/r2.ts
+++ b/src/lib/server/r2.ts
@@ -52,7 +52,7 @@ const EXT: Record = {
/** 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/.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.
*/
diff --git a/src/routes/trackers/pc/+page.server.ts b/src/routes/trackers/pc/+page.server.ts
index 23d4528..5887032 100644
--- a/src/routes/trackers/pc/+page.server.ts
+++ b/src/routes/trackers/pc/+page.server.ts
@@ -1,5 +1,14 @@
import { isOwner, requireOwner } from '#lib/server/owner.js';
-import { deleteRow, loadSetup, saveDevice, saveGame, savePart } from '#lib/server/hardware.js';
+import {
+ addPhoto,
+ deletePhoto,
+ deleteRow,
+ loadSetup,
+ saveDevice,
+ saveGame,
+ savePart
+} from '#lib/server/hardware.js';
+import { deleteObject, setupImageKey, uploadImage } from '#lib/server/r2.js';
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
@@ -7,15 +16,16 @@ import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => {
const owner = isOwner(event);
if (owner) event.setHeaders({ 'cache-control': 'private, no-store' });
- const { devices, games } = await loadSetup(owner);
+ const { devices, games, photos } = await loadSetup(owner);
return {
owner,
devices,
games,
+ photos,
seo: {
title: 'PC setup',
description:
- 'Every part in my PC with specs and prices, my other devices and servers, the planned next build and my mouse sensitivities.'
+ 'My PC, desk and devices on one screen, with a time slider that shows the setup on any day since 2021, plus the planned next build and my mouse sensitivities.'
}
};
};
@@ -49,5 +59,33 @@ export const actions: Actions = {
deleteGame: async (event) => {
requireOwner(event);
return run(deleteRow('game', await event.request.formData()));
+ },
+ addPhoto: async (event) => {
+ requireOwner(event);
+ const form = await event.request.formData();
+ const photo = form.get('photo');
+ if (
+ !(photo instanceof File) ||
+ !['image/jpeg', 'image/png', 'image/webp'].includes(photo.type) ||
+ !photo.size ||
+ photo.size > 15 * 1024 * 1024
+ )
+ return fail(400, { message: 'Choose a JPEG, PNG or WebP image up to 15 MB.' });
+ let url: string;
+ try {
+ url = (await uploadImage(photo, 'setup')).publicUrl;
+ } catch (cause) {
+ console.error('setup photo upload failed', cause);
+ return fail(503, { message: 'Image storage is unavailable.' });
+ }
+ return run(addPhoto(url, form));
+ },
+ deletePhoto: async (event) => {
+ requireOwner(event);
+ const r = await deletePhoto(await event.request.formData());
+ if (!r.ok) return fail(400, { message: r.message });
+ const key = r.url ? setupImageKey(r.url) : null;
+ if (key) await deleteObject(key).catch(() => {});
+ return { saved: true };
}
};
diff --git a/src/routes/trackers/pc/+page.svelte b/src/routes/trackers/pc/+page.svelte
index fd013b5..b3d5900 100644
--- a/src/routes/trackers/pc/+page.svelte
+++ b/src/routes/trackers/pc/+page.svelte
@@ -1,733 +1,592 @@
-
-
-
-
pc setup
-
- Every part in my PC with specs and what I paid, the rest of my devices and servers, the next
- build, and the mouse sensitivities I keep the same everywhere.
-
-
+{#snippet cards(list: SlotCard[], compact: boolean)}
+ {#each list as c (c.key)}
+
+ sens = 360 × 2.54 / (yaw × DPI × cm/360), using the measured DPI when known (move the
+ mouse 10 cm, count pixels, DPI = pixels / 3.937). One cm/360 per game keeps every mouse
+ feeling the same.
+
- Per-mouse values use in-game sens = 360 × 2.54 / (yaw × DPI × cm/360), with the measured DPI
- when known. Measure DPI by moving the mouse exactly 10 cm and counting the cursor's pixels:
- DPI = pixels / 3.937.
-