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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user