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:
435
src/lib/pc/EditDialogs.svelte
Normal file
435
src/lib/pc/EditDialogs.svelte
Normal file
@@ -0,0 +1,435 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { SubmitFunction } from '@sveltejs/kit';
|
||||
import Modal from './Modal.svelte';
|
||||
import {
|
||||
CATEGORIES,
|
||||
DEVICE_KINDS,
|
||||
DEVICE_KIND_META,
|
||||
DEVICE_STATUSES,
|
||||
PART_STATUSES,
|
||||
SECTIONS,
|
||||
SLOT_META,
|
||||
USAGES,
|
||||
categoryLabel,
|
||||
specsToText,
|
||||
statusLabel,
|
||||
type Device,
|
||||
type Game,
|
||||
type Part
|
||||
} from './shared';
|
||||
|
||||
// Owner-only edit dialogs for parts, devices and games (the page sets one of them to open it).
|
||||
let {
|
||||
editPart = $bindable(null),
|
||||
editDevice = $bindable(null),
|
||||
editGame = $bindable(null),
|
||||
devices,
|
||||
mice
|
||||
}: {
|
||||
editPart?: Partial<Part> | null;
|
||||
editDevice?: Partial<Device> | null;
|
||||
editGame?: Partial<Game> | null;
|
||||
devices: Device[];
|
||||
mice: Part[];
|
||||
} = $props();
|
||||
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>
|
||||
|
||||
{#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 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>
|
||||
<input type="hidden" name="imageUrl" value={p.imageUrl ?? ''} />
|
||||
<label
|
||||
>section<select name="section" value={p.section ?? ''}>
|
||||
<option value="">–</option>
|
||||
{#each SECTIONS as s (s)}<option value={s}>{s}</option>{/each}
|
||||
</select></label
|
||||
>
|
||||
<label
|
||||
>slot<input
|
||||
name="slot"
|
||||
value={p.slot ?? ''}
|
||||
list="pc-slots"
|
||||
placeholder="cpu, fans-case, mouse-gaming…"
|
||||
/></label
|
||||
>
|
||||
<datalist id="pc-slots">
|
||||
{#each Object.keys(SLOT_META) as s (s)}<option value={s}></option>{/each}
|
||||
</datalist>
|
||||
<label
|
||||
>usage<select name="usage" value={p.usage ?? ''}>
|
||||
<option value="">–</option>
|
||||
{#each USAGES as u (u)}<option value={u}>{u}</option>{/each}
|
||||
</select></label
|
||||
>
|
||||
<label
|
||||
>in setup since<input name="installedAt" type="date" value={p.installedAt ?? ''} /></label
|
||||
>
|
||||
<label>removed on<input name="removedAt" type="date" value={p.removedAt ?? ''} /></label>
|
||||
<label class="wide"
|
||||
>images (one per line, /hardware/… or https://)<textarea name="imageUrls" rows="3"
|
||||
>{(p.imageUrls ?? []).join('\n')}</textarea
|
||||
></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
|
||||
>
|
||||
<label class="check"
|
||||
><input type="checkbox" name="hidden" checked={p.hidden ?? false} /> hidden (kept for history,
|
||||
not shown)</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
|
||||
>in setup since<input name="installedAt" type="date" value={d.installedAt ?? ''} /></label
|
||||
>
|
||||
<label>removed on<input name="removedAt" type="date" value={d.removedAt ?? ''} /></label>
|
||||
<label class="wide"
|
||||
>images (one per line)<textarea name="imageUrls" rows="2"
|
||||
>{(d.imageUrls ?? []).join('\n')}</textarea
|
||||
></label
|
||||
>
|
||||
<label class="wide"
|
||||
>notes (public)<textarea name="notes" rows="3">{d.notes ?? ''}</textarea></label
|
||||
>
|
||||
<label class="check"
|
||||
><input type="checkbox" name="hidden" checked={d.hidden ?? false} /> hidden (kept for history,
|
||||
not shown)</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>
|
||||
.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;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.action.primary {
|
||||
background: var(--site-text);
|
||||
color: var(--site-bg);
|
||||
border-color: var(--site-text);
|
||||
}
|
||||
.action.primary:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user