Replace the fullscreen PreviewOverlay with an inline top pane above
each grid surface. SplitGrid + InlinePreview render the focused
photo/video inside the host page; a resizableVertical action drives
the divider and the height persists via the view store. Applied to
the timeline, /tags drill-in, /review cause tabs, /photo/[uid], and
/map. selection.focused is now the single source of truth for both
the inline pane and the right sidebar — preview.svelte store and
PreviewOverlay are removed.
Sidebar: drop the thumb; lead with icon-led filename and folder
rows that match the date/place rhythm. Move dims+size to the top
(below date) and camera/lens/exposure into the collapsible File
section. Read-only spans share the input padding so the text column
aligns across rows. Folder row sits between date and dims+size.
VideoPlayer: stop forcing width/height: 100% so videos honour their
intrinsic aspect ratio inside the pane. Key the player on file hash
in InlinePreview so navigating between videos remounts the element
and autoplay fires again.
Sidebar (LeftSidebar): switch the labels badge to a dedicated
countPhotos('label:*') query so it reports photos with a label
rather than PhotoPrism's category roll-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PhotoPrism's /api/v1/config.count is library-wide and the same value
for every authenticated session. That made non-admins (and admins
with a non-empty BasePath) see badges that didn't match what the
timeline actually showed them.
Replaces the direct `configQuery.data?.count?.<bucket>` reads in
LeftSidebar with per-bucket queries against PhotoPrism's /photos
endpoint. The new `countPhotos(q)` helper sets `count=10000` and
reads the X-Count response header to get the true total in one round
-trip (PhotoPrism's ACL filter is what scopes the result, so the
header reflects "what this session can see").
Each bucket query appends `path:"<BasePath>*"` so admins-with-a-
BasePath stay scoped too; non-admins without a BasePath short-circuit
to `uid:none` (their effective visibility is zero, no point
querying). Admins without a BasePath skip the scoped queries
entirely and keep using the precomputed /config totals — same
network footprint as before for the common case.
Affected badges: Favorites, Hidden, Archive, Review, Tags (labels
component). Map already used `geoQuery` whose result is ACL-filtered
server-side, so its badge is per-user-correct without changes. The
`favorites` field was missing from PpClientConfig.count's TypeScript
type; added it.
Resolves the `test`-user complaint: sidebar showed the admin
library's totals next to Review / Hidden / Archive / Favorites
because those numbers came from /config, not from a user-scoped
query.
PhotoPrism's /api/v1/config.count returns library-wide aggregates to
any authenticated session, with no per-user scoping. The timeline
itself IS scoped (a guest sees zero photos), but the sidebar was
rendering admin-side totals next to Review / Hidden / Archive / Tags /
Map / root for non-admins — including a freshly-registered "test"
user with role=guest and BasePath="".
Until PhotoPrism gains per-user counters, the SPA now derives an
`isAdminUser` flag and gates every count that's drawn from
configQuery on it. Non-admin users see the labels without badges;
counts re-appear automatically when promoted. Per-folder counts from
the sidecar (which DO scope to BasePath) are unaffected.
Sidebar counts, marks, folder counts, etc. were keyed only on query
name, not on the authenticated user. Logging in as a non-admin kept
rendering the previous admin session's data because the cache was
never invalidated. clearSession and adoptSession now wipe the cache
so each identity starts fresh.
User-observed: the "test" user (role guest, BasePath="") saw the
admin library counts in the left sidebar after signing in.
- /tags: each tab pill shows its own count (labels/keywords =
distinct tags, ratings/colors = photos covered). Labels and marks
queries become always-enabled on the route so every pill resolves
immediately; keywords stays lazy.
- LeftSidebar: Map badge now reads from the shared `['geo']` cache so
it matches /map's "N geotagged" footer instead of count.places
(distinct locations). Tags badge sums the four inner counts;
keywords contributes lazily once /tags?tab=keywords is visited.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- /duplicates and /inbox routes removed and folded into /review as
additional tabs alongside cause tabs; /duplicates keeps a redirect
for bookmarks.
- LeftSidebar: drop import/inbox tile and favorites; show per-user
BasePath label at the folder root.
- RightSidebar: split file header into read-only path over editable
basename (matches sidecar rename contract); date field switches to
plain-text ISO YYYY-MM-DD (no native datetime picker) with strict
validation and revert-on-invalid-blur; preserves original hour.
- BulkMetadataSidebar: same ISO-only date input with invalid-state
styling and apply-button gating.
- BulkActionBar: drop redundant Restore and Undo buttons; ⌘Z still
reachable via gridKeyNav.
- gridKeyNav: remove favorite toggle (F) alongside the favorites view
retirement.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stacks count appears immediately (cheap query); cross-folder count fills
in after its tab is visited (lazy disk scan). Page observes both queries
from cache so badges stay in sync with the panel content.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The /review cards now share the timeline's PhotoGrid, gridKeyNav,
BulkActionBar, and global selection store instead of carrying parallel
implementations. The card is reduced to a header (title + count +
Dismiss / Archive all), an advisory caption, and a PhotoGrid; keyboard
nav, hover affordances, multi-select, and bulk verbs come from the
shared machinery.
- New web/src/lib/services/photoActions.ts holds the canonical
dismissPhotos / archivePhotos helpers (toast wording, focus advance,
undo push, ['photos'] + ['review-groups'] cache invalidation).
BulkActionBar.onApprove / onArchive and gridKeyNav.approveCullTargets
/ toggleArchive('archive') route through it. CauseGroupCard's
Dismiss / Archive-all buttons call the same helpers - one code path
from any surface.
- Approve verb renamed to "Dismiss" across BulkActionBar, gridKeyNav
toasts ("Kept N" -> "Dismissed N"), and the new review card. The
BulkActionBar Clear/Dismiss clear button is just "Clear" now so the
verb only means the action.
- /review sets filters.section='review' on mount and restores on
unmount, which is what swings the shared action surface into review
semantics; an effect clears the selection on tab change so a
previously-selected photo from another cause can't be hit by a new
tab's bulk verb.
- The route mounts BulkActionBar at the bottom and swaps the right
aside to BulkMetadataSidebar when selection.ids.size >= 2 - same as
the timeline; gives the user a one-shot "apply this Date / Caption /
Keyword to all selected" affordance for EXIF-stripped batches.
- CauseGroupCard drops its bespoke keyboard handler, ResizeObserver,
focusedIdx state, per-tile hover Approve/Archive buttons, confirm()
dialogs, and toast.loading worker loop. The unused CauseBadges
component is removed.
Sidebar Duplicates badge now sums stacks + cross-folder groups, with
cross-folder observed from cache (no eager disk scan from the sidebar).
Cross-folder tab auto-fires the scan on access; button becomes Rescan.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PhotoPrism's user entity carries a per-user BasePath; the web app now
mirrors that scope client-side so each user sees only their own subtree
in the sidebar, timeline, folder counts, and heap-convert target picker.
Admin without a BasePath is unchanged. Also removes the redundant
"✕ <folder>" pill below the folder tree.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each cause now becomes a URL-driven tab instead of stacking the cards
vertically. Empty buckets are filtered out by the adapter already, so
the tab row only shows causes with hits. Active tab persists via
?tab=<cause_key>; refresh / share / back land on the same panel.
The standalone ReviewView container is no longer used (the page
renders the active CauseGroupCard directly); deleted.
Builds a dedicated /review route that mirrors /duplicates' chrome:
- /review/+page.svelte mounts Toolbar + ReviewView + RightSidebar
- CauseGroupCard.svelte renders one card per cause with Approve all
and Archive all bulk actions plus a per-cause suggestion line
- CauseBadges.svelte shows every matching cause as chips on each tile
- services/adapters/review.ts fetches review:true and groups photos
by primary cause; current taxonomy is low_resolution >
stripped_exif > implausible_year > non_image_type > quality_other
(low_resolution ranks first because it's the most actionable signal)
Sidebar gains an opt-in showRelated prop that adds three
RelatedStrip panels (same folder / camera / year) for the
'decide these together' workflow.
LeftSidebar's Review entry switches from a section filter to a
route link so /review picks up the click.
PpPhoto gains the missing Resolution field PhotoPrism actually
returns on list responses.
PhotoPrism plays a silent preview of the actual video when you hover
its grid tile; this mirrors that. After a 250ms debounce the tile
mounts a muted, looping <video> over the thumbnail and cross-fades it
in on first decoded frame, so cursor-skimming doesn't fire N requests
and the tile never blanks mid-fetch. The byte-prefetch helper added
in af96922 is now redundant — the hover <video> warms the same caches
on its own.
Also tells Vidstack the playback URL is video/mp4 via a nested
<source>: our /api/v1/videos/.../avc URL has no extension, so
Vidstack's suffix sniff was failing, falling back to a HEAD probe,
and picking the wrong loader (which surfaced as
NS_ERROR_DOM_MEDIA_METADATA_ERR in Firefox).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace PreviewOverlay's bare <video controls> with a vidstack-driven
player wrapping the same source URL. Vidstack's default video layout
provides a polished chrome (gradient bottom bar, large play overlay,
hover-revealed scrubber) and registers <media-player>/<media-provider>
custom elements that vendor the browser quirks.
To keep first-frame latency low, PhotoTile starts a hover-warm fetch
of the playback URL after a short (120 ms) delay — a single Range
request of the first 512 KB pages the backend's pre-transcoded MP4
cache file into the OS page cache and lands in the browser's HTTP
cache, so when the player mounts and issues its own bytes=0- request
the response is satisfied from disk. Each hash is warmed at most once
per session; AbortController cancels hovers that don't commit.
The vidstack modules are dynamically imported on mount so they never
run during SvelteKit's static prerender — they side-effect
customElements.define() calls which would crash under SSR.
Three small cleanups bundled:
- Remove the `console.debug('[indexer]', ...)` line in the indexer
store. The PhotoPrism WS protocol is now verified; the log was a
development aid that no longer earns its console noise.
- GeneralSettingsDialog: normalize cloned PpSettings so `ui` / `search`
/ `maps` are always real objects (some deployments return them
unset), and re-clone the draft on each open instead of nulling it on
close. The previous lifecycle let Dialog's exit animation keep the
form mounted while `draft` was already null, which threw at runtime
via the `bind:value={draft.ui!.theme}` getters.
- Search-input placeholder string: rewrite as a JS expression so the
embedded `"vacation"` quotes inside the example don't terminate the
HTML attribute early. The previous form was a Svelte parse error
that stopped the dev-server module from loading.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Earlier we (a) wildcarded the per-folder count fan-out in the sidecar
so internal tree nodes (year folders, etc.) recurse, and (b) flipped
the timeline root view to mean "the whole library" instead of
"photos with no path component". The remaining piece — the badge on
the root row — still computed `total - Σ(folderCounts)`, which used
to give the count of root-direct photos. With recursive folder
counts that subtraction double-counts every nested photo (year +
month + …) and clamps the badge to 0.
Use PhotoPrism's authoritative `count.all` directly. That now matches
what the timeline shows under `/` (everything indexed) without an
extra round-trip.
GPS, Credits & notes, and File sections in the right sidebar now read
and write their expanded state through the view store and persist it
to localStorage. Closed by default; the user's first toggle pins their
choice across subsequent photos and reloads.
Switched from the previous data-driven defaults ("open if this photo
has GPS / IPTC fields") to static defaults: a data-driven default would
change between photos, fire a programmatic `toggle` event on the
<details> element, and silently overwrite the user's persisted choice.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
X (archive/restore), Delete, and S (approve) keyboard handlers in
gridKeyNav advanced focus and invalidated the photos query but never
cleared the selection — so the archived/deleted/approved UIDs stayed in
the SvelteSet and kept their rings on tiles that hadn't unmounted yet.
A subsequent Ctrl-click would then pile new UIDs on top of the stale
set, leaving the user uncertain which photos a follow-up action would
actually target. The BulkActionBar button path already cleared selection
for the same reason; mirror that here.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two paired fixes for the folder tree on the timeline:
* +page.svelte: drop the `Path === ''` post-filter for the root entry.
PhotoPrism's indexer always nests photos under YYYY/MM, so "photos
whose Path is empty" is always the empty set in practice — the root
entry looked broken instead of "whole library". Treat `/` as the
unscoped view and rely on subfolder selections (now wildcarded via
filters.svelte.ts) for narrowing.
* sidecar/handlers_folders.go: the per-folder count fan-out used
`q=path:<x>`, the same exact-match operator that just got fixed in
the web filter. Result: every year-level folder reported count=0
in the sidebar. Switch to `q=path:"<x>*"` so the count reflects
the whole subtree (dedupe by UID still in place).
filtersToQ emitted `path:<folder>` for any non-root folder, but
PhotoPrism's `path:` operator is exact-by-default — so picking the
"2024" node in the folder tree returned zero hits when all photos
lived in date-stamped sub-folders (`2024/01`, `2024/02`, …). PP's
indexer always nests photos under YYYY/MM, so every year-level
folder was empty in the timeline.
PhotoPrism supports a trailing `*` wildcard, so emit
`path:"<folder>*"` instead:
path:"2024*" → matches `2024`, `2024/01`, `2024/02/...`, …
path:"2024/01*" → matches `2024/01` plus descendants — still
correct for a leaf folder.
Confirmed against the M0 instance: picking 2024 now returns the full
year's photos; 2024/01 still returns its direct contents.
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>
Stale import landed when 6b8c7ab rebased on top of the OIDC-rename
commit. The call site updated to bootstrapSessionFromPhotoPrism but
the import line kept the old bootstrapSessionFromCookies name —
svelte-check caught it on the next pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Six-item frontend performance pass on the SvelteKit app.
P1 — Move per-folder photo counts to a new sidecar endpoint and defer
the fetch to requestIdleCallback. The old client-side path fired one
/photos?count=1000 per folder from the browser (≈1 MB JSON × N folders)
on every cold sidebar mount; the new POST /api/sidecar/folders/counts
fans out over loopback with bounded concurrency and returns a single
{path: count} payload of a few KB.
P2 — Bound the visibleRange scroll-scan around the previous visible
band instead of sweeping every shell from index 0 on each scroll-rAF.
Falls back to a full sweep on cache miss (filter reset, programmatic
jump) so behaviour is unchanged at the edges.
P3 — Adaptive thumbnail size + srcset. PhotoTile now picks the smallest
PhotoPrism tile_* variant (100/224/500) that covers the user's grid
preset at the current DPR. Adds decoding="async".
P4 — Lift the selection check above the {#each} loop. Mostly readability
— SvelteSet.has() is already per-key reactive — but keeps the hot loop
body terse.
P5 — Split dedupedAll / photos derivations so filter-store mutations
(search-as-you-type, section toggles) don't re-walk every loaded page;
only the cheap folder-scope filter re-runs.
P6 — Dynamic-import PreviewOverlay on first preview.uid !== null and
cache the loaded module; closing the overlay leaves the component
mounted with its internal {#if} collapsing the DOM.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PhotoPrism's /api/v1/oidc/redirect handler doesn't actually set
auth_token/auth_session cookies — it returns an HTML page that does:
setItem("pp:<storageNamespace>:session.id", <session uid>)
setItem("pp:<storageNamespace>:session.token", <X-Auth-Token value>)
setItem("pp:<storageNamespace>:session.user", <user JSON>)
setItem("pp:<storageNamespace>:session.provider", "oidc")
window.location.href = "/library/login";
The deployment's reverse proxy is expected to bounce /library/login
(and /library/*) back to `/`; the SPA then reads PhotoPrism's
storageNamespace from /api/v1/config, looks up session.id and
session.token under that prefix, and adopts the session.
Confirmed via the M0 test instance: prior to this change, server-side
sessions were created on every OIDC return (DB row present) but the
browser had no way to claim them, so the user bounced back to /login.
The SvelteKit /login was username/password only; the legacy comment
even called out 'OIDC SSO ships in M4 when the IdP is wired up'.
Authentik is wired up now, so:
- /api/v1/config exposes ext.oidc when the IdP is configured. Fetch
it on the login page and conditionally render "Sign in with
{provider}", which kicks off /api/v1/oidc/login.
- After PhotoPrism completes the auth code exchange, it sets
`auth_token` + `auth_session` cookies and redirects to siteUrl
(/library/browse by default; the deployment's reverse proxy is
expected to bounce that to /). bootstrapSessionFromCookies()
reads those cookies, calls GET /api/v1/session/<id> with the
cookie's token, and adopts the resulting session into the SPA
store on mount.
- Root layout's auth guard now waits for the bootstrap pass before
punting to /login, so a fresh OIDC return doesn't get redirected
away before the session is read.
Tailwind v4 dropped the default cursor: pointer on <button>, so most
interactive controls (bulk sidebar, star/color pickers, summary
disclosures) had no hover affordance. Add a global base rule covering
button / [role=button] / summary, plus cursor: not-allowed for disabled
states to mirror the existing opacity-50 styling.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- New selection.focusAfter(excluded) walks selection.order forward past
the archived/restored set so X-ing through the timeline keeps the
cursor on the next live photo instead of falling back to photo[0]
via the auto-anchor effect. Wired into gridKeyNav.toggleArchive (X
key) and BulkActionBar.onArchive.
- Auto-focus effect on the timeline always re-anchors to photos[0] on
view load (pageCount → 1), instead of preserving a stale uid from
the previous filter.
- PhotoGrid re-anchors focus when the previously focused uid isn't in
the new photo set, so drilling into a /tags category drops the
cursor on its first tile instead of carrying a stale selection from
whatever view the user came from.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- visibleRange action rewritten to scan [data-uid-shell] divs on each
rAF-throttled scroll instead of attaching an IntersectionObserver to
sample tiles. The observer approach broke on return from /inbox: with
cached photo data, shells mounted in the same Svelte pass as the
scroll root and tileRegister fired before any __visibleRange stash
was in place, so registrations dropped silently. Fast scrolling could
also strand the observer in a dead zone when every sample tile left
the viewport before the next was mounted. Shells are always rendered,
so a DOM scan always finds a true first/last.
- Extract PhotoTile + SkeletonGrid so the timeline and the drill-in
PhotoGrid share one tile chrome (selection animation, badges,
hover-only "open preview" affordance).
- FolderTree count badge moves inside the row's button so the badge
area becomes part of the click target instead of a dead zone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- /tags hosts four tabs (Labels (auto) / Keywords / Ratings / Colors),
URL-driven with pagination on the label + keyword grids; ratings and
colors stay as fixed buckets.
- /duplicates tabs (Stacks / Cross-folder) restyled to pill row in the
Toolbar to match /tags; tab state moved into the route and bound to
?tab=...
- New aggregateKeywords() service fans out per-photo getPhoto calls so
user-typed Details.Keywords surface on /tags (PhotoPrism's /labels
only returns classifier output).
- RightSidebar renders photo.Labels[] as dashed-border chips after the
Keywords section, each linking to /?q=label:slug.
- /colors and /ratings routes redirect to /tags?tab=colors|ratings so
old bookmarks still land somewhere useful; LeftSidebar drops their
entries and the Tags badge now sums labels + ratings + colors.
- listFolderCounts dedupes by UID (merged=false returns one row per
FILE, so HEIC+JPG / Live Photo / RAW+JPG pairs were inflating folder
counts ~2x).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sidebar
- New `/` root-folder entry at the top of the Folders group. Active
when the timeline is scoped to root; the photo grid post-filters to
`Path === ''` because PhotoPrism's `path:` operator can't express an
exact-root match. Collapsible chevron, persisted to its own
localStorage key, and a kebab carrying just "New subfolder".
- Per-folder count badges. `/api/v1/photos?q=path:X&count=1000` per
folder in parallel via `listFolderCounts`; root count derived from
`config.count.all − Σ subfolder counts`.
- Folder tree starts at depth=1 under the root so nested rows indent
visually relative to `/`.
- Footer matches the Toolbar / action-bar h-9 height.
Timeline interaction
- Single click on a tile selects only that tile (clears others); the
preview now lives on dblclick. Modifier clicks still go through
`gridKeyNav`'s document handler (shift = range, cmd/ctrl = toggle).
- `x` (archive) now actually archives — PhotoPrism's photo PUT
silently drops the Archived field, so we route through
/batch/photos/{archive,restore} the same way the BulkActionBar
already did. Mirror for `u`.
- Preview close restores the timeline focus + scrolls the last-shown
photo into view via `forcedExpand`+`scrollTileIntoView` so it
actually mounts (selection ring would otherwise stay invisible when
the user navigated far in preview).
- `applyFolderScope` only narrows the timeline to root when the active
view is a folder view (no heap / search / non-default section), so
label clicks / heap views / favorites no longer drop subfolder
photos.
Action bar
- Inline `h-9` row at the bottom of the main column (not `fixed`),
matching the Toolbar's visual language. Right sidebar stays full
height — the bar only spans the timeline width.
- Approve action wired for the review pile.
Colors / Tags / Ratings drill-ins
- New shared `PhotoGrid` component owning tile rendering, selection
styling, single-click-selects + dblclick-previews, and `setOrder`
for arrow-key nav.
- Each route's drill-in `<main>` carries `use:gridKeyNav` and a
trailing `<BulkActionBar />` so shift/cmd/ctrl click, arrow keys,
and the keyboard shortcuts work the same as the timeline.
- Tags switches from `goto('/?q=label:…')` to an in-place drill-in
with a back button, mirroring `/colors`'s flow.
- Category cards + drill-in photo cards honour the global
`view.thumbnailSize` (XS–XL) so the timeline's size selector now
reaches into all four grids.
Settings
- General-settings dialog merges Appearance into UI and switches free
text inputs to selects for the PhotoPrism theme / language / start
page / map style (the value-from-server prepends if it's outside
the curated list so we never silently rewrite a custom value). Time
zone uses `<datalist>` with `Intl.supportedValuesOf('timeZone')`.
Sidecar
- Heap convert runs reindex synchronously per source path so the
client's invalidate-and-refetch sees the moved files.
Inbox
- New /inbox route stub for the upcoming import workflow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add a Settings cog to the Folders header that opens a tabbed library
admin dialog (Library / Index / Import / Logs) wrapping PhotoPrism's
/api/v1 settings, index, import and errors endpoints.
- Add a sticky footer to the left sidebar with the signed-in user's
display name plus quick-toggle theme, general-settings cog (separate
dialog for app prefs), and sign-out. Pull these out of the top
Toolbar trailing slot.
- Align depth-0 folder rows with the rest of the sidebar entries (drop
the leading chevron column when no children) and bring heap rows in
line with folder rows so the kebab is part of the row's hover
background instead of a detached chip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Switch the preview overlay from a still <img> to a <video> tag when the
focused photo's Type is "video". Uses PhotoPrism's /api/v1/videos/:hash
endpoint with the existing previewToken, falls back to a still thumb as
the poster, and autoplays muted so the controls reveal without
clobbering whatever else is on the page.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the legacy mule-image backend with PhotoPrism plus a thin
SvelteKit client and a Node sidecar for endpoints PhotoPrism doesn't
expose (file rename), and add a two-phase migrator (metadata via PUT,
heaps → albums) for the existing Postgres library.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>