diff --git a/web/src/lib/components/layout/GeneralSettingsDialog.svelte b/web/src/lib/components/layout/GeneralSettingsDialog.svelte index 426d121..62d6547 100644 --- a/web/src/lib/components/layout/GeneralSettingsDialog.svelte +++ b/web/src/lib/components/layout/GeneralSettingsDialog.svelte @@ -90,21 +90,43 @@ enabled: open })); + /** + * Some PhotoPrism deployments return `/settings` without the + * `ui` / `search` / `maps` keys (older versions, custom edits to + * settings.yml). The form's `bind:value={draft.ui!.theme}` etc. + * non-null-asserts those sub-objects — when they're missing the + * assertion lies and the bind getter throws on the next tick. Force + * the shape on every clone so every binding has a real object to + * write into, and so `draft.ui` is never null while `draft` is non- + * null (template gates only check `draft`). + */ + function normalize(s: PpSettings): PpSettings { + return { + ...s, + ui: s.ui ?? {}, + search: s.search ?? {}, + maps: s.maps ?? {} + }; + } + let draft = $state(null); + // Re-clone on each open so reopening the dialog shows the freshest + // server state. Eagerly nulling on close used to introduce a window + // where Dialog's exit animation kept the form mounted while draft + // was already null — and bind:value getters read null, triggering + // "$.get(...) is null" / can't access .ui at runtime. Resetting on + // open instead avoids that race entirely. $effect(() => { - if (settingsQuery.data && draft === null) { - draft = structuredClone(settingsQuery.data); + if (open && settingsQuery.data) { + draft = normalize(structuredClone(settingsQuery.data)); } }); - $effect(() => { - if (!open) draft = null; - }); const saveMut = createMutation(() => ({ mutationFn: (patch: PpSettings) => saveSettings(patch), onSuccess: (next) => { qc.setQueryData(['settings'], next); - draft = structuredClone(next); + draft = normalize(structuredClone(next)); toast.success('Settings saved'); }, onError: (err) => @@ -112,7 +134,7 @@ })); function resetDraft() { - if (settingsQuery.data) draft = structuredClone(settingsQuery.data); + if (settingsQuery.data) draft = normalize(structuredClone(settingsQuery.data)); } const selectClass = diff --git a/web/src/lib/stores/indexer.svelte.ts b/web/src/lib/stores/indexer.svelte.ts index 42ab2f2..4657941 100644 --- a/web/src/lib/stores/indexer.svelte.ts +++ b/web/src/lib/stores/indexer.svelte.ts @@ -126,11 +126,6 @@ function handleMessage(raw: string): void { const eventName = inner.event as string | undefined; const data = (inner.data ?? {}) as Record; if (!eventName) return; - // PhotoPrism's WS protocol isn't a stable contract; log the live shape - // at `debug` (hidden by default in DevTools — toggle "Verbose" to see) - // so future-us can spot new indexer event names without instrumenting - // the entire app. - console.debug('[indexer]', eventName, data); switch (eventName) { case 'index.indexing': { // Per-file event during the scan pass. PhotoPrism emits one diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index a50abc5..224401b 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -1,975 +1,1006 @@ - - {sectionLabel} - - - {#if photos.length > 0} - - {/if} - {#if filters.section === 'archive' && photos.length > 0} - - {/if} -
- - - {#if filters.search} - - {/if} - - {#if searchFocused} -
-
- Examples -
- {#each SEARCH_EXAMPLES as ex (ex)} - - {/each} -
- {/if} -
+ {#if searchFocused} +
+
+ Examples +
+ {#each SEARCH_EXAMPLES as ex (ex)} + + {/each} +
+ {/if} + - {#snippet trailing()} - -
- {#each THUMBNAIL_SIZE_PRESETS as size, i (size)} - - {/each} -
- - {/snippet} +
+ {#each THUMBNAIL_SIZE_PRESETS as size, i (size)} + + {/each} +
+ {/snippet}
- -
-
{ - visFirst = f; - visLast = l; - }, - sampleEvery: TILE_SAMPLE - }} - > -
- {#if photosQuery.isPending} - - {:else if photosQuery.isError} -

- Failed to load photos: {photosQuery.error instanceof Error - ? photosQuery.error.message - : 'unknown error'} -

- {:else if photos.length === 0} -

- {#if filters.section === 'archive'} - Archive is empty. - {:else if filters.section === 'favorites'} - No favorites yet. Heart a photo to add it here. - {:else if filters.section === 'review'} - Nothing left to review. Photos PhotoPrism's indexer wasn't sure about - land here — use Keep to accept them into the timeline or Archive to - set them aside. - {:else if filters.section === 'hidden'} - No hidden photos. PhotoPrism auto-hides files it can't index (broken - files, very low quality); they only ever show up here. - {:else if filters.section === 'heap'} - This heap has no photos yet. Select some photos and use the bulk bar's - "+ Add to heap" button. - {:else} - No photos. Index a folder via PhotoPrism's reindex command. - {/if} -

- {:else} -
- {#each rows as row (row.kind === 'header' ? `h:${row.key}` : `t:${row.photo.UID}`)} - {#if row.kind === 'header'} - -

- {row.label} - - {row.count} - -

- {:else} - {@const photo = row.photo} - {@const i = row.tileIndex} - {@const inWindow = i >= renderFirst && i <= renderLast} - -
- {#if inWindow} - {@const sel = selectedIds.has(photo.UID) || focusedUid === photo.UID} - onTileClick(e, photo.UID)} - onDblclick={(e) => onTileDblclick(e, photo.UID)} - onOpenPreview={() => onTileOpenPreview(photo.UID)} - /> - {/if} -
- {/if} - {/each} -
+
+ {#if inWindow} + {@const sel = + selectedIds.has(photo.UID) || focusedUid === photo.UID} + onTileClick(e, photo.UID)} + onDblclick={(e) => onTileDblclick(e, photo.UID)} + onOpenPreview={() => onTileOpenPreview(photo.UID)} + /> + {/if} +
+ {/if} + {/each} +
- - - {#if photosQuery.isFetchingNextPage} -

Loading more…

- {/if} - {/if} -
- - -
+ + {#if photosQuery.isFetchingNextPage} +

+ Loading more… +

+ {/if} + {/if} + + + + - {#if !view.rightSidebarCollapsed} - - {/if} +
view.rightSidebarWidth, + setWidth: setRightSidebarWidth, + }} + role="separator" + aria-orientation="vertical" + aria-label="Resize info panel" + > +
+
+ + {/if}