From 0f7cdc6faf8316c5752df5e2daf72ffe4929ddaa Mon Sep 17 00:00:00 2001 From: Donkie Date: Mon, 13 Jul 2026 10:49:15 +0200 Subject: [PATCH] Add location rename and richer spool info on Locations page Location names can now be edited inline (renaming moves all spools in that location to the new name via a new spoolSource.renameLocation). Spool entries on the Locations page now show vendor, material, full weight, and last-used time, matching the old client's layout. --- client_v2/src/lib/api/spoolSource.ts | 4 + client_v2/src/lib/utils/format.ts | 5 + client_v2/src/routes/locations/+page.svelte | 169 +++++++++++++++++--- 3 files changed, 158 insertions(+), 20 deletions(-) diff --git a/client_v2/src/lib/api/spoolSource.ts b/client_v2/src/lib/api/spoolSource.ts index 4f926fa9..df7c0202 100644 --- a/client_v2/src/lib/api/spoolSource.ts +++ b/client_v2/src/lib/api/spoolSource.ts @@ -301,6 +301,10 @@ class HttpSpoolSource { async locations(): Promise { return getJson('/location'); } + /** Rename a location, moving every spool currently in it to the new name. */ + async renameLocation(current: string, next: string): Promise { + await patchJson(`/location/${encodeURIComponent(current)}`, { name: next }); + } async lotNumbers(): Promise { return getJson('/lot-number'); } diff --git a/client_v2/src/lib/utils/format.ts b/client_v2/src/lib/utils/format.ts index 440dd5b9..503eb7e8 100644 --- a/client_v2/src/lib/utils/format.ts +++ b/client_v2/src/lib/utils/format.ts @@ -10,6 +10,11 @@ export function grams(g: number): string { return Number.isInteger(g) ? String(g) : g.toFixed(1); } +/** Grams → "864 g" or "1 kg", switching units at 1000 g like the old client. */ +export function weightAuto(weightInGrams: number): string { + return weightInGrams >= 1000 ? grams(weightInGrams / 1000) + ' kg' : grams(weightInGrams) + ' g'; +} + /** Approximate remaining length in meters for a weight of filament. */ export function lengthMeters(grams: number, f: Filament): number { // volume(cm³) = mass / density; cross-section area = π r² (mm² → cm²) diff --git a/client_v2/src/routes/locations/+page.svelte b/client_v2/src/routes/locations/+page.svelte index 0a5ba8cd..8deb5c58 100644 --- a/client_v2/src/routes/locations/+page.svelte +++ b/client_v2/src/routes/locations/+page.svelte @@ -6,11 +6,15 @@ import { spoolSource } from '$lib/api/spoolSource'; import { live } from '$lib/api/live'; import { goto } from '$app/navigation'; - import { grams } from '$lib/utils/format'; + import { weightAuto } from '$lib/utils/format'; let draggingId = $state(null); let dragOver = $state(null); + let editingLocation = $state(null); + let editValue = $state(''); + let renameError = $state(''); + // Fetch every (non-archived) spool + the configured locations, kept live. let spools = $state([]); let locations = $state([]); @@ -71,6 +75,57 @@ function addLocation() { extraLocations = [...extraLocations, 'New shelf ' + (locations.length + extraLocations.length + 1)]; } + + function focusAndSelect(el: HTMLInputElement) { + el.focus(); + el.select(); + } + + function startEdit(loc: string) { + if (loc === 'No location') return; + editingLocation = loc; + editValue = loc; + renameError = ''; + } + + function cancelEdit() { + editingLocation = null; + renameError = ''; + } + + async function commitEdit() { + if (editingLocation === null) return; + const oldName = editingLocation; + const newName = editValue.trim(); + + if (newName === oldName) { + editingLocation = null; + renameError = ''; + return; + } + if (!newName) { + renameError = 'Location name cannot be empty'; + return; + } + if ([...locations, ...extraLocations].includes(newName)) { + renameError = 'A location with that name already exists'; + return; + } + + const hasSpools = spools.some((s) => (s.location || 'No location') === oldName); + try { + if (hasSpools) { + await spoolSource.renameLocation(oldName, newName); + spools = spools.map((s) => (s.location === oldName ? { ...s, location: newName } : s)); + } + locations = locations.map((l) => (l === oldName ? newName : l)); + extraLocations = extraLocations.map((l) => (l === oldName ? newName : l)); + editingLocation = null; + renameError = ''; + } catch (e) { + renameError = e instanceof Error ? e.message : 'Failed to rename location'; + } + } @@ -99,12 +154,44 @@ >
⠿ - {shelf.name} + {#if editingLocation === shelf.name} + (editValue = (e.target as HTMLInputElement).value)} + onblur={commitEdit} + onkeydown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + (e.target as HTMLInputElement).blur(); + } else if (e.key === 'Escape') { + e.preventDefault(); + cancelEdit(); + } + }} + /> + {:else} + startEdit(shelf.name)} + onkeydown={(e) => e.key === 'Enter' && startEdit(shelf.name)} + > + {shelf.name} + + {/if} {shelf.spools.length} spool{shelf.spools.length === 1 ? '' : 's'}
+ {#if editingLocation === shelf.name && renameError} +
{renameError}
+ {/if}
{#each shelf.spools as s (s.id)} {@const f = inventory.filamentById(s.filamentId)!} + {@const v = inventory.vendorOf(f)}
openSpool(s.id)} onkeydown={(e) => e.key === 'Enter' && openSpool(s.id)} > - - #{s.id} - {f.name} - {grams(s.remaining)} g + +
+
+ #{s.id} + {#if v.name !== '?'}{v.name}{' - '}{/if}{f.name} +
+
+ {f.material}{' - '}{weightAuto(s.remaining)}{' / '}{weightAuto(f.weight)}{#if s.lastUsedLabel}{' - Last used '}{s.lastUsedLabel} ago{/if} +
+
{/each} {#if shelf.spools.length === 0} @@ -208,22 +301,49 @@ font-weight: 600; font-size: 13px; } + .shelf-name.editable { + cursor: text; + border-radius: 4px; + } + .shelf-name.editable:hover { + background: var(--bg); + } + .shelf-name-input { + flex: 1; + font-weight: 600; + font-size: 13px; + font-family: inherit; + color: inherit; + background: var(--bg); + border: 1px solid var(--accent-border); + border-radius: 4px; + padding: 1px 5px; + min-width: 0; + } + .shelf-name-input:focus { + outline: none; + border-color: var(--accent); + } + .rename-error { + font-size: 11px; + color: var(--danger-soft); + padding: 0 12px 8px; + } .shelf-meta { font-size: 11px; color: var(--text-dim); } .shelf-body { display: flex; - flex-wrap: wrap; - gap: 8px; + flex-direction: column; + gap: 6px; padding: 10px 12px; flex: 1; - align-content: flex-start; } .chip { display: flex; align-items: center; - gap: 7px; + gap: 9px; background: var(--bg); border: 1px solid var(--border-input); border-radius: 7px; @@ -234,23 +354,32 @@ .chip:hover { border-color: var(--accent); } - .chip-id { - font-size: 11px; - color: var(--text-muted); + .chip-info { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; } - .chip-name { - font-size: 11.5px; + .chip-title { + font-size: 12px; color: var(--text-2); - max-width: 150px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } - .chip-rem { + .chip-id { font-size: 11px; color: var(--text-muted); + margin-right: 2px; } - .chip-rem.low { + .chip-subtitle { + font-size: 10.5px; + color: var(--text-dim); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .chip-subtitle .low { color: var(--danger-soft); } .empty {