pc: PC setup tracker at /trackers/pc on the hardware schema
All checks were successful
Deploy production / deploy (push) Successful in 1m4s
All checks were successful
Deploy production / deploy (push) Successful in 1m4s
- model the existing hardware schema in Drizzle (devices, components, new sensitivities) - idempotent reconciling migration: new columns, checks, setup_overview view, grants, meta.tables - seed: main PC labels/hostnames, planned AMD X3D + RTX 5090 build, stan-ovh (OVH RISE-S), other devices, sensitivity placeholders (no invented cm/360) - owner-only add/edit via modals; public view hides notes/order numbers - trackers dropdown entry + icon, seo/title/sitemap, owner write prefix
This commit is contained in:
25
src/lib/pc/Modal.svelte
Normal file
25
src/lib/pc/Modal.svelte
Normal file
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { onMount, type Snippet } from 'svelte';
|
||||
|
||||
// Native <dialog> (focus trap, Esc, backdrop from the browser), opened on mount.
|
||||
let {
|
||||
title,
|
||||
onclose,
|
||||
children
|
||||
}: { title: string; onclose: () => void; children: Snippet<[() => void]> } = $props();
|
||||
let dialog = $state<HTMLDialogElement>();
|
||||
onMount(() => dialog?.showModal());
|
||||
const close = () => dialog?.close();
|
||||
</script>
|
||||
|
||||
<dialog
|
||||
bind:this={dialog}
|
||||
{onclose}
|
||||
class="m-auto max-h-[calc(100dvh-2rem)] w-[min(40rem,calc(100vw-2rem))] overflow-y-auto rounded-2xl border border-border bg-popover p-0 text-popover-foreground shadow-2xl backdrop:bg-black/60 backdrop:backdrop-blur-sm"
|
||||
aria-label={title}
|
||||
>
|
||||
<div class="p-5">
|
||||
<h2 class="mb-4 text-lg font-semibold">{title}</h2>
|
||||
{@render children(close)}
|
||||
</div>
|
||||
</dialog>
|
||||
160
src/lib/pc/PartCard.svelte
Normal file
160
src/lib/pc/PartCard.svelte
Normal file
@@ -0,0 +1,160 @@
|
||||
<script lang="ts">
|
||||
import Pencil from 'lucide-svelte/icons/pencil';
|
||||
import ExternalLink from 'lucide-svelte/icons/external-link';
|
||||
import PcIcon from './PcIcon.svelte';
|
||||
import { categoryLabel, money, shortDate, specKey, statusLabel, type Part } from './shared';
|
||||
|
||||
let {
|
||||
part,
|
||||
owner = false,
|
||||
today,
|
||||
onedit
|
||||
}: { part: Part; owner?: boolean; today: string; onedit?: (p: Part) => void } = $props();
|
||||
|
||||
// Internal shop article numbers are noise on a card.
|
||||
const chips = $derived(
|
||||
Object.entries(part.specs)
|
||||
.filter(([k, v]) => v !== null && v !== '' && !k.endsWith('_art') && k !== 'sku')
|
||||
.slice(0, 5)
|
||||
);
|
||||
const underWarranty = $derived(!!part.warrantyUntil && part.warrantyUntil >= today);
|
||||
const price = $derived(money(part.totalPriceCents));
|
||||
</script>
|
||||
|
||||
<article class="part group" class:planned={part.status === 'planned'}>
|
||||
<div class="flex items-start gap-3">
|
||||
<PcIcon category={part.category} />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-[11px] font-medium tracking-wider text-[var(--site-muted)] uppercase">
|
||||
{categoryLabel(part.category)}{#if part.quantity > 1} · ×{part.quantity}{/if}
|
||||
</p>
|
||||
<h3 class="mt-0.5 leading-snug font-semibold">
|
||||
{#if part.brand}<span class="text-[var(--site-muted)]">{part.brand}</span>{/if}
|
||||
{part.model}
|
||||
</h3>
|
||||
</div>
|
||||
{#if owner && onedit}
|
||||
<button
|
||||
type="button"
|
||||
class="edit"
|
||||
onclick={() => onedit(part)}
|
||||
aria-label="Edit {part.brand ?? ''} {part.model}"><Pencil size={15} /></button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if chips.length}
|
||||
<ul class="mt-3 flex flex-wrap gap-1.5">
|
||||
{#each chips as [k, v] (k)}
|
||||
<li class="chip">
|
||||
<span class="text-[var(--site-muted)]">{specKey(k)}</span>
|
||||
{v === true ? 'yes' : v === false ? 'no' : v}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if part.category === 'mouse' && (part.dpi || part.measuredDpi)}
|
||||
<p class="mt-2 text-sm">
|
||||
{#if part.dpi}<span class="chip">set {part.dpi} DPI</span>{/if}
|
||||
{#if part.measuredDpi}<span class="chip">measured {part.measuredDpi} DPI</span>{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="mt-auto flex flex-wrap items-center gap-x-3 gap-y-1 pt-3 text-sm">
|
||||
{#if price}<span class="font-semibold tabular-nums">{price}</span>{/if}
|
||||
{#if part.shop || part.orderDate}
|
||||
<span class="text-[var(--site-muted)]"
|
||||
>{[part.shop, shortDate(part.orderDate)].filter(Boolean).join(' · ')}</span
|
||||
>
|
||||
{/if}
|
||||
{#if part.status !== 'in_use'}<span class="badge">{statusLabel(part.status)}</span>{/if}
|
||||
{#if underWarranty}<span class="badge ok">warranty to {shortDate(part.warrantyUntil)}</span
|
||||
>{/if}
|
||||
{#if !part.verified}<span class="badge warn" title="Bought, but not confirmed still in use"
|
||||
>unverified</span
|
||||
>{/if}
|
||||
{#if part.url}<a href={part.url} class="badge link" target="_blank" rel="noreferrer"
|
||||
>link <ExternalLink size={11} /></a
|
||||
>{/if}
|
||||
</div>
|
||||
{#if owner && (part.notes || part.orderNumber)}
|
||||
<p class="mt-2 border-t border-[var(--site-border)] pt-2 text-xs text-[var(--site-muted)]">
|
||||
{#if part.orderNumber}order {part.orderNumber}{#if part.invoiceNumber}, invoice {part.invoiceNumber}{/if}.
|
||||
{/if}{part.notes ?? ''}
|
||||
</p>
|
||||
{/if}
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.part {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 1rem;
|
||||
background: var(--site-surface);
|
||||
padding: 1rem;
|
||||
transition:
|
||||
transform 0.15s ease,
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
.part:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: color-mix(in srgb, var(--site-text) 35%, var(--site-border));
|
||||
box-shadow: 0 10px 30px -15px rgb(0 0 0 / 0.5);
|
||||
}
|
||||
.part.planned {
|
||||
border-style: dashed;
|
||||
}
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
gap: 0.3rem;
|
||||
border-radius: 999px;
|
||||
background: var(--site-hover);
|
||||
padding: 0.1rem 0.55rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 999px;
|
||||
padding: 0 0.5rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.badge.ok {
|
||||
border-color: color-mix(in srgb, #10b981 50%, transparent);
|
||||
color: #10b981;
|
||||
}
|
||||
.badge.warn {
|
||||
border-color: color-mix(in srgb, #f59e0b 50%, transparent);
|
||||
color: #f59e0b;
|
||||
}
|
||||
.badge.link:hover {
|
||||
color: var(--site-text);
|
||||
}
|
||||
.edit {
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.35rem;
|
||||
color: var(--site-muted);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.part:hover .edit,
|
||||
.edit:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
.edit:hover {
|
||||
background: var(--site-hover);
|
||||
color: var(--site-text);
|
||||
}
|
||||
@media (hover: none) {
|
||||
.edit {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
76
src/lib/pc/PcIcon.svelte
Normal file
76
src/lib/pc/PcIcon.svelte
Normal file
@@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import Box from 'lucide-svelte/icons/box';
|
||||
import CircuitBoard from 'lucide-svelte/icons/circuit-board';
|
||||
import Cloud from 'lucide-svelte/icons/cloud';
|
||||
import Cpu from 'lucide-svelte/icons/cpu';
|
||||
import Fan from 'lucide-svelte/icons/fan';
|
||||
import Gamepad2 from 'lucide-svelte/icons/gamepad-2';
|
||||
import Gpu from 'lucide-svelte/icons/gpu';
|
||||
import HardDrive from 'lucide-svelte/icons/hard-drive';
|
||||
import Headphones from 'lucide-svelte/icons/headphones';
|
||||
import Keyboard from 'lucide-svelte/icons/keyboard';
|
||||
import Laptop from 'lucide-svelte/icons/laptop';
|
||||
import Lightbulb from 'lucide-svelte/icons/lightbulb';
|
||||
import MemoryStick from 'lucide-svelte/icons/memory-stick';
|
||||
import Monitor from 'lucide-svelte/icons/monitor';
|
||||
import Mouse from 'lucide-svelte/icons/mouse';
|
||||
import Package from 'lucide-svelte/icons/package';
|
||||
import PcCase from 'lucide-svelte/icons/pc-case';
|
||||
import Server from 'lucide-svelte/icons/server';
|
||||
import Smartphone from 'lucide-svelte/icons/smartphone';
|
||||
import Snowflake from 'lucide-svelte/icons/snowflake';
|
||||
import Speaker from 'lucide-svelte/icons/speaker';
|
||||
import Tablet from 'lucide-svelte/icons/tablet';
|
||||
import Zap from 'lucide-svelte/icons/zap';
|
||||
import { CATEGORY_META, DEVICE_KIND_META } from './shared';
|
||||
|
||||
// A gradient tile with a white icon, for a part category or a device kind.
|
||||
let {
|
||||
category = undefined,
|
||||
kind = undefined,
|
||||
class: className = 'size-10'
|
||||
}: { category?: string; kind?: string; class?: string } = $props();
|
||||
|
||||
const icons = {
|
||||
case: PcCase,
|
||||
cpu: Cpu,
|
||||
motherboard: CircuitBoard,
|
||||
ram: MemoryStick,
|
||||
cpu_cooler: Snowflake,
|
||||
gpu: Gpu,
|
||||
psu: Zap,
|
||||
storage: HardDrive,
|
||||
fan: Fan,
|
||||
lighting: Lightbulb,
|
||||
monitor: Monitor,
|
||||
mouse: Mouse,
|
||||
keyboard: Keyboard,
|
||||
headset: Headphones,
|
||||
audio: Speaker,
|
||||
controller: Gamepad2,
|
||||
accessory: Package,
|
||||
other: Box,
|
||||
desktop: PcCase,
|
||||
laptop: Laptop,
|
||||
server: Server,
|
||||
vm: Cloud,
|
||||
phone: Smartphone,
|
||||
tablet: Tablet
|
||||
} as const;
|
||||
const key = $derived(category ?? kind ?? 'other');
|
||||
const meta = $derived(
|
||||
(category ? CATEGORY_META[category] : kind ? DEVICE_KIND_META[kind] : undefined) ??
|
||||
CATEGORY_META.other
|
||||
);
|
||||
const Icon = $derived(icons[key as keyof typeof icons] ?? Box);
|
||||
const light = $derived(key === 'case' || key === 'psu' || key === 'controller');
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="flex shrink-0 items-center justify-center rounded-xl shadow-sm ring-1 ring-black/10 ring-inset {className}"
|
||||
style:background-image="linear-gradient(135deg, {meta.from}, {meta.to})"
|
||||
style:color={light ? '#111' : '#fff'}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon class="size-[52%]" strokeWidth={2} />
|
||||
</span>
|
||||
17
src/lib/pc/shared.test.ts
Normal file
17
src/lib/pc/shared.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { cmFor, sensFor, specsToText, textToSpecs } from './shared';
|
||||
|
||||
describe('pc tracker helpers', () => {
|
||||
it('computes CS2 sensitivity from cm/360 and real DPI', () => {
|
||||
// 800 DPI, sens 1.0 in CS2 (yaw 0.022) is about 51.95 cm/360.
|
||||
expect(cmFor(1, 0.022, 800)).toBeCloseTo(51.95, 1);
|
||||
expect(sensFor(51.95, 0.022, 800)).toBeCloseTo(1, 2);
|
||||
expect(sensFor(null, 0.022, 800)).toBeNull();
|
||||
expect(sensFor(40, null, 800)).toBeNull();
|
||||
});
|
||||
it('round-trips specs through the edit textarea', () => {
|
||||
const specs = { socket: 'AM5', cores: 8, rgb: true, note: null };
|
||||
expect(textToSpecs(specsToText(specs))).toEqual(specs);
|
||||
expect(textToSpecs('no colon here\n: empty key\nspeed_mts: 3600')).toEqual({ speed_mts: 3600 });
|
||||
});
|
||||
});
|
||||
177
src/lib/pc/shared.ts
Normal file
177
src/lib/pc/shared.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
// Client-safe helpers for the PC setup tracker (/trackers/pc): labels, colours, formatting
|
||||
// and the cm/360 maths. No server imports here.
|
||||
|
||||
export type Specs = Record<string, string | number | boolean | null>;
|
||||
|
||||
export type Part = {
|
||||
id: number;
|
||||
deviceId: number;
|
||||
category: string;
|
||||
brand: string | null;
|
||||
model: string;
|
||||
quantity: number;
|
||||
specs: Specs;
|
||||
unitPriceCents: number | null;
|
||||
totalPriceCents: number | null;
|
||||
shop: string | null;
|
||||
orderDate: string | null;
|
||||
warrantyUntil: string | null;
|
||||
status: string;
|
||||
verified: boolean;
|
||||
dpi: number | null;
|
||||
measuredDpi: number | null;
|
||||
url: string | null;
|
||||
imageUrl: string | null;
|
||||
/** Owner only (null for everyone else). */
|
||||
notes: string | null;
|
||||
orderNumber: string | null;
|
||||
invoiceNumber: string | null;
|
||||
source: string | null;
|
||||
};
|
||||
|
||||
export type Device = {
|
||||
id: number;
|
||||
name: string;
|
||||
label: string | null;
|
||||
hostnames: string[];
|
||||
kind: string;
|
||||
status: string;
|
||||
brand: string | null;
|
||||
model: string | null;
|
||||
specs: Specs;
|
||||
priceCents: number | null;
|
||||
monthlyCents: number | null;
|
||||
budgetCents: number | null;
|
||||
shop: string | null;
|
||||
purchaseDate: string | null;
|
||||
url: string | null;
|
||||
imageUrl: string | null;
|
||||
sortOrder: number;
|
||||
notes: string | null;
|
||||
parts: Part[];
|
||||
};
|
||||
|
||||
export type Game = {
|
||||
id: number;
|
||||
game: string;
|
||||
cmPer360: number | null;
|
||||
yaw: number | null;
|
||||
inGameSens: number | null;
|
||||
mouseId: number | null;
|
||||
fov: string | null;
|
||||
notes: string | null;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
/** Build order, then peripherals. Gradient tiles: black/white page, colourful content. */
|
||||
export const CATEGORY_META: Record<string, { label: string; from: string; to: string }> = {
|
||||
case: { label: 'Case', from: '#e5e7eb', to: '#9ca3af' },
|
||||
cpu: { label: 'CPU', from: '#60a5fa', to: '#2563eb' },
|
||||
motherboard: { label: 'Motherboard', from: '#34d399', to: '#059669' },
|
||||
ram: { label: 'Memory', from: '#a78bfa', to: '#7c3aed' },
|
||||
cpu_cooler: { label: 'Cooler', from: '#67e8f9', to: '#0891b2' },
|
||||
gpu: { label: 'Graphics', from: '#f87171', to: '#dc2626' },
|
||||
psu: { label: 'Power', from: '#fde047', to: '#ca8a04' },
|
||||
storage: { label: 'Storage', from: '#fb923c', to: '#ea580c' },
|
||||
fan: { label: 'Fans', from: '#7dd3fc', to: '#0284c7' },
|
||||
lighting: { label: 'Lighting', from: '#f0abfc', to: '#c026d3' },
|
||||
monitor: { label: 'Monitor', from: '#818cf8', to: '#4338ca' },
|
||||
mouse: { label: 'Mouse', from: '#f472b6', to: '#db2777' },
|
||||
keyboard: { label: 'Keyboard', from: '#2dd4bf', to: '#0d9488' },
|
||||
headset: { label: 'Headset', from: '#c4b5fd', to: '#6d28d9' },
|
||||
audio: { label: 'Audio', from: '#fda4af', to: '#e11d48' },
|
||||
controller: { label: 'Controller', from: '#bef264', to: '#65a30d' },
|
||||
accessory: { label: 'Accessory', from: '#cbd5e1', to: '#64748b' },
|
||||
other: { label: 'Other', from: '#d4d4d8', to: '#71717a' }
|
||||
};
|
||||
export const CATEGORIES = Object.keys(CATEGORY_META);
|
||||
export const categoryLabel = (c: string) => CATEGORY_META[c]?.label ?? c;
|
||||
|
||||
export const DEVICE_KIND_META: Record<string, { label: string; from: string; to: string }> = {
|
||||
desktop: { label: 'Desktop', from: '#22d3ee', to: '#0891b2' },
|
||||
laptop: { label: 'Laptop', from: '#a78bfa', to: '#7c3aed' },
|
||||
server: { label: 'Server', from: '#34d399', to: '#059669' },
|
||||
vm: { label: 'VM', from: '#94a3b8', to: '#475569' },
|
||||
phone: { label: 'Phone', from: '#f472b6', to: '#db2777' },
|
||||
tablet: { label: 'Tablet', from: '#fb923c', to: '#ea580c' },
|
||||
monitor: { label: 'Monitor', from: '#818cf8', to: '#4338ca' },
|
||||
other: { label: 'Other', from: '#d4d4d8', to: '#71717a' }
|
||||
};
|
||||
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 statusLabel = (s: string) => s.replace('_', ' ');
|
||||
|
||||
const eur2 = new Intl.NumberFormat('en-GB', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
minimumFractionDigits: 2
|
||||
});
|
||||
const eur0 = new Intl.NumberFormat('en-GB', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
maximumFractionDigits: 0
|
||||
});
|
||||
export const money = (cents: number | null | undefined) =>
|
||||
cents === null || cents === undefined
|
||||
? null
|
||||
: (cents % 100 === 0 ? eur0 : eur2).format(cents / 100);
|
||||
|
||||
export const shortDate = (d: string | null) =>
|
||||
d
|
||||
? new Date(`${d}T12:00:00Z`).toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
})
|
||||
: null;
|
||||
|
||||
/** "key: value" lines (the edit form) from specs and back. */
|
||||
export const specsToText = (specs: Specs) =>
|
||||
Object.entries(specs)
|
||||
.map(([k, v]) => `${k}: ${v ?? ''}`)
|
||||
.join('\n');
|
||||
|
||||
export function textToSpecs(text: string): Specs {
|
||||
const out: Specs = {};
|
||||
for (const line of text.split('\n')) {
|
||||
const at = line.indexOf(':');
|
||||
if (at < 1) continue;
|
||||
const key = line.slice(0, at).trim().slice(0, 60);
|
||||
const raw = line
|
||||
.slice(at + 1)
|
||||
.trim()
|
||||
.slice(0, 300);
|
||||
if (!key) continue;
|
||||
out[key] =
|
||||
raw === ''
|
||||
? null
|
||||
: raw === 'true'
|
||||
? true
|
||||
: raw === 'false'
|
||||
? false
|
||||
: /^-?\d+(\.\d+)?$/.test(raw)
|
||||
? Number(raw)
|
||||
: raw;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** "speed_mts" -> "speed mts", for spec chips. */
|
||||
export const specKey = (k: string) => k.replace(/_/g, ' ');
|
||||
|
||||
/**
|
||||
* In-game sensitivity for a physical cm/360 with a mouse of `dpi` real counts per inch, in a
|
||||
* game that turns `yaw` degrees per count at sensitivity 1: sens = 360 × 2.54 / (yaw × dpi × cm).
|
||||
*/
|
||||
export function sensFor(cmPer360: number | null, yaw: number | null, dpi: number | null) {
|
||||
if (!cmPer360 || !yaw || !dpi) return null;
|
||||
return (360 * 2.54) / (yaw * dpi * cmPer360);
|
||||
}
|
||||
|
||||
/** cm/360 that a given in-game sens gives with a mouse (inverse of sensFor). */
|
||||
export function cmFor(sens: number | null, yaw: number | null, dpi: number | null) {
|
||||
if (!sens || !yaw || !dpi) return null;
|
||||
return (360 * 2.54) / (yaw * dpi * sens);
|
||||
}
|
||||
Reference in New Issue
Block a user