schema.ts is gitignored and generated by `generate:types`, which only
`dev` and `build` ran as a prestep — not `check`. The deploy runs
`npm run check` before `npm run build`, so on a clean checkout (where
schema.ts is absent) check fails with "Cannot find module './schema'"
before build ever regenerates it. Give check (and check:watch) the same
generate:types prestep so it stands on its own.
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>
Pools now store a sort setting (sort_key + sort_order). "manual" keeps the
user-arranged order in file_pool.position and allows drag-to-reorder; any
other key (content_datetime, created, original_name) sorts the pool's files
automatically server-side, in which case reordering is rejected. Manual
order is always ascending by position — direction does not apply.
Backend: add the columns (sort_key defaults to 'manual'), generalise the
pool-files keyset cursor to page by the active sort, persist the setting on
create/update, and guard reorder against non-manual pools.
Frontend: a sort dropdown (+ direction toggle) on the pool page that PATCHes
the pool and reloads; drag-to-reorder is disabled unless the pool is manual.
Closes the "automatic sorting in pools" requirement (REQUIREMENTS.md §4.6.5).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Prepare the 3.0.0 release:
- Bump the version in openapi.yaml and frontend/package.json to 3.0.0.
- Document the existing GET /health endpoint in openapi.yaml (served at the
root, outside /api/v1) and refine the auth note.
- Add docs/REQUIREMENTS.md (product requirements, in English) and
docs/ARCHITECTURE.md (system overview); remove the old Russian
docs/Описание.md.
- Rewrite GO_PROJECT_STRUCTURE.md and FRONTEND_STRUCTURE.md to match the
current code (dedup CLI, imagehash, real components/stores, Tailwind v4).
- Fix stale counts and references in CLAUDE.md and link the new docs from
README.md.
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.
Every action button (Not a dup, Delete, Merge) called reload(), which wiped
the whole cluster list and refetched from offset 0 — re-running the costly
server-side clustering and jumping the scroll to the top on each tap. Give
each cluster a stable local key and edit it in place: drop the resolved file
from its cluster, removing the cluster when fewer than two files remain. The
server view is live, so subsequent pages reconcile. A separate monotonic page
cursor keeps "Load more" from repeating clusters after local removals.
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 pool view's selection bar could only remove files from the current
pool. Add an "Add to pool" action beside it that opens the existing file
picker with the selected files (in selection order), so a multi-select can
be copied into another pool in one step. On success the picker closes and
the selection clears.
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>
Shift range-select normalized the range with Math.min/Math.max and
always iterated ascending, so the selection's insertion order (which the
Set preserves and which carries through to e.g. pool add order) ignored
the gesture direction. Iterate anchor → target instead via a shared
selectRange helper, so selecting first→last and last→first yield
correspondingly ordered selections.
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>
The manifest's largest, maskable icon (ms-icon-310x310) was transparent,
so installing the PWA produced a transparent app icon. Generate opaque
192/512 "any" + maskable icons from favicon-bg.png (solid #524B6B
background, maskable variants padded into the inner 80% safe zone) and
point the manifest at them instead of the transparent ms-icon entry.
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 grid windowed to ~4 viewports, dropping off-screen rows as it grew.
That broke large multi-selects: range/drag selection could not span past
trimmed cards, which silently vanished mid-scroll. Accumulate all loaded
rows for the visit instead; the grid is still cleared on sort/filter
change and on leaving the page (reset effect + section cache). Removes
the now-dead trim/anchor-refill fallbacks in loadMore/loadPrev.
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>
A native <input type="color"> always holds a value, so the form always
sent the input's default colour and a tag could never be colourless. Add
a "Color" checkbox gating the swatch: off by default on the new-tag form
(so tags are colourless unless opted in) and initialised from the tag on
the edit form, which can now clear a colour. Sends color: null when off.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fire POST /pools/{id}/views fire-and-forget after the pool loads, the
same way the file viewer logs file views.
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>
Closing the viewer (or returning via a deep-link ?anchor=) already
scrolled the grid back to the file you were on, but the keyboard
roving-focus stayed unset, so the next arrow press jumped to the top.
Both return paths now place the focus on that file and show the ring, so
arrow navigation resumes exactly where you left off.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Surfaces the previously UI-less POST /files/import: an admin-only Settings
card with an optional subfolder field, an Import button, and a result
summary (imported / skipped / per-file errors). Notes that imported files
are drained from the folder and that mtime is kept as the date when EXIF
is absent. Also documents the endpoint's drain + mtime behaviour in the
OpenAPI spec.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Arrowing up/down moved the focus ring but the view didn't follow: the
card was scrolled with scrollIntoView({block:'nearest'}), which aligns to
the scroller's edges and is unaware of the fixed bottom navbar overlaying
the scroll area — so the newly focused row slid under the navbar. Replace
it with a manual scroll that keeps the focused card inside the scroller
with a top margin and a bottom margin sized for the navbar.
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>
Extends the global `/` shortcut to focus the always-on search input on
Tags/Categories/Pools, matching what the help overlay advertises. Files
keeps its own `/` handler since it has no persistent input and instead
opens the filter bar.
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>
The grid grew without bound as you scrolled (and the section cache then
snapshotted the whole thing). It now keeps at most ~4 viewports of rows:
once it grows past the cap on one end, loadMore/loadPrev trim the
off-screen rows on the other end. The trimmed boundary cursor is dropped
and the opposite has-more flag is raised, so scrolling back refills that
side from an anchored window (?anchor=<file>), reusing the existing
prepend scroll-compensation. This bounds both live memory and the cached
snapshot regardless of how deep you scroll.
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>
The bottom-nav links pointed at the bare section roots, so leaving a list
and tapping its tab again dropped the active filter/sort in the query
string. The root layout (which never unmounts) now records the last list
root URL — including its query — per section on navigation, and the nav
links target that remembered URL. Only the list root is recorded, so
detail pages, the trash sub-view, and the shallow-routed file viewer
don't hijack the tab.
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>
Changing the filter (or sort/order) reset the grid to empty but never fetched
page 1 — the reset effect only loaded for the deep-link anchor case and
otherwise relied on InfiniteScroll, which doesn't re-trigger without a remount
or scroll. So filtering blanked the list until a hard refresh. Load page 1 from
the reset effect itself for the non-anchor case (guarded by `loading`, so it
doesn't double-fetch with InfiniteScroll's mount load).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>