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:
@@ -17,6 +17,7 @@ const staticPaths = [
|
||||
'/trackers/cars',
|
||||
'/trackers/cars/brands',
|
||||
'/trackers/music',
|
||||
'/trackers/pc',
|
||||
'/projects/game-tracker',
|
||||
'/projects/game-tracker/pokemon',
|
||||
'/projects/game-tracker/osu',
|
||||
|
||||
53
src/routes/trackers/pc/+page.server.ts
Normal file
53
src/routes/trackers/pc/+page.server.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { isOwner, requireOwner } from '#lib/server/owner.js';
|
||||
import { deleteRow, loadSetup, saveDevice, saveGame, savePart } from '#lib/server/hardware.js';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
// Public page; only the owner sees notes/order numbers and the edit controls.
|
||||
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);
|
||||
return {
|
||||
owner,
|
||||
devices,
|
||||
games,
|
||||
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.'
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const run = async (result: Promise<{ ok: true } | { ok: false; message: string }>) => {
|
||||
const r = await result;
|
||||
return r.ok ? { saved: true } : fail(400, { message: r.message });
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
savePart: async (event) => {
|
||||
requireOwner(event);
|
||||
return run(savePart(await event.request.formData()));
|
||||
},
|
||||
deletePart: async (event) => {
|
||||
requireOwner(event);
|
||||
return run(deleteRow('part', await event.request.formData()));
|
||||
},
|
||||
saveDevice: async (event) => {
|
||||
requireOwner(event);
|
||||
return run(saveDevice(await event.request.formData()));
|
||||
},
|
||||
deleteDevice: async (event) => {
|
||||
requireOwner(event);
|
||||
return run(deleteRow('device', await event.request.formData()));
|
||||
},
|
||||
saveGame: async (event) => {
|
||||
requireOwner(event);
|
||||
return run(saveGame(await event.request.formData()));
|
||||
},
|
||||
deleteGame: async (event) => {
|
||||
requireOwner(event);
|
||||
return run(deleteRow('game', await event.request.formData()));
|
||||
}
|
||||
};
|
||||
868
src/routes/trackers/pc/+page.svelte
Normal file
868
src/routes/trackers/pc/+page.svelte
Normal file
@@ -0,0 +1,868 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { SubmitFunction } from '@sveltejs/kit';
|
||||
import Plus from 'lucide-svelte/icons/plus';
|
||||
import Pencil from 'lucide-svelte/icons/pencil';
|
||||
import Crosshair from 'lucide-svelte/icons/crosshair';
|
||||
import Modal from '#lib/pc/Modal.svelte';
|
||||
import PartCard from '#lib/pc/PartCard.svelte';
|
||||
import PcIcon from '#lib/pc/PcIcon.svelte';
|
||||
import {
|
||||
CATEGORIES,
|
||||
DEVICE_KINDS,
|
||||
DEVICE_KIND_META,
|
||||
DEVICE_STATUSES,
|
||||
PART_STATUSES,
|
||||
categoryLabel,
|
||||
money,
|
||||
sensFor,
|
||||
shortDate,
|
||||
specKey,
|
||||
specsToText,
|
||||
statusLabel,
|
||||
type Device,
|
||||
type Game,
|
||||
type Part
|
||||
} from '#lib/pc/shared.js';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
const main = $derived(
|
||||
data.devices.find((d) => d.status === 'active' && d.kind === 'desktop') ?? null
|
||||
);
|
||||
const planned = $derived(data.devices.filter((d) => d.status === 'planned'));
|
||||
const others = $derived(
|
||||
data.devices.filter((d) => d !== main && (d.status === 'active' || d.status === 'spare'))
|
||||
);
|
||||
const retired = $derived(
|
||||
data.devices.filter((d) => d.status === 'retired' || d.status === 'sold')
|
||||
);
|
||||
const navDevices = $derived([main, ...planned, ...others].filter((d): d is Device => !!d));
|
||||
|
||||
const sum = (parts: Part[]) => parts.reduce((s, p) => s + (p.totalPriceCents ?? 0), 0);
|
||||
const inUse = (d: Device) => d.parts.filter((p) => p.status === 'in_use');
|
||||
const mainParts = $derived(main ? inUse(main) : []);
|
||||
const build = $derived(
|
||||
mainParts.filter((p) =>
|
||||
[
|
||||
'case',
|
||||
'cpu',
|
||||
'motherboard',
|
||||
'ram',
|
||||
'cpu_cooler',
|
||||
'gpu',
|
||||
'psu',
|
||||
'storage',
|
||||
'fan',
|
||||
'lighting'
|
||||
].includes(p.category)
|
||||
)
|
||||
);
|
||||
const desk = $derived(mainParts.filter((p) => !build.includes(p)));
|
||||
const mice = $derived(
|
||||
data.devices
|
||||
.filter((d) => d.status !== 'retired' && d.status !== 'sold')
|
||||
.flatMap((d) => d.parts)
|
||||
.filter((p) => p.category === 'mouse' && p.status !== 'sold' && p.status !== 'retired')
|
||||
);
|
||||
const monthly = $derived(
|
||||
data.devices.filter((d) => d.status === 'active').reduce((s, d) => s + (d.monthlyCents ?? 0), 0)
|
||||
);
|
||||
|
||||
const deviceSpecs = (d: Device) =>
|
||||
Object.entries(d.specs).filter(([, v]) => v !== null && v !== '');
|
||||
const fmtSens = (n: number | null) =>
|
||||
n === null ? null : n.toLocaleString('en-GB', { maximumFractionDigits: 3 });
|
||||
|
||||
// ---- owner editing ----
|
||||
let editPart = $state<Partial<Part> | null>(null);
|
||||
let editDevice = $state<Partial<Device> | null>(null);
|
||||
let editGame = $state<Partial<Game> | null>(null);
|
||||
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>
|
||||
|
||||
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||||
<header class="mb-8 flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">pc setup</h1>
|
||||
<p class="mt-3 max-w-2xl text-[var(--site-muted)]">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
{#if data.owner}
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="action"
|
||||
onclick={() =>
|
||||
(editPart = {
|
||||
deviceId: main?.id,
|
||||
category: 'other',
|
||||
status: 'in_use',
|
||||
verified: true,
|
||||
quantity: 1,
|
||||
specs: {}
|
||||
})}><Plus size={15} /> part</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="action"
|
||||
onclick={() =>
|
||||
(editDevice = { kind: 'desktop', status: 'active', specs: {}, hostnames: [] })}
|
||||
><Plus size={15} /> device</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<dl class="stats">
|
||||
<div>
|
||||
<dt>parts in my PC</dt>
|
||||
<dd>{build.length}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>PC cost (known prices)</dt>
|
||||
<dd>{money(sum(build)) ?? '–'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>desk + peripherals</dt>
|
||||
<dd>{money(sum(desk)) ?? '–'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>servers / month</dt>
|
||||
<dd>{money(monthly) ?? '–'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<nav class="devices" aria-label="Devices">
|
||||
{#each navDevices as d (d.id)}
|
||||
<a href="#{d.name}" class="device-tile">
|
||||
<PcIcon kind={d.kind} class="size-11" />
|
||||
<span class="min-w-0">
|
||||
<span class="block truncate font-medium">{d.label ?? d.name}</span>
|
||||
<span class="block truncate text-xs text-[var(--site-muted)]"
|
||||
>{[d.brand, d.model].filter(Boolean).join(' ') || DEVICE_KIND_META[d.kind]?.label}</span
|
||||
>
|
||||
</span>
|
||||
{#if d.status !== 'active'}<span class="pill">{d.status}</span>{/if}
|
||||
</a>
|
||||
{/each}
|
||||
<a href="#sensitivity" class="device-tile">
|
||||
<span
|
||||
class="flex size-11 shrink-0 items-center justify-center rounded-xl text-white shadow-sm"
|
||||
style:background-image="linear-gradient(135deg, #f472b6, #7c3aed)"
|
||||
aria-hidden="true"><Crosshair size={22} /></span
|
||||
>
|
||||
<span class="min-w-0">
|
||||
<span class="block font-medium">Sensitivity</span>
|
||||
<span class="block text-xs text-[var(--site-muted)]">cm/360 per game</span>
|
||||
</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
{#snippet deviceHeader(d: Device, title: string)}
|
||||
<div class="mb-4 flex flex-wrap items-center gap-3">
|
||||
<PcIcon kind={d.kind} class="size-12" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="text-2xl font-semibold tracking-tight">{title}</h2>
|
||||
<p class="text-sm text-[var(--site-muted)]">
|
||||
<code>{d.name}</code>{#each d.hostnames as h (h)} · <code>{h}</code>{/each}
|
||||
{#if d.brand || d.model} · {[d.brand, d.model].filter(Boolean).join(' ')}{/if}
|
||||
{#if d.monthlyCents} · {money(d.monthlyCents)}/month{/if}
|
||||
{#if d.priceCents} · {money(d.priceCents)}{/if}
|
||||
{#if d.purchaseDate} · since {shortDate(d.purchaseDate)}{/if}
|
||||
</p>
|
||||
</div>
|
||||
{#if data.owner}
|
||||
<button
|
||||
type="button"
|
||||
class="action"
|
||||
onclick={() =>
|
||||
(editPart = {
|
||||
deviceId: d.id,
|
||||
category: 'other',
|
||||
status: d.status === 'planned' ? 'planned' : 'in_use',
|
||||
verified: true,
|
||||
quantity: 1,
|
||||
specs: {}
|
||||
})}><Plus size={15} /> part</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="action"
|
||||
onclick={() => (editDevice = d)}
|
||||
aria-label="Edit {d.name}"><Pencil size={15} /></button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{#if deviceSpecs(d).length}
|
||||
<ul class="mb-4 flex flex-wrap gap-2">
|
||||
{#each deviceSpecs(d) as [k, v] (k)}
|
||||
<li class="spec"><span>{specKey(k)}</span>{v}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if d.notes}<p class="mb-4 max-w-3xl text-sm text-[var(--site-muted)]">{d.notes}</p>{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet grid(parts: Part[])}
|
||||
<ul class="parts">
|
||||
{#each parts as p (p.id)}
|
||||
<li><PartCard part={p} owner={data.owner} {today} onedit={(x) => (editPart = x)} /></li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/snippet}
|
||||
|
||||
{#if main}
|
||||
<section id={main.name} class="section">
|
||||
{@render deviceHeader(main, main.label ?? main.name)}
|
||||
{@render grid(build)}
|
||||
{#if desk.length}
|
||||
<h3 class="mt-8 mb-3 text-lg font-semibold">Desk and peripherals</h3>
|
||||
{@render grid(desk)}
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#each planned as d (d.id)}
|
||||
{@const spent = sum(d.parts)}
|
||||
<section id={d.name} class="section">
|
||||
{@render deviceHeader(d, d.label ?? d.name)}
|
||||
{#if d.budgetCents}
|
||||
<div class="budget" aria-label="Budget">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span>picked so far <strong class="tabular-nums">{money(spent)}</strong></span>
|
||||
<span class="text-[var(--site-muted)]">budget {money(d.budgetCents)}</span>
|
||||
</div>
|
||||
<div class="bar">
|
||||
<span style:width="{Math.min(100, (spent / d.budgetCents) * 100)}%"></span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{@render grid(d.parts)}
|
||||
</section>
|
||||
{/each}
|
||||
|
||||
<section id="sensitivity" class="section">
|
||||
<div class="mb-4 flex flex-wrap items-center gap-3">
|
||||
<span
|
||||
class="flex size-12 shrink-0 items-center justify-center rounded-xl text-white shadow-sm"
|
||||
style:background-image="linear-gradient(135deg, #f472b6, #7c3aed)"
|
||||
aria-hidden="true"><Crosshair size={24} /></span
|
||||
>
|
||||
<div class="flex-1">
|
||||
<h2 class="text-2xl font-semibold tracking-tight">Mice and sensitivity</h2>
|
||||
<p class="text-sm text-[var(--site-muted)]">
|
||||
One physical sensitivity per game (cm per 360° turn), converted per mouse from its
|
||||
measured DPI, so every mouse feels the same on every PC.
|
||||
</p>
|
||||
</div>
|
||||
{#if data.owner}
|
||||
<button type="button" class="action" onclick={() => (editGame = {})}
|
||||
><Plus size={15} /> game</button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mice">
|
||||
{#each mice as m (m.id)}
|
||||
<div class="mouse">
|
||||
<PcIcon category="mouse" class="size-9" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate font-medium">{m.brand} {m.model}</p>
|
||||
<p class="text-sm text-[var(--site-muted)]">
|
||||
set {m.dpi ?? '?'} DPI · measured
|
||||
<strong class="text-[var(--site-text)]">{m.measuredDpi ?? '?'}</strong> DPI
|
||||
</p>
|
||||
</div>
|
||||
{#if data.owner}
|
||||
<button
|
||||
type="button"
|
||||
class="action"
|
||||
onclick={() => (editPart = m)}
|
||||
aria-label="Edit {m.model}"><Pencil size={14} /></button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-[var(--site-muted)]">No mice yet.</p>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>game</th>
|
||||
<th>cm/360</th>
|
||||
<th>yaw</th>
|
||||
<th>set now</th>
|
||||
{#each mice as m (m.id)}<th>{m.model}</th>{/each}
|
||||
{#if data.owner}<th><span class="sr-only">edit</span></th>{/if}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data.games as g (g.id)}
|
||||
{@const setWith = mice.find((m) => m.id === g.mouseId)}
|
||||
<tr>
|
||||
<td class="font-medium">{g.game}</td>
|
||||
<td class="tabular-nums">{g.cmPer360 ?? '–'}</td>
|
||||
<td class="tabular-nums text-[var(--site-muted)]">{g.yaw ?? '–'}</td>
|
||||
<td class="tabular-nums"
|
||||
>{g.inGameSens ?? '–'}{#if setWith}<span class="text-xs text-[var(--site-muted)]">
|
||||
· {setWith.model}</span
|
||||
>{/if}</td
|
||||
>
|
||||
{#each mice as m (m.id)}
|
||||
<td class="tabular-nums font-semibold"
|
||||
>{fmtSens(sensFor(g.cmPer360, g.yaw, m.measuredDpi ?? m.dpi)) ?? '–'}</td
|
||||
>
|
||||
{/each}
|
||||
{#if data.owner}
|
||||
<td
|
||||
><button
|
||||
type="button"
|
||||
class="action"
|
||||
onclick={() => (editGame = g)}
|
||||
aria-label="Edit {g.game}"><Pencil size={14} /></button
|
||||
></td
|
||||
>
|
||||
{/if}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-[var(--site-muted)]">
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{#each others as d (d.id)}
|
||||
<section id={d.name} class="section">
|
||||
{@render deviceHeader(d, d.label ?? d.name)}
|
||||
{#if d.parts.length}{@render grid(d.parts)}{/if}
|
||||
</section>
|
||||
{/each}
|
||||
|
||||
{#if retired.length}
|
||||
<details class="section retired">
|
||||
<summary class="cursor-pointer text-lg font-semibold">Retired ({retired.length})</summary>
|
||||
{#each retired as d (d.id)}
|
||||
<section id={d.name} class="mt-6">
|
||||
{@render deviceHeader(d, d.label ?? d.name)}
|
||||
{#if d.parts.length}{@render grid(d.parts)}{/if}
|
||||
</section>
|
||||
{/each}
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#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 data.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>
|
||||
<label class="wide"
|
||||
>image link<input name="imageUrl" type="url" value={p.imageUrl ?? ''} /></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
|
||||
>
|
||||
{#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 class="wide"
|
||||
>notes (public)<textarea name="notes" rows="3">{d.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 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>
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.stats {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
.stats > div {
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 1rem;
|
||||
padding: 0.9rem 1rem;
|
||||
background: var(--site-surface);
|
||||
}
|
||||
.stats dt {
|
||||
font-size: 0.75rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.stats dd {
|
||||
margin-top: 0.2rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.devices {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
.device-tile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 1rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: var(--site-surface);
|
||||
transition:
|
||||
transform 0.15s ease,
|
||||
background 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
.device-tile:hover {
|
||||
transform: translateY(-2px);
|
||||
background: var(--site-hover);
|
||||
border-color: color-mix(in srgb, var(--site-text) 35%, var(--site-border));
|
||||
}
|
||||
.pill {
|
||||
margin-left: auto;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 999px;
|
||||
padding: 0 0.45rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.section {
|
||||
margin-bottom: 3.5rem;
|
||||
scroll-margin-top: 5rem;
|
||||
}
|
||||
.section :global(code) {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
.parts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.spec {
|
||||
display: inline-flex;
|
||||
gap: 0.4rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.6rem;
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.spec span {
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.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);
|
||||
border-color: color-mix(in srgb, var(--site-text) 35%, var(--site-border));
|
||||
}
|
||||
.action.primary {
|
||||
background: var(--site-text);
|
||||
color: var(--site-bg);
|
||||
border-color: var(--site-text);
|
||||
}
|
||||
.action.primary:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.budget {
|
||||
margin-bottom: 1rem;
|
||||
max-width: 36rem;
|
||||
}
|
||||
.bar {
|
||||
margin-top: 0.4rem;
|
||||
height: 0.55rem;
|
||||
border-radius: 999px;
|
||||
background: var(--site-hover);
|
||||
overflow: hidden;
|
||||
}
|
||||
.bar span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
min-width: 0.55rem;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, #22d3ee, #7c3aed, #db2777);
|
||||
}
|
||||
.mice {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.mouse {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 1rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: var(--site-surface);
|
||||
}
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 1rem;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
th {
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
font-size: 0.75rem;
|
||||
color: var(--site-muted);
|
||||
padding: 0.6rem 0.9rem;
|
||||
border-bottom: 1px solid var(--site-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
td {
|
||||
padding: 0.55rem 0.9rem;
|
||||
border-bottom: 1px solid var(--site-border);
|
||||
}
|
||||
tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
tbody tr:hover {
|
||||
background: var(--site-hover);
|
||||
}
|
||||
.retired summary {
|
||||
list-style: none;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.retired summary:hover {
|
||||
color: var(--site-text);
|
||||
}
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user