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

@@ -0,0 +1,49 @@
<!--
Compact status pill that appears in the header while PhotoPrism's
indexer is doing work. Driven by the indexer store, which subscribes
to PhotoPrism's WS channel. Renders nothing when idle so it never
steals header real estate from the user.
The `detail` (current path/file) is exposed via `title` rather than
rendered inline — the pill stays narrow even on slow flashes through
a deep library, and hover surfaces the detail for users who care.
-->
<script lang="ts">
import { indexer } from '$lib/stores/indexer.svelte';
import { Loader2 } from 'lucide-svelte';
// PhotoPrism's `fileName` arrives as the full relative path
// (`subdir/IMG_0554.HEIC.jpg`). The basename is enough for inline
// recognition; the full path stays in the `title` for users who hover.
const basename = $derived.by(() => {
const d = indexer.detail;
if (!d) return '';
const i = d.lastIndexOf('/');
return i >= 0 ? d.slice(i + 1) : d;
});
</script>
{#if indexer.active || indexer.label}
<div
class="flex items-center gap-1.5 rounded-full border border-border bg-background/80 px-2.5 py-1 text-xs text-foreground shadow-sm backdrop-blur"
title={indexer.detail ?? indexer.label}
role="status"
aria-live="polite"
>
{#if indexer.active}
<Loader2 class="h-3 w-3 animate-spin text-primary" />
{/if}
<span class="whitespace-nowrap">{indexer.label}</span>
{#if basename}
<!-- Fixed-width slot so the pill stops shrinking/growing as
PhotoPrism rattles through files of different name lengths.
`w-[24ch]` locks the column; `truncate` ellipsises anything
longer. The full path remains in the parent's `title`. -->
<span
class="w-[24ch] truncate text-left font-mono text-[10px] text-muted-foreground"
>
{basename}
</span>
{/if}
</div>
{/if}