feat(web): header pill showing PhotoPrism indexer status

Subscribes to PhotoPrism's /api/v1/ws channel on login and surfaces
index.indexing / index.updating / index.completed events as a small
status pill in the header (next to the AnimatedMule wordmark).

- Shows "Indexing" + the current filename (basename, monospace) during
  the scan pass, "Finalizing — <step>" during faces/counts/folders/
  purge/moments, and "Indexed in Ns" for ~4s after completion before
  fading.
- Per-file events arrive many per second on large libraries — throttled
  to 150 ms with a trailing-edge update so the pill stays calm and
  always lands on the most recent filename. Step and completion events
  bypass the throttle.
- Filename slot is fixed at 24ch so the pill width stays constant
  through a run (no horizontal jitter as filenames change length); the
  full path is exposed via the parent's `title` for hover.
- WS reconnect uses exponential backoff capped at 30 s, and the store
  tears down cleanly on logout so we don't leak sockets across
  identities.

Defensive parsing throughout: PhotoPrism's WS protocol isn't a stable
contract, so unknown event shapes are ignored rather than thrown —
worst-case the pill stays idle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 23:13:46 +02:00
parent 3d8e050af4
commit e669e80a91
3 changed files with 315 additions and 1 deletions

View File

@@ -14,6 +14,8 @@
import { resizable } from '$lib/actions/resizable';
import { queryClient } from '$lib/queryClient';
import { preview } from '$lib/stores/preview.svelte';
import { startIndexerWatch, stopIndexerWatch } from '$lib/stores/indexer.svelte';
import IndexerStatusPill from '$lib/components/layout/IndexerStatusPill.svelte';
import LeftSidebar from '$lib/components/layout/LeftSidebar.svelte';
import AnimatedMule from '$lib/components/mule/AnimatedMule.svelte';
@@ -44,6 +46,16 @@
}
});
// Indexer status feed. The store opens a WS to PhotoPrism's /api/v1/ws
// channel once we're signed in and tears it down on logout. Kept here
// (not in the store's top-level) so SvelteKit's SSR pass never touches
// `WebSocket` and so logout can short-circuit reconnect attempts.
$effect(() => {
if (!browser || !bootstrapped) return;
if (isAuthenticated()) startIndexerWatch();
else stopIndexerWatch();
});
// PreviewOverlay is the full-screen lightbox — keyboard nav, map
// pane, exif sidebar. Users who never click into a photo never need
// it, so we lazy-import the first time `preview.uid` flips non-null
@@ -75,7 +87,9 @@
region inside each page scrolls. The mule header, toolbar, and
sidebars stay fixed regardless of how far you scroll the grid. -->
<div class="flex h-screen flex-col overflow-hidden">
<AnimatedMule />
<AnimatedMule>
<IndexerStatusPill />
</AnimatedMule>
<div class="flex min-h-0 flex-1">
{#if !view.leftSidebarCollapsed}
<aside