Commit Graph

344 Commits

Author SHA1 Message Date
c783f129cc preview: serve fit_1280 + tighten timeline grid padding
InlinePreview was requesting fit_1920 for both the still image and
the video poster — roughly 3× the pixel count of what the pane
actually needs. Drop to fit_1280: still sharp inside the inline
pane (which the user resizes around 300–500px tall in practice)
while cutting payload by ~⅔.

Timeline: pull the grid wrapper padding in from `p-6 pb-24` to
`pr-2 pl-2 pb-2 overflow-x-hidden` now that the SplitGrid preview
pane sits above the grid — the old generous padding existed to
breathe under a full-screen modal that no longer renders inline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 23:25:10 +02:00
24c449f475 feat(preview): fanned deck of cards on multi-select
When `selection.ids.size >= 2`, the inline preview swaps the single-
photo view for a SelectionDeck: each selected thumbnail renders as
an absolutely-positioned card with a translate + rotate computed
from its index in the deck, so the spread reads as a fan. CSS
transition-transform handles the reflow as the deck grows or
shrinks; `in:fly` lands new cards from above, `out:scale` shrinks
removals into the stack. Hash resolution walks the existing
TanStack caches (per-photo + photos-infinite envelope) so the deck
is side-effect-free — no fetches just to render thumbs.

Drop the now-redundant Maximize hover affordance on PhotoTile and
the `onOpenPreview` plumbing through PhotoGrid / +page.svelte:
single-click already places a tile into the inline preview pane,
so the dedicated "open preview" button no longer has a job.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:50:40 +02:00
e36f1939c6 feat(preview): inline split-pane preview + sidebar metadata pass
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>
2026-05-18 22:46:06 +02:00
Claudio
2a75896274 sidecar: declarative USER_BASEPATHS reconciler
PhotoPrism's OSS edition has no way to map OIDC claims to BasePath, so
every freshly-registered OIDC user lands with BasePath="" and either
sees the whole library (admin) or nothing (guest) — never their own
subfolder.

Introduces a sidecar-driven reconciler with a single env knob the
admin sets in docker-compose / .env.photoprism:

  USER_BASEPATHS="test:test, alice:family/alice, bob:bob"

(`user:originals-relative-path` pairs, comma-separated.) On boot and
every 60s thereafter the sidecar:
  - mkdir -p's the target subdirectory under ORIGINALS_ROOT so
    PhotoPrism's path: ACL filter has somewhere real to point;
  - UPDATEs photoprism.auth_users.base_path for the matching row
    where it differs (idempotent, missing users skipped — they
    materialise on first OIDC login and the next pass catches them).

The reconciler uses a separate gorm connection scoped to the
`photoprism` schema with PhotoPrism's own DB user, since the existing
`sidecar` user only has grants on `mule_sidecar.*`. Connection stays
dormant when PP_DB_PASSWORD is empty — the feature is opt-in via env.

Compose changes: thread PP_DB_* + USER_BASEPATHS through to the
sidecar service. New users.go file isolates the reconciler logic;
main.go calls startUserBasepathReconciler() during boot.
2026-05-18 20:25:19 +00:00
Claudio
85847848c4 Sidebar: scope count badges to current user's library
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.
2026-05-18 20:11:52 +00:00
Claudio
b0c8c06b2b Sidebar: suppress global count badges for non-admin users
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.
2026-05-18 20:02:20 +00:00
Claudio
986dab7334 Clear TanStack Query cache on session change
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.
2026-05-18 20:01:17 +00:00
829d7bed83 feat(sidebar,tags): per-tab counts and aggregated Tags badge
- /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>
2026-05-18 21:59:47 +02:00
d70244f17e feat(web): fold duplicates+inbox into /review; sidebar UX cleanup
- /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>
2026-05-18 21:48:38 +02:00
a38c3c6e9b feat(duplicates): show count badges on Stacks/Cross-folder tabs
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>
2026-05-18 21:14:56 +02:00
Claudio
8ac406ac1f Review view: reuse timeline action surface, simpler card UX
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.
2026-05-18 18:50:23 +00:00
70de4b65ec feat(duplicates): sidebar count + auto-run cross-folder scan
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>
2026-05-18 20:48:20 +02:00
79a9ef49d4 feat(web): scope library views to per-user BasePath
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>
2026-05-18 18:18:39 +02:00
bd39d310ab feat(sidebar): path next to date + ISO date-only inputs
Moved the filepath display from the readonly facts row to immediately
above the Date Taken editor, and switched both the single-photo and
bulk Date Taken inputs from datetime-local to date (YYYY-MM-DD). The
date-only compare in commitTakenAt avoids clobbering the stored
time-of-day when the user blurs the field without editing it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 18:18:39 +02:00
Claudio
f9f276a986 Review view: tabs per cause (mirrors /tags pill row)
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.
2026-05-18 15:54:25 +00:00
Claudio
ca9f6e6bd2 Review view: cause-grouped UI with actions + suggestions
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.
2026-05-18 09:03:30 +00:00
9d955d6b94 feat(timeline): muted hover-to-play preview on video tiles
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>
2026-05-18 08:20:16 +02:00
af96922e89 feat(preview): inline video player via vidstack + hover-warm prefetch
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.
2026-05-18 08:13:59 +02:00
ccbc1050de fix(web): drop indexer debug log, harden settings dialog, escape search-placeholder quotes
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>
2026-05-18 08:13:42 +02:00
Claudio
cfd85a1fe8 fix(sidebar): root badge shows the library total, not zero
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.
2026-05-18 00:06:01 +02:00
aa63d4c11d feat(sidebar): persist metadata section collapse across photo switches
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>
2026-05-18 00:03:38 +02:00
79ec511482 fix(timeline): clear selection after keyboard archive/delete/approve
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>
2026-05-18 00:03:38 +02:00
Claudio
505fef5dfc fix(folders): root shows whole library + sidecar counts recurse
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).
2026-05-17 23:25:43 +02:00
Claudio
8083328f2d fix(filters): make path:folder query recursive
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.
2026-05-17 23:22:29 +02:00
e669e80a91 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>
2026-05-17 23:13:57 +02:00
Claudio
3d8e050af4 fix(compose): cap indexer concurrency to keep shared hosts alive
Default PHOTOPRISM_INDEX_WORKERS is NumCPU/2 (3 on the M0 6-core test
LXC). Each worker forks TF + ffmpeg + libvips, so a fresh index of
~1.2k photos pushed the LXC's load avg above 50 and starved the
Proxmox host. Pin to PP_WORKERS / PP_INDEX_WORKERS (default 2) so
the indexer is calm by default; bump in .env.photoprism on dedicated
boxes.
2026-05-17 23:13:36 +02:00
Claudio
cce1d876c3 fix(compose): pass OIDC env vars under the names PhotoPrism actually reads
The compose file was using PHOTOPRISM_OIDC_ISSUER_URL / _CLIENT_ID /
_CLIENT_SECRET / _PROVIDER_NAME / _REDIRECT_URI, but PhotoPrism's CLI
flags are --oidc-uri / --oidc-client / --oidc-secret / --oidc-provider —
so the env vars it parses are PHOTOPRISM_OIDC_URI / _CLIENT / _SECRET /
_PROVIDER. With the old names PhotoPrism silently ignored them, OIDC
stayed dormant, and `photoprism show config` reported blank oidc-uri /
oidc-client even though everything else looked configured.

Confirmed on the M0 LXC: renaming the env vars makes the Authentik
"Sign in" button appear on /library/login, /api/v1/oidc/login emits a
proper 302 to the IdP authorize endpoint, and the callback creates the
OIDC user + session in the DB.

The user-facing `.env.photoprism` keys are unchanged (OIDC_PROVIDER_NAME,
OIDC_ISSUER_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET); the compose file
just maps them to the correct PHOTOPRISM_* targets. OIDC_REDIRECT_URI
is removed because PhotoPrism derives the redirect from PHOTOPRISM_SITE_URL.
2026-05-17 22:56:07 +02:00
7e10f0b462 fix(web): align lazy-PreviewOverlay layout with renamed OIDC bootstrap
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>
2026-05-17 22:50:27 +02:00
6b8c7abc20 perf(web): batch folder counts, bounded scroll scan, adaptive thumbs, lazy preview
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>
2026-05-17 22:48:59 +02:00
Claudio
9a3ad3e579 fix(web): adopt OIDC session from PhotoPrism's localStorage (not cookies)
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.
2026-05-17 22:48:13 +02:00
Claudio
4abe6d758c feat(web): OIDC login button + cookie-based session bootstrap
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.
2026-05-17 22:06:33 +02:00
cb5bc120dc fix(web): restore button cursor: pointer affordance under Tailwind v4
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>
2026-05-17 22:00:34 +02:00
a72619e3d1 feat(timeline): focus follows archive, snaps to first on view load
- 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>
2026-05-17 21:56:54 +02:00
84e433ff63 fix(timeline): scroll-driven visibility scan, factor PhotoTile + SkeletonGrid
- 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>
2026-05-17 21:33:03 +02:00
d35de8a2a9 feat(web): /tags tabs + keyword surfacing + dup tab restyle
- /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>
2026-05-17 21:19:44 +02:00
d5e4f23c0f feat(web): sidebar root folder + folder counts, drill-in colors/tags/ratings, click semantics rework
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>
2026-05-17 21:01:41 +02:00
5153aeebec fix(sidecar): podman keep-id mapping so mutations land as the host user
Without keep-id the container's UID:GID maps into the rootless podman
subuid range (524288+), so the sidecar couldn't create
`/photoprism/originals/.duplicates/` — the archive endpoint failed
with "mkdir: permission denied", and rename / folder ops would have
hit the same wall.

The PhotoPrism container already has this override for the same
reason; mirror it for the sidecar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:23:37 +02:00
4362e475a7 build(sidecar): containerize + delete the Node prototype
- New sidecar/Dockerfile: multi-stage golang:1.25-alpine → distroless/
  static, ~12 MB final image, static CGO-free binary.
- Wire pp-sidecar into docker-compose.photoprism.yml so the whole
  stack (mariadb + photoprism + sidecar) starts with one
  `podman-compose up`. Container reaches mariadb + photoprism on the
  internal network; the host gets 127.0.0.1:8000 for Vite's proxy.
- New SIDECAR_LISTEN_ADDR env var (default 127.0.0.1 for the host-mode
  dev loop) so the container can bind 0.0.0.0:8000 and let the port
  mapping reach it. Without this the loopback bind was invisible to
  the host.
- Delete sidecar/legacy/server.mjs — the Node prototype's archival
  window is over; git history is its home now.
- Update sidecar/README with compose-first bringup; keep the host
  `go build` flow as the fast-iteration loop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:23:36 +02:00
032dce6c85 feat(sidecar): port Node prototype to Go + Gin + GORM + MariaDB
Replace the Node prototype (server.mjs) with the stack the merge plan
calls for: Go 1.25, Gin for routing, GORM + MariaDB for persistence.
Same wire contract on /api/sidecar/* so the SvelteKit client doesn't
change.

- Marks move from a JSON file on disk to mule_sidecar.marks (auto-
  migrated by GORM on first boot). The Node prototype's marks.json
  was dev-only; not migrated.
- Folder/rename/heap-convert/duplicates handlers reproduce the
  prototype's behaviour, including the path-traversal defence
  (resolveUnderRoot + EvalSymlinks), the size-bucket prefilter for
  the duplicate hasher, and the background reindex fire-and-forget
  pattern.
- Auth model unchanged: requireSession middleware proxies the
  caller's X-Auth-Token to PhotoPrism's /api/v1/photos?count=1
  before any destructive op.
- Expose pp-mariadb on 127.0.0.1:3306 in docker-compose so the
  host Go process can reach mule_sidecar.* without joining the
  container network.
- Archive the Node prototype under sidecar/legacy/server.mjs for
  one cycle as reference.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:15:47 +02:00
0766b47bb2 feat(sidebar): library + general settings dialogs, sidebar footer, alignment polish
- 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>
2026-05-17 16:51:56 +02:00
17df1ecd09 feat(preview): play videos inline in the lightbox overlay
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>
2026-05-17 16:51:46 +02:00
8c2526d982 feat: PhotoPrism M0 bring-up — compose stack, web client, sidecar, migrate
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>
2026-05-17 16:06:58 +02:00
claudio
423a73a8a6 fix(SettingsDialog): drop dead imports after AI tab removal
tsc --noEmit caught seven TS6133 "declared but never used" + one
TS2614 "no exported member 'features'" left over from a27267f. Strip
Brain/RotateCcw icons, the unused Switch + Loader2 imports, the
adminApi + featuresApi + features module references, and the
SETTINGS_FEATURE_FLAGS_KEY constant. No runtime change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:28:14 +02:00
claudio
a27267f7ad refactor: drop AI/vision pipeline + plain Postgres + full-refresh script
Removes the OpenCLIP-on-ONNX classifier and everything that fed or
consumed it:
  - backend: app/services/vision/, app/tasks/vision.py,
    app/services/feature_flags.py, app/routers/features.py — all
    deleted; admin AI/feature-flag endpoints and the worker-vision
    bootstrap call gone. Photo.needs_review and its index dropped.
  - frontend: AI Settings tab, useFeaturesQuery hook, FeatureFlag
    types, "Needs Review" sidebar entry + filter, needs_review filter
    URL param all gone.
  - infra: worker-vision compose service + models_data volume deleted;
    worker-light command no longer runs bootstrap_models; the db
    image switches from pgvector/pgvector:pg16 to postgres:16; backend
    Dockerfile drops the dedicated torch RUN layer; requirements.txt
    drops torch/torchvision/open-clip-torch/onnxruntime.

Alembic 0019_drop_ai_remnants:
  - drops photos.needs_review + ix_photos_needs_review
  - DROP EXTENSION IF EXISTS vector (must run before the image swap;
    the new postgres:16 doesn't ship pgvector)

New scripts/full_refresh.py: one-shot DB ↔ filesystem reconciliation.
Runs cleanup_data_integrity, scans every active SourceRoot inline
(no celery dependency so the worker can be stopped), hard-prunes
photo + folder rows for files that are gone, removes orphan
/data/thumbs/{user}/{photo}/ directories. New helper
prune_orphan_thumbnails in cleanup.py.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:20:38 +02:00
claudio
6915c30911 build(backend): cache torch wheel + pip downloads across rebuilds
Splits torch+torchvision into its own RUN layer so edits to
requirements.txt don't invalidate the ~200MB CPU-only torch download.
Adds buildkit cache mounts on both pip-install layers so even when a
layer is invalidated (or buildkit evicts it) the wheel is reused from
the on-disk pip cache instead of refetching from download.pytorch.org.

Triggered by two consecutive deploy failures where pytorch.org timed
out mid-download (2026-05-13 ~21:41 and possibly ~22:47).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:08:10 +02:00
claudio
4e1af0f356 refactor: simplify after self-review
- drop the redundant isLoadingMore boolean from the background-loading
  store; derive from inFlight > 0 in the selector
- pull _active_source_root_paths out of get_library_stats and reuse it
  from get_duplicate_groups (same is_active + admin-scope check, now
  in one place)
- drop p.rstrip('/') in the duplicates folder-scope filter (SourceRoot
  paths are never written with a trailing slash)
- match Timeline's initial-load affordance to the new bottom indicator
  (Loader2 spinner + ellipsis instead of plain "Loading photos...")
- trim narrative comments that explained what the surrounding code does

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 20:50:17 +02:00
claudio
5e2823ae94 feat(library): tighten duplicates scope, drop in-app upload, polish loaders
- duplicates: restrict the /library/duplicates/groups query to photos whose
  folder path actually lives under an active SourceRoot in the user's
  settings. Nextcloud's "move to trash" flow was leaving .delete/purge-1
  Folder rows wired to the original source_root_id, leaking those entries
  into the Duplicates view as ghost paths that the user never opted into.
- discard: add a spinner to the "Delete N" and "Empty discard pile"
  buttons (and their confirm dialogs) while the destructive mutation is
  in flight, so the user gets immediate feedback for a slow operation.
- timeline: render a bottom-of-grid "Loading more photos…" indicator
  while usePhotosQuery's background cursor loop is still pulling pages.
  Backed by a tiny Zustand store the loop drives via a balanced
  start/stop (counter, not boolean, so rapid filter changes can't flip
  the flag false while a fresh loop is alive).
- remove client-side upload UI + /upload endpoint. Nextcloud is the
  authoritative ingress now; the duplicate path created confusion and
  the backend route is gone too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 20:44:36 +02:00
root
99edd7d395 ui(thumbs): remove the "date may be wrong" badge
The amber AlertTriangle in the bottom-right of every photo with
has_date_warning set was more visual noise than help — the filter pill's
"Date issues" option still surfaces the same photos when the user
actually wants to triage them. Keeps the BR corner uncluttered.

Backend field + filter pill option unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 10:55:32 +02:00
root
ab3c55dd96 feat(topbar): drop search box to reclaim filter-bar space
The search box on the right edge of the filter bar wasn't pulling its
weight — kills it entirely along with the supporting plumbing:

- FilterBar: remove input + Search icon import + local/debounced state
- filterStore: drop `q`, `setQ`, plus all references in INITIAL_FILTERS,
  filtersToParams, hasActiveFilters, snapshotFilters
- usePhotosQuery: stop passing q through filtersToParams
- useFilterUrlSync: drop the `q` URL param read/write
- PhotoThumbnail + PreviewView: remove the search-match banner/chip and
  findSearchMatch helper imports
- Timeline + MemoriesView: stop subscribing to / forwarding the prop
- useKeyboardShortcuts: drop the `/` and Cmd+F focus hotkeys
- KeyboardHints: drop the `/` hint and the now-stale `?` collision note
- delete hooks/useSearchQuery.ts (no callers) and lib/searchMatch.ts

Backend /photos/search endpoint left untouched — no UI reaches it now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 09:53:32 +02:00
root
c30b387dc3 feat(grid): user-configurable thumbnail size for grid views
Adds a "Size" pill in the FilterBar with 5 presets (XS/S/M/L/XL, 96–272px,
default M=160) that drives the cell size in the Timeline, Memories, and
Duplicates grids. Preference persists in localStorage. Preview filmstrip
is intentionally untouched — it's a fixed-track nav rail, not a grid.

Centralised in a new viewSettingsStore so every grid reads from the same
source. Duplicates' virtualizer is poked on size change so row heights
and the keyboard nav's column count stay in sync.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 09:37:01 +02:00