The datetime-local input was populated by slicing the raw UTC ISO
string, so a saved time appeared shifted by the local offset (e.g.
12:00 Moscow rendered as 09:00). Convert the UTC instant to local
wall-clock on load via isoToLocalInput; the save path already
round-trips local -> UTC.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Formatting only: run prettier across the frontend and gofmt over the
backend. Fixes struct-field alignment in domain/pool.go and pre-existing
prettier drift in a few frontend files.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- TagBadge: derive color/style so they track a changing tag prop.
- FilterBar: seed tokens from the value prop via untrack (the effect keeps
it in sync), silencing state_referenced_locally.
- FileCard: give the interactive card role="button".
- admin user toggles: add aria-labels to the switch buttons.
svelte-check is now clean (0 errors, 0 warnings).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Give the in-pool file selection the same action set as the files grid.
SelectionBar is now driven by props (count + callbacks) instead of reading
the selection store directly, with an optional "Remove from pool" action.
The pool page uses it to offer Edit tags, Add to pool, Mark reviewed,
Remove from pool and Delete (to trash), replacing the previous
add/remove-only bar.
Also rename the content_datetime label from the photo-centric "Date taken"
to "Content date" across the files sort, the file viewer field and the pool
sort options (and align the pool's "created" option with the files grid).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the flat key/value metadata editor with a recursive tree editor.
Each entry is a leaf (scalar edited as text) or a nested object with its own
children; a per-row toggle switches between the two, expanding pasted JSON
into rows and collapsing groups back to JSON text without losing data. Leaf
values still round-trip as JSON where possible, so numbers and booleans keep
their type.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a key/value metadata editor to the file viewer (display, add, edit and
remove fields; values round-trip as JSON where possible, otherwise as plain
strings) and a compact side-by-side metadata preview to the duplicate merge
dialog so each side's keys and values are visible while choosing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each action row now shows the perceptual distance (Δn) between the kept file
and that file, read from the new per-cluster distances; recomputed live when the
survivor pick changes. A transitively-linked pair with no stored distance shows
a muted Δ—.
The lightbox only showed the preview image, but dedup decisions need the
metadata — date, tags, EXIF — to choose a survivor. Replace it with the same
FileViewer the files page uses, mounted as a full-screen overlay with prev/
next wired across the cluster's files. A review toggle made in the viewer is
mirrored back into the cluster list. Drops the now-superseded PreviewLightbox.
The duplicate cluster view only showed 96px thumbnails, too small to tell
near-duplicates apart. Add a zoom button per tile that opens a lightbox
showing the full preview — the same image the single-file viewer uses — and
pages across the cluster with arrows/Esc. The zoom button stops propagation
so it doesn't also reassign the cluster's "keep" selection.
Thumb and FileCard fetched their auth-gated thumbnail on mount, so a view
that rendered many tiles at once — notably a duplicate cluster, which can
hold hundreds of files — fired thousands of requests immediately and hung
the tab. Gate each fetch behind an IntersectionObserver that loads only when
the tile nears the viewport (200px rootMargin), then stops observing.
The backend has had PUT /files/:id/content for a while, but nothing in the
UI exposed it. Add a "Replace file content" action to the file viewer's top
bar: it opens a file picker, confirms before overwriting (the original bytes
are replaced irreversibly under the same id; tags/pools/metadata are kept),
then uploads via a new api.uploadPut helper.
The file id is unchanged, so the preview URL stays the same while the bytes
behind it don't — refetch the preview past the browser cache (the server
cache is already invalidated by the replace) and re-mint the content token so
"open original" serves the new content. A spinner overlay covers the preview
during the upload, and the viewer's own shortcuts yield while the confirm is
open or a replace is in flight.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The file viewer could only open the pool picker via its top-right button —
there was no `p` shortcut there (only the grid had one), so pressing `p`
on the view page did nothing. Add `p` to open the picker from the viewer,
and give the picker itself full keyboard control: `/` focuses the search
box, arrows move a highlight through the pool list, Enter adds to the
highlighted pool, and Escape clears the search first, then closes.
Both the viewer and the grid now yield the keyboard entirely to the open
picker (the picker owns Escape via its own window handler) so the
clear-then-close behaviour isn't pre-empted by the host's own Escape.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the duplicate-detection UI:
- api/duplicates.ts: getDuplicates / dismissDuplicate / resolveDuplicate, plus
the cluster and merge-field types.
- /files/duplicates: an offset-paginated list of clusters. Each cluster shows its
files (auth-loaded thumbnails via a reusable Thumb component); the user clicks a
file to mark it the survivor, then per other file: Merge, Delete, or "Not a dup"
(dismiss). The list reloads after each action so it stays consistent with the
rescan-gated server state.
- DuplicateMergeDialog: a bottom sheet to merge two files field-by-field — each
scalar from the kept or other file, metadata keep/other/merge, tags & pools
keep-or-union, with a swap-survivor toggle and an optional trash-the-other box.
- Entry point: a Duplicates action in the files Header next to Trash.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The API client only skipped JSON parsing for 204, so a success with an
empty body (e.g. 201 from POST /pools/:id/files) hit res.json() on an
empty stream, threw, and surfaced as "Failed to add to pool" even though
the add had committed. Read the body as text and parse only when present.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirror the Files grid's roving keyboard focus on the tag and category
lists (and the tags shown on a category page): arrows move a focus ring,
Enter opens the focused item, "/" jumps to search, Escape drops the ring.
Extracts the model into a reusable createRovingGrid controller; vertical
movement is geometric since the pills wrap at variable widths. The
tag/category edit pages gain Escape-to-leave parity with the file viewer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The DSL already supported m~/m= tokens but the filter UI had no way to
add them. Add Images/Video quick buttons and a free-text MIME input that
append m~<pattern> tokens (LIKE on the type name), plus friendly token
labels in dsl.ts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
FilterBar gains an Any/Needs review/Reviewed segment (r=1/r=0 token);
FileCard shows a "needs review" dot; FileViewer gets a header toggle that
propagates back to the grid; SelectionBar gains a bulk "Mark reviewed"
action. Adds a --color-success theme token.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mint a content token on file load (POST /files/:id/content-token) and put it in
the original-content URL instead of the access token, so opening an original —
especially a long video — in a new tab keeps working past the 15-minute access
token expiry. Falls back to the access token until the content token arrives,
and re-mints when paging to another file.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Refresh tokens rotate on every use and each refresh deletes the old
session server-side, so when one tab refreshed, other open tabs were
left holding a dead access token and a rotated-away refresh token —
their next request 401'd and bounced them to the login screen.
Sync the auth store across tabs via the storage event (propagating
logins, refreshes, and logouts), and make refresh race-resilient: if a
refresh fails but a newer token has meanwhile synced in from another
tab, adopt it and retry instead of ending a still-valid session.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extract the bottom-sheet pool picker (load, search, add) into a
reusable PoolPicker component and use it both for the grid's bulk
selection and from a new button in the file viewer's top bar, which adds
the single open file to a chosen pool. While the picker is open the
viewer hands it the keyboard so Escape closes the sheet (even from its
search) instead of the viewer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirror the server's secondary sort in the client-side sortTags (used for
a file's assigned tags) so the assigned and available lists agree.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
In the single-file viewer's tag filter, Escape now clears a non-empty
filter first; on an empty filter it blurs and scrolls the preview back
to the top. In the bulk (multi-file) editor it clears, then releases
focus, so only the next Escape reaches the page handler and closes the
popup.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Consume the import endpoint's NDJSON progress stream via a new postStream
client helper (reuses the bearer token and 401 refresh, but keeps the body
as a stream). The Settings import card now renders a live progress bar
(processed/total) and a scrolling per-file list where each entry shows its
status — imported, skipped or error — with the failure reason inline and the
newest row kept in view. A final summary replaces the old single-shot result.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The previous attempt wrote scrollContainer.scrollTop, but <main> isn't the
scroll element here (the window/document scrolls, as getScroller and the
infinite-scroll listeners assume) — so it was a no-op and the grid stopped
following the focus. Move back to scrollIntoView({block:'nearest'}), which
scrolls whatever element actually scrolls, and give the card
scroll-margin-top/-bottom so it clears the sticky header and the fixed
navbar instead of sliding under them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The viewer's tag picker focuses its search input on `e`, but the input
swallowed every key and the viewer's own handler bails on input targets,
so there was no keyboard way out of the field. Escape now blurs the input
back to the page, restoring arrow/Escape navigation in the viewer (a
second Escape then closes it).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Plain Space/x toggles the focused card and drops a range anchor there;
Shift+Space / Shift+x now selects everything from that anchor to the
focused card, sharing the same anchor (lastSelectedIdx) as Shift+click so
mouse and keyboard range-selection are interchangeable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Command keys were matched by character (e.key), so on a non-Latin layout
(e.g. Russian) the physical g/f/e/p/x/j/k keys emitted Cyrillic letters
and nothing fired. Letter and digit commands now match by physical
position (e.code: KeyG, Digit1, Slash, …) across the global nav, the file
grid, and the viewer, so the same physical keys work on any layout. Named
keys (arrows, Enter, Esc, Delete), the Mod combos, and the filter's
literal operators (& | ! ( )) stay on e.key, where character matching is
correct.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The grid's `e` opens the bulk tag editor, which has its own UI rather than
the shared TagPicker, so it needed the same keyboard handling: from the
search input, ↓/↑ highlight a suggestion and Enter adds it to all
selected files (focus stays for chaining); with the input empty ←/→ walk
the assigned tags and Del removes the focused one. Mirrors the viewer's
tag picker.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Viewer: j/k mirror the arrow keys, and `e` scrolls to the (lazy) Tags
section and drops the cursor into its filter, forcing the load so focus
lands even before the section is reached.
Tag picker & filter bar: from the search input, ↓/↑ highlight a
suggestion and Enter adds it (focus stays for chaining); with the input
empty ←/→ walk the added tags/tokens and Del removes the focused one. The
filter bar also inserts an operator token on & | ! ( ), applies on
Ctrl+Enter, resets on Ctrl+Backspace and closes on Esc.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Arrow keys move a focus ring across the grid (column count derived from
the layout, scrolling the focused card into view and pulling the next
page near the end). Enter opens the focused file; Space/x select; e edits
tags (opening the sheet and focusing its tag filter); p adds to a pool;
Del moves to trash — each falling back to the focused card when nothing
is selected. / opens the filter and focuses its search. The ring only
appears once keyboard navigation starts and is dismissed on pointer use,
so it never distracts mouse users. Escape layering is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a single window-level key dispatcher in the root layout: `g` then
c/t/f/p/s (or the digits 1–5) jump between the five sections, honouring
each section's remembered URL so you land back on the same filter and
scroll. `?` toggles a shortcuts cheat-sheet overlay. The handler stays
out of the way while typing in inputs or when a browser/OS modifier is
held. This is the foundation for the per-context keymaps (grid, viewer,
tag/filter pickers) that follow.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The offset-paginated lists lost their loaded items, search text and
scroll position when you left for another section, since search is local
(not in the URL) and the page unmounts on navigation. Each now snapshots
that state on departure and rehydrates it on return when the
sort/order/search still match, restoring scroll after the list paints.
Because these lists are edited on their own detail/new pages, the API
client drops the matching section's snapshot on any successful mutation
so a stale list never restores. Shared scroll-restore helper and an
OffsetListSnapshot type keep the three pages in lockstep.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Leaving the Files list for another section unmounted the page and lost
the loaded grid, cursors and scroll position; returning refetched page 1
from the top. A new in-memory section cache snapshots that state on
departure (beforeNavigate) and rehydrates it on the next mount when the
sort/filter still match, reapplying the scroll offset after the grid
paints. Combined with the navbar remembering the section URL, tapping
back into Files lands you exactly where you left off. The snapshot is
session-only, validated by resetKey, and skipped for in-page query
changes and the shallow-routed viewer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Escape now peels one layer at a time on the files page: an open tag
editor, pool picker, or delete confirm closes first, and only a second
Escape drops the multi-select. Selection-exit handling moves out of
SelectionBar into a single window handler on the page so precedence is
deterministic rather than dependent on window-listener fire order.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New tag rules should retroactively apply to existing files by default, so flip
the tagRuleApplyToExisting default to true. (Settings already saved in
localStorage keep their stored value.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The file viewer's preview is now a real link (target=_blank) to the original,
instead of fetching it into a blob. A navigation can't send the auth header, so
the access token rides in the query — the auth middleware accepts ?access_token=
as a fallback, but only for GET, so a crafted link can't drive a mutation.
GetContent gains an ?inline=1 toggle (Content-Disposition: inline) so the tab
views the original instead of downloading it; download stays the default.
Documented in openapi.yaml; TestMediaQueryTokenAuth covers GET-with-query-token
(200), missing token (401) and query-token rejected on a non-GET (401).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
BulkTagEditor optimistically marked only the clicked tag as common after a bulk
add, so tags applied by auto-tag rules (resolved server-side) never appeared.
Refetch /files/bulk/common-tags after each change and rebuild the common/partial
sets from the response, so rule-applied tags and partial->common shifts show up.
Backend bulk path was already correct — covered now by TestBulkTagAutoRule.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The activity.file_views table existed but nothing ever wrote to it. Add a
POST /files/{id}/views endpoint: FileRepo.RecordView inserts a history row,
FileService.RecordView enforces view ACL first. The file viewer fires it
(fire-and-forget) when a file is opened, including while paging prev/next.
Documented in openapi.yaml; covered by TestRecordFileView (204 on view,
repeatable, 404 for unknown file).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The file viewer's assigned-tags list showed tags in API order, ignoring the
sort the user picked on the tags page. Add a client-side sortTags helper and
order the assigned list (in TagPicker) by tagSorting, reactively so it re-sorts
the moment the sort changes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The tag pickers, filter bar and batch editor loaded only /tags?limit=200 and
filtered client-side, so with more than 200 tags the rest were invisible and
unsearchable. Same for the category dropdowns on the tag forms.
Add fetchAllTags / fetchAllCategories helpers that page past the server's
per-request cap of 200, and order results by the sort the user chose on the
tags / categories page (tagSorting / categorySorting) instead of a hardcoded
name-asc. Wire them into FilterBar, TagPicker, TagRuleEditor, BulkTagEditor and
the tag new/edit category dropdowns.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Run gofmt -w across the backend, normalising the manually-aligned := blocks
to the gofmt standard. No code behaviour changes.
Add Prettier (+ prettier-plugin-svelte) to the frontend with the SvelteKit
default config (tabs, single quotes) so formatting is reproducible, then run
it over the whole tree. Add format / format:check npm scripts and a
.prettierignore (build output, generated schema.ts, static assets).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The service worker and web manifest were already in place; close the remaining
gaps against the PWA spec:
- pwa.ts: resetPwa() cleared caches and unregistered the service worker but
never reloaded, despite its docstring. Add the hard reload so the page
re-fetches everything from the network after a clear.
- service-worker.ts: pre-cache the SPA entry HTML ('/') alongside build/files
so the shell — and the offline navigation fallback — works from the very
first visit, not just after a navigation has been seen by the runtime cache.
- settings: resetPwa now reloads, so the post-clear success toast (which told
the user to reload manually) is unreachable and misleading. Drop the dead
pwaSuccess state and toast; keep the disabled "Clearing…" button state.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The IntersectionObserver fired only on enter/leave transitions, so a scroll
that ended with the sentinel already in range (scrolling straight to the
bottom) produced no callback and nothing loaded — the user had to scroll up
and back down to force a fresh transition, loading one chunk per cycle.
Replace the observer with a capture-phase window scroll listener (capture is
required since scroll events don't bubble; it catches scrolls from the grid's
nested <main> as well as the document), rAF-throttled, re-checking the
sentinel's viewport position on every scroll. Keep the re-check on load
completion / mount for short pages and already-in-range first renders.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Returning to the grid at a deep position (deep link / hard reload to a
file, then back → /files?anchor=<id>) used to load only a tiny forward
window at the anchor. Now the grid fills the viewport around the anchor
and pages in both directions as the user scrolls.
- loadAroundAnchor fetches a window centred on the anchor and pre-fills a
few pages each way sequentially, then centres on the anchor once. Doing
the initial fill explicitly (rather than via the sentinels) keeps the
pages contiguous and leaves the sentinels out of range, so there's no
mount-time load storm.
- loading starts true when the URL carries an ?anchor, so the child
InfiniteScroll sentinels (whose effects run before this page's reset
effect on mount) can't fire a stray page-1 loadMore that interleaves
with loadAroundAnchor.
- loadPrev pages backward (direction=backward) and prepends, then shifts
the scroller down by the added height via flushSync (no paint between
prepend and correction) so the viewport stays visually fixed.
- InfiniteScroll gains an `edge` prop; a top instance (shown only when
hasPrev) drives upward loading. Both loaders share the `loading` guard.
- Mock: honour direction=backward and emit prev_cursor; the Go backend
already supports backward keyset pagination.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Lazy load fetched the entire list at once: every list's loader had a
"fill the viewport" recursion gated on
scrollContainer.scrollHeight <= clientHeight, but <main> is not the
scroller (the window/body is), so that condition is always true and it
recursed through every page (with a 10-item window, ~all pages fired at
once).
Move the filling logic into InfiniteScroll and base it on the sentinel's
viewport rect instead: load while the sentinel is within 300px of the
viewport bottom, re-checked synchronously after each load. This works
regardless of which element scrolls and loads only enough pages to reach
past the viewport.
Drop the per-page recursion (and now-unused scrollContainer refs / tick
imports) from the files, trash, tags, categories and pools lists.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The viewer was a separate /files/[id] route, so returning tore down and
reloaded the whole grid. Now opening a file uses SvelteKit shallow
routing (pushState + page.state.fileId): the list stays mounted and the
viewer renders as a full-screen overlay on top of it, like Immich. The
URL still becomes /files/<id> and the back button (or Escape/close)
dismisses the overlay via history.back(), revealing the untouched grid —
no reload — then scrolls it to the last-viewed file instantly.
- Extract the viewer UI/logic into a reusable FileViewer component
(file fetch, preview, lazy tags, save, prev/next, keyboard).
- List: neighbours come straight from its own files[]; paging past the
loaded set pulls the next page by cursor (prefetch near the end).
- Paging uses replaceState so one back press returns to the grid.
- /files/[id] remains as a thin standalone fallback for deep links /
hard reloads, resolving neighbours via the anchor API and returning
to the grid with ?anchor=<id>.
- Remove the now-unused filesCache snapshot store (the list is never
unmounted, so there's nothing to snapshot/restore).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Returning from the file viewer left the grid scrolled to the top: the
position lived only in volatile module state and was never carried
anywhere, and the scroll restore ran before SvelteKit's own scroll reset
(on goto) clobbered it back to the top — worsened by the body, not
<main>, being the effective scroller, so scrollTop restoration was inert.
- The viewer's back/Escape now return to /files?anchor=<currentId> with
noScroll, carrying the position in the URL (survives reload, no longer
depends on hidden in-memory state).
- The list restores grid DATA from the snapshot as before, but scrolls in
afterNavigate — which runs AFTER SvelteKit's scroll handling — using
scrollIntoView so it works whether <main> or the window scrolls. The
?anchor is consumed (stripped via shallow replaceState) once applied.
- Deep link / hard reload with an anchor but no cached grid falls back to
loading a page anchored at that file, then scrolling to it.
- Snapshot is mirrored to sessionStorage so a refresh still restores.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On a failed token refresh the client cleared the auth store and threw, but
nothing navigated away, so an expired session left the user on a page that
only showed errors. Redirect to /login when the refresh token is missing or
rejected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Opening a file now snapshots the grid (loaded pages, cursor, scroll offset,
opened id) into a shared store, and the viewer derives prev/next from that
list instead of a separate anchored request. Returning to the grid restores
the cached list and scroll-centres the last-viewed file rather than
reloading page 1 from the top.
This also fixes two issues:
- The viewer's "previous" arrow never appeared: the backend anchor window
is forward-inclusive, so the anchor was always item 0 and prev was null.
Neighbors now come from the cached list, so paging is symmetric.
- Paging forward in the viewer prefetches further pages into the snapshot,
so navigation continues past the initially loaded set and the grid still
restores correctly.
A deep link straight to a file (empty cache) falls back to the anchored
API window as before.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>