From 30787e36d2c8aa2f9473c0747cb8dfd7da6da0dc Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 8 Apr 2026 22:10:42 +0200 Subject: [PATCH] refactor(mana/web): consolidate DetailView scaffolding into DetailViewShell + useDetailEntity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every module's inline-editable DetailView reimplemented the same plumbing: liveQuery → optional decryptRecord → reset on id change → focused/confirmDelete state → save-on-blur → deleteWithUndo via toastStore. Plus ~150 LOC of duplicated scoped CSS for the .detail-view / .title-input / .properties / .prop-row / .section / .danger-zone style track. Extract two pieces: - useDetailEntity (svelte runes module, $lib/data/detail-entity.svelte.ts) handles the JS plumbing: liveQuery + optional decrypt + reset on id change + focused/confirmDelete state + deleteWithUndo. Supports a custom `loader` for cross-table joins (events+timeBlocks, timeEntries+timeBlocks, tasks+timeBlocks). - DetailViewShell ($lib/components/DetailViewShell.svelte) handles the visual scaffold: outer flex column with scroll, loading/not-found state, body snippet, danger zone with confirm flow. Exports the shared field/property/section/meta classes as :global so consumer snippets can use them without redefining. Migrated 16 of the 18 DetailViews. Skipped: - zitare: no DB entity (quotes from bundled @zitare/content), no edit/delete flow. - events: different page shape (centered max-width, edit/view modes, eventId via direct prop instead of params, nested guest list / RSVP sections). Side wins: - 6 encrypted modules (storage, uload, music, questions, calendar, todo) now route their decrypt logic through one path instead of six separate `liveQuery + decryptRecord({ ...raw })` variations. - times/views/DetailView had the same latent type bug as the ListView (reading .date / .startTime / .endTime / .source off LocalTimeEntry, which doesn't define them). Now uses toTimeEntry() via the loader option for the joined TimeEntry shape. Net impact: ~3640 LOC removed across the 16 files (~49% reduction), ~510 LOC added for shell + helper. Net ~3130 LOC saved. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/lib/components/DetailViewShell.svelte | 374 +++++++++++++++ .../web/src/lib/data/detail-entity.svelte.ts | 187 ++++++++ .../modules/calendar/views/DetailView.svelte | 372 ++++---------- .../lib/modules/cards/views/DetailView.svelte | 329 ++----------- .../citycorners/views/DetailView.svelte | 332 ++----------- .../modules/contacts/views/DetailView.svelte | 388 ++++----------- .../modules/inventar/views/DetailView.svelte | 323 ++----------- .../modules/memoro/views/DetailView.svelte | 317 ++---------- .../lib/modules/music/views/DetailView.svelte | 314 ++---------- .../modules/places/views/DetailView.svelte | 393 ++++----------- .../modules/planta/views/DetailView.svelte | 306 ++---------- .../lib/modules/presi/views/DetailView.svelte | 312 ++---------- .../modules/questions/views/DetailView.svelte | 300 ++---------- .../modules/skilltree/views/DetailView.svelte | 331 ++----------- .../modules/storage/views/DetailView.svelte | 258 ++-------- .../lib/modules/times/views/DetailView.svelte | 398 +++------------ .../lib/modules/todo/views/DetailView.svelte | 452 ++++-------------- .../lib/modules/uload/views/DetailView.svelte | 358 ++------------ 18 files changed, 1472 insertions(+), 4572 deletions(-) create mode 100644 apps/mana/apps/web/src/lib/components/DetailViewShell.svelte create mode 100644 apps/mana/apps/web/src/lib/data/detail-entity.svelte.ts diff --git a/apps/mana/apps/web/src/lib/components/DetailViewShell.svelte b/apps/mana/apps/web/src/lib/components/DetailViewShell.svelte new file mode 100644 index 000000000..02dd3026c --- /dev/null +++ b/apps/mana/apps/web/src/lib/components/DetailViewShell.svelte @@ -0,0 +1,374 @@ + + + +
+ {#if loading && !entity} +

Lade…

+ {:else if !entity} + {#if notFound} + {@render notFound()} + {:else} +

{notFoundLabel}

+ {/if} + {:else} + {@render body(entity)} + +
+ {#if confirmDelete} +

{confirmDeleteLabel}

+
+ + +
+ {:else} + + {/if} +
+ {/if} +
+ + diff --git a/apps/mana/apps/web/src/lib/data/detail-entity.svelte.ts b/apps/mana/apps/web/src/lib/data/detail-entity.svelte.ts new file mode 100644 index 000000000..2698ec77b --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/detail-entity.svelte.ts @@ -0,0 +1,187 @@ +/** + * useDetailEntity — shared plumbing for inline-editable DetailView screens. + * + * Encodes the boilerplate every Mana module's DetailView shares: + * liveQuery → optional decrypt → reset on id change → focused/confirmDelete state + * → delete-with-undo via toastStore. + * + * The consumer keeps its own per-field `$state` variables and form template; + * this helper just removes the ~50 lines of repeated wiring. + * + * @example + * ```svelte + * + * + * + * + * ``` + */ + +import { liveQuery } from 'dexie'; +import { onDestroy } from 'svelte'; +import { db } from './database'; +import { decryptRecord } from './crypto'; +import { toastStore } from '@mana/shared-ui/toast'; + +export interface DetailEntityOptions { + /** Reactive getter for the entity id (driven by `params.someId`). */ + id: () => string | undefined; + /** Dexie table name. Required unless `loader` is provided. */ + table?: string; + /** When true, the loaded record is run through `decryptRecord` (only used with `table`). */ + decrypt?: boolean; + /** + * Custom loader for cross-table joins or other non-trivial fetches. + * If provided, takes precedence over `table` + `decrypt`. + * Receives the current id; should return the assembled record (or null). + */ + loader?: (id: string) => Promise; + /** + * Called whenever a fresh entity is loaded AND no input is currently + * focused. Use this to populate per-field `$state` variables. + */ + onLoad?: (entity: T) => void; +} + +export interface DeleteWithUndoOptions { + /** Toast label, e.g. "Datei gelöscht". */ + label: string; + /** Performs the soft-delete (typically a store call). */ + delete: () => Promise; + /** Navigation back, called after the delete resolves. */ + goBack: () => void; +} + +export interface DetailEntityHandle { + readonly entity: T | null; + readonly loading: boolean; + readonly focused: boolean; + readonly confirmDelete: boolean; + focus: () => void; + blur: () => void; + askDelete: () => void; + cancelDelete: () => void; + deleteWithUndo: (opts: DeleteWithUndoOptions) => Promise; +} + +export function useDetailEntity( + opts: DetailEntityOptions +): DetailEntityHandle { + let entity = $state(null); + let loading = $state(true); + let focused = $state(false); + let confirmDelete = $state(false); + + let unsubscribe: (() => void) | null = null; + + $effect(() => { + const id = opts.id(); + // Reset transient UI state on every id change. + confirmDelete = false; + focused = false; + entity = null; + loading = true; + + if (unsubscribe) { + unsubscribe(); + unsubscribe = null; + } + if (!id) { + loading = false; + return; + } + + const obs = liveQuery(async () => { + if (opts.loader) { + return await opts.loader(id); + } + if (!opts.table) { + throw new Error('useDetailEntity requires either `table` or `loader`'); + } + const raw = await db.table(opts.table).get(id); + if (!raw) return null; + if (opts.decrypt) { + // clone before decrypt so the IDB-cached row stays ciphertext + return (await decryptRecord(opts.table, { ...raw })) as T; + } + return raw; + }); + const sub = obs.subscribe((val) => { + entity = val ?? null; + loading = false; + if (val && !focused) { + opts.onLoad?.(val); + } + }); + unsubscribe = () => sub.unsubscribe(); + }); + + onDestroy(() => { + if (unsubscribe) unsubscribe(); + }); + + return { + get entity() { + return entity; + }, + get loading() { + return loading; + }, + get focused() { + return focused; + }, + get confirmDelete() { + return confirmDelete; + }, + focus: () => { + focused = true; + }, + blur: () => { + focused = false; + }, + askDelete: () => { + confirmDelete = true; + }, + cancelDelete: () => { + confirmDelete = false; + }, + async deleteWithUndo({ label, delete: doDelete, goBack }: DeleteWithUndoOptions) { + const id = opts.id(); + if (!id) return; + await doDelete(); + goBack(); + if (opts.table) { + toastStore.undo(label, () => { + db.table(opts.table!).update(id, { + deletedAt: undefined, + updatedAt: new Date().toISOString(), + }); + }); + } else { + // Custom loader: consumer must provide its own undo via the toast directly. + toastStore.undo(label, () => {}); + } + }, + }; +} diff --git a/apps/mana/apps/web/src/lib/modules/calendar/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/calendar/views/DetailView.svelte index 6605110cb..c625beacc 100644 --- a/apps/mana/apps/web/src/lib/modules/calendar/views/DetailView.svelte +++ b/apps/mana/apps/web/src/lib/modules/calendar/views/DetailView.svelte @@ -3,11 +3,12 @@ All fields are always editable. Changes auto-save on blur. --> -
- {#if !event} -

Termin nicht gefunden

- {:else} - + + {#snippet body(event)} (focused = true)} + onfocus={detail.focus} onblur={saveField} placeholder="Titel..." /> -
@@ -134,7 +131,7 @@ type="date" class="prop-input" bind:value={editDate} - onfocus={() => (focused = true)} + onfocus={detail.focus} onblur={saveField} /> {#if !editAllDay} @@ -143,7 +140,7 @@ type="time" class="prop-input" bind:value={editStartTime} - onfocus={() => (focused = true)} + onfocus={detail.focus} onblur={saveField} /> @@ -151,7 +148,7 @@ type="time" class="prop-input" bind:value={editEndTime} - onfocus={() => (focused = true)} + onfocus={detail.focus} onblur={saveField} />
@@ -168,21 +165,20 @@ (focused = true)} + onfocus={detail.focus} onblur={saveField} placeholder="Ort hinzufügen..." />
- {#if timeBlock?.recurrenceRule} + {#if event._block?.recurrenceRule}
🔁 - {timeBlock.recurrenceRule} + {event._block.recurrenceRule}
{/if}
- {#if eventTags.length > 0}
@@ -202,23 +198,20 @@
{/if} - -
-
{#if event.createdAt} Erstellt: {new Date(event.createdAt).toLocaleDateString('de')} @@ -227,118 +220,27 @@ Bearbeitet: {new Date(event.updatedAt).toLocaleDateString('de')} {/if}
- - -
- {#if confirmDelete} -

Termin wirklich löschen?

-
- - -
- {:else} - - {/if} -
- {/if} - + {/snippet} + diff --git a/apps/mana/apps/web/src/lib/modules/cards/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/cards/views/DetailView.svelte index eb8f0ade7..3362202cc 100644 --- a/apps/mana/apps/web/src/lib/modules/cards/views/DetailView.svelte +++ b/apps/mana/apps/web/src/lib/modules/cards/views/DetailView.svelte @@ -5,64 +5,50 @@ -
- {#if !deck} -

Deck nicht gefunden

- {:else} - + + detail.deleteWithUndo({ + label: 'Deck gelöscht', + delete: () => deckStore.deleteDeck(deckId), + goBack, + })} +> + {#snippet body(deck)} (focused = true)} + onfocus={detail.focus} onblur={saveField} placeholder="Deck-Name..." /> -
Farbe @@ -109,7 +96,7 @@ type="color" class="color-input" bind:value={editColor} - onfocus={() => (focused = true)} + onfocus={detail.focus} onblur={saveField} />
@@ -134,259 +121,23 @@ {/if}
-
-
Erstellt: {new Date(deck.createdAt ?? '').toLocaleDateString('de')} {#if deck.updatedAt} Bearbeitet: {new Date(deck.updatedAt).toLocaleDateString('de')} {/if}
- - -
- {#if confirmDelete} -

Deck wirklich löschen?

-
- - -
- {:else} - - {/if} -
- {/if} -
- - + {/snippet} + diff --git a/apps/mana/apps/web/src/lib/modules/citycorners/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/citycorners/views/DetailView.svelte index 49da46b70..c94c2be94 100644 --- a/apps/mana/apps/web/src/lib/modules/citycorners/views/DetailView.svelte +++ b/apps/mana/apps/web/src/lib/modules/citycorners/views/DetailView.svelte @@ -5,49 +5,34 @@ -
- {#if !location} -

Ort nicht gefunden

- {:else} - + + detail.deleteWithUndo({ + label: 'Ort gelöscht', + delete: deleteLocation, + goBack, + })} +> + {#snippet body(location)}
(focused = true)} + onfocus={detail.focus} onblur={saveField} placeholder="Name..." /> -
-
-
Adresse (focused = true)} + onfocus={detail.focus} onblur={saveField} placeholder="Adresse..." />
-
-
Erstellt: {new Date(location.createdAt ?? '').toLocaleDateString('de')} {#if location.updatedAt} Bearbeitet: {new Date(location.updatedAt).toLocaleDateString('de')} {/if}
- - -
- {#if confirmDelete} -

Ort wirklich löschen?

-
- - -
- {:else} - - {/if} -
- {/if} -
+ {/snippet} + diff --git a/apps/mana/apps/web/src/lib/modules/contacts/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/contacts/views/DetailView.svelte index 6e6e400e6..86b9338c3 100644 --- a/apps/mana/apps/web/src/lib/modules/contacts/views/DetailView.svelte +++ b/apps/mana/apps/web/src/lib/modules/contacts/views/DetailView.svelte @@ -3,32 +3,19 @@ All fields are always editable. Changes auto-save on blur. --> -
- {#if !contact} -

Kontakt nicht gefunden

- {:else} - + + detail.deleteWithUndo({ + label: 'Kontakt gelöscht', + delete: () => contactsStore.deleteContact(contactId), + goBack, + })} +> + {#snippet body(contact)}
{initials(contact)}
(focused = true)} + onfocus={detail.focus} onblur={saveField} placeholder="Vorname" /> (focused = true)} + onfocus={detail.focus} onblur={saveField} placeholder="Nachname" /> @@ -161,14 +136,13 @@
-
(focused = true)} + onfocus={detail.focus} onblur={saveField} placeholder="E-Mail" type="email" @@ -180,7 +154,7 @@ (focused = true)} + onfocus={detail.focus} onblur={saveField} placeholder="Telefon" type="tel" @@ -192,7 +166,7 @@ (focused = true)} + onfocus={detail.focus} onblur={saveField} placeholder="Mobil" type="tel" @@ -205,14 +179,14 @@ (focused = true)} + onfocus={detail.focus} onblur={saveField} placeholder="Firma" /> (focused = true)} + onfocus={detail.focus} onblur={saveField} placeholder="Position" /> @@ -225,7 +199,7 @@ (focused = true)} + onfocus={detail.focus} onblur={saveField} placeholder="Straße" /> @@ -233,14 +207,14 @@ (focused = true)} + onfocus={detail.focus} onblur={saveField} placeholder="PLZ" /> (focused = true)} + onfocus={detail.focus} onblur={saveField} placeholder="Stadt" /> @@ -248,7 +222,7 @@ (focused = true)} + onfocus={detail.focus} onblur={saveField} placeholder="Land" /> @@ -260,7 +234,7 @@ (focused = true)} + onfocus={detail.focus} onblur={saveField} placeholder="Website" type="url" @@ -272,14 +246,13 @@ (focused = true)} + onfocus={detail.focus} onblur={saveField} type="date" />
- {#if contactTags.length > 0}
@@ -299,26 +272,23 @@
{/if} - -
-
{#if contact.createdAt} Erstellt: {new Date(contact.createdAt).toLocaleDateString('de')} @@ -327,58 +297,27 @@ Bearbeitet: {new Date(contact.updatedAt).toLocaleDateString('de')} {/if}
- - -
- {#if confirmDelete} -

Kontakt wirklich löschen?

-
- - -
- {:else} - - {/if} -
- {/if} -
+ {/snippet} +
diff --git a/apps/mana/apps/web/src/lib/modules/inventar/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/inventar/views/DetailView.svelte index 0b83e3e21..7e1330384 100644 --- a/apps/mana/apps/web/src/lib/modules/inventar/views/DetailView.svelte +++ b/apps/mana/apps/web/src/lib/modules/inventar/views/DetailView.svelte @@ -5,90 +5,73 @@ -
- {#if !collection} -

Sammlung nicht gefunden

- {:else} - + + detail.deleteWithUndo({ + label: 'Sammlung gelöscht', + delete: () => collectionsStore.delete(collectionId), + goBack, + })} +> + {#snippet body(collection)}
{#if collection.icon} {collection.icon} @@ -96,22 +79,21 @@ (focused = true)} + onfocus={detail.focus} onblur={saveField} placeholder="Name..." />
-
Icon (focused = true)} + onfocus={detail.focus} onblur={saveField} - placeholder="z.B. 📦" + placeholder="z.B. 📦" />
@@ -120,252 +102,35 @@ (focused = true)} + onfocus={detail.focus} onblur={saveField} placeholder="z.B. #78716C" />
- Gegenstaende + Gegenstände {itemCount}
-
-
Erstellt: {new Date(collection.createdAt ?? '').toLocaleDateString('de')} {#if collection.updatedAt} Bearbeitet: {new Date(collection.updatedAt).toLocaleDateString('de')} {/if}
- - -
- {#if confirmDelete} -

Sammlung wirklich loeschen?

-
- - -
- {:else} - - {/if} -
- {/if} -
- - + {/snippet} + diff --git a/apps/mana/apps/web/src/lib/modules/memoro/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/memoro/views/DetailView.svelte index 4f5867471..2ebaef15f 100644 --- a/apps/mana/apps/web/src/lib/modules/memoro/views/DetailView.svelte +++ b/apps/mana/apps/web/src/lib/modules/memoro/views/DetailView.svelte @@ -3,47 +3,32 @@ Memo details with transcript, pin toggle, auto-save on blur. --> -
- {#if !memo} -

Memo nicht gefunden

- {:else} - + + detail.deleteWithUndo({ + label: 'Memo gelöscht', + delete: () => memosStore.delete(memoId), + goBack, + })} +> + {#snippet body(memo)}
(focused = true)} + onfocus={detail.focus} onblur={saveField} placeholder="Titel..." /> @@ -110,11 +98,10 @@
-
Status - + {statusLabels[memo.processingStatus]}
@@ -129,27 +116,25 @@ (focused = true)} + onfocus={detail.focus} onblur={saveField} placeholder="z.B. de" />
-
- {#if memo.transcript}
@@ -157,78 +142,26 @@
{/if} -
Erstellt: {new Date(memo.createdAt ?? '').toLocaleDateString('de')} {#if memo.updatedAt} Bearbeitet: {new Date(memo.updatedAt).toLocaleDateString('de')} {/if}
- - -
- {#if confirmDelete} -

Memo wirklich loeschen?

-
- - -
- {:else} - - {/if} -
- {/if} - + {/snippet} + diff --git a/apps/mana/apps/web/src/lib/modules/music/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/music/views/DetailView.svelte index fa56910b5..6f60f6ae8 100644 --- a/apps/mana/apps/web/src/lib/modules/music/views/DetailView.svelte +++ b/apps/mana/apps/web/src/lib/modules/music/views/DetailView.svelte @@ -3,22 +3,16 @@ All fields are always editable. Changes auto-save on blur. --> -
- {#if !song} -

Song nicht gefunden

- {:else} - + + detail.deleteWithUndo({ + label: 'Song gelöscht', + delete: () => libraryStore.delete(songId), + goBack, + })} +> + {#snippet body(song)}
(focused = true)} + onfocus={detail.focus} onblur={saveField} placeholder="Titel..." /> -
-
Künstler (focused = true)} + onfocus={detail.focus} onblur={saveField} placeholder="Unbekannt" /> @@ -123,9 +104,9 @@ (focused = true)} + onfocus={detail.focus} onblur={saveField} - placeholder="--" + placeholder="—" />
@@ -134,9 +115,9 @@ (focused = true)} + onfocus={detail.focus} onblur={saveField} - placeholder="--" + placeholder="—" />
@@ -146,9 +127,9 @@ type="number" class="prop-input" bind:value={editYear} - onfocus={() => (focused = true)} + onfocus={detail.focus} onblur={saveField} - placeholder="--" + placeholder="—" />
@@ -158,9 +139,9 @@ type="number" class="prop-input" bind:value={editBpm} - onfocus={() => (focused = true)} + onfocus={detail.focus} onblur={saveField} - placeholder="--" + placeholder="—" /> @@ -175,7 +156,6 @@ -
Erstellt: {new Date(song.createdAt ?? '').toLocaleDateString('de')} {#if song.updatedAt} @@ -185,205 +165,5 @@ Zuletzt gehört: {new Date(song.lastPlayedAt).toLocaleDateString('de')} {/if}
- - -
- {#if confirmDelete} -

Song wirklich löschen?

-
- - -
- {:else} - - {/if} -
- {/if} - - - + {/snippet} + diff --git a/apps/mana/apps/web/src/lib/modules/places/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/places/views/DetailView.svelte index 885430ca8..a808e68d7 100644 --- a/apps/mana/apps/web/src/lib/modules/places/views/DetailView.svelte +++ b/apps/mana/apps/web/src/lib/modules/places/views/DetailView.svelte @@ -3,23 +3,20 @@ All fields are always editable. Changes auto-save on blur. --> -
- {#if !place} -

Ort nicht gefunden

- {:else} - + + {#snippet body(place)}
@@ -146,7 +138,7 @@ (focused = true)} + onfocus={detail.focus} onblur={saveField} placeholder="Name" /> @@ -156,7 +148,6 @@
- {#if mapUrl}