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

@@ -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.
*/