The preview area was one big link, so a click anywhere in it — including
the black letterbox margins — opened the original. Make the link
pointer-events:none and re-enable pointer-events on the image itself, so
"open original" fires only on the image, not the surrounding margins.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Paging previously required hitting a small arrow circle pinned near each
edge. Replace them with full-height left/right tap zones (30%, capped at
220px) so a tap anywhere on that side pages. The arrow stays as a chip
near the edge with a hover-only hint gradient; the zones sit above the
image, and the "Replacing…" overlay is lifted above them so an in-flight
content replace still blocks paging.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Android's adaptive-icon mask clipped the bamboo artwork and showed a
white backing on install. Fix the icon set so it survives masking.
- maskable icons: artwork fitted inside the 0.40W safe-zone circle,
opaque full-bleed background (no transparency, so no white fill)
- any icons: rounded-corner tiles with transparent corners
- drop legacy transparent android-icon-* (RealFaviconGenerator) that
Android picked up as adaptive icons and backed with white
- rename to icon-tile-*/icon-maskable-* so the manifest src changes
and installed PWAs re-fetch the launcher icon
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
The integration suite creates a per-run database and rewrites the admin
DSN to point at it via replaceDSNDatabase. The URL-style branch was a
stub that returned the DSN unchanged, so with a URL DSN every test ran
against the shared `postgres` database instead of its own — no isolation.
The first test to create a given user/object won; the rest collided on
unique constraints, surfacing as cascades of 500s (and ImportFromFolder
seeing accumulated rows).
CI passes TANABATA_TEST_ADMIN_DSN in URL form, so this only broke in the
deploy pipeline, not local key=value runs. Parse the URL and swap its
path to the per-run database.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Gate the deploy on tests. After pulling master and before building the
image, run the full Go suite (incl. integration tests, against an ephemeral
Postgres) and the frontend type-check + build. Everything runs inside
throwaway toolchain containers, so the host still needs only docker and a
red build never reaches production. Update DEPLOY.md to reflect the gate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a short Versioning note: info.version tracks the 3.0.0 product release
while the /api/v1 path is the API compatibility version, and the two are
independent. Pre-empts the "why v1 under 3.0.0?" confusion.
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>
Add the GNU AGPL-3.0 license text, point the openapi.yaml license metadata
at AGPL-3.0-or-later (was the "Proprietary" placeholder), and note the
license in the README.
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>
minimalJPEG() returned a hand-written byte literal that passed MIME
sniffing but failed image.Decode ("missing SOS marker"), so the upload
path left phash NULL and TestDuplicateDetection found no duplicate
clusters. Build the fixture with image/jpeg.Encode instead, so it decodes
through the same path the server uses to hash uploads. Encoding is
deterministic, so two uploads still hash identically.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Run the HTTP server in a goroutine and, on SIGINT/SIGTERM, call
srv.Shutdown so it stops accepting connections and lets in-flight
requests finish before exiting (ErrServerClosed is a clean exit). This
stops uploads/streams being cut when the container is stopped or
recreated on deploy.
The drain deadline is configurable via SHUTDOWN_TIMEOUT (default 15s).
docker-compose.yml feeds the same variable into the app's
stop_grace_period, so Docker won't SIGKILL mid-drain and the two values
can't drift.
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>
FileRepo.Update rewrites every editable column, so a PATCH that omitted a
field cleared it. Seed original_name and metadata — which have no always-
present input in the file editor — from the current file so a partial
update leaves them untouched instead of nulling them. The merge path builds
its own complete patch and calls the repo directly, so it is unaffected.
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 Δ—.
ListVisible already loads each pair's Hamming distance, but clusterPairs threw
it away. Thread it through: Clusters now returns a Cluster carrying the stored
pairwise distances (indexed once per page), and the list endpoint emits them as
{a, b, distance}. Pairs linked only transitively have no stored distance and are
omitted. Lets the UI show how close each file is to the kept one without a
client-side hash compare (phash exceeds JS's safe-integer range).
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 distance histogram over a real ~65k-pair table is flat-to-rising from
distance 1 and nearly doubles at 10 — the 8-10 band is dominated by
coincidental matches, not duplicates. Default the example to 4 (the valley
minimum) to keep the genuine-duplicate signal and cut the pairs to cluster.
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 previous Python/Flask app under docs/reference/ was kept as a visual
design reference while bootstrapping the new frontend. That's well past
done, and 85 files of dead code just add noise to search and exploration.
Remove it (recoverable from history if needed) and update CLAUDE.md: keep
the design tokens, drop the now-dead pointer at the folder.
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>
Video thumbnails and previews were extracted ~1s in, which lands on
shared intros, title cards or black lead-in frames. Take the frame from
the middle (duration/2) instead — the same frame used for the perceptual
hash — so the thumbnail/preview reflects what dedup compared. Fold the
midpoint logic into a shared extractVideoFrameMiddle helper reused by
both the thumbnail/preview path and VideoFrameMiddle.
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>
Reorder was the only pool-file operation that didn't record an audit
entry, unlike AddFiles (file_pool_add) and RemoveFiles
(file_pool_remove). Log file_pool_reorder on success and seed the new
action type.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both phases now share a small, dependency-free progress indicator: an in-place
bar on a TTY (e.g. `docker compose run`), and a line every 10% when stdout is
piped (cron/CI) so logs don't fill with carriage returns. Also fixes the pairs
phase, which mislabelled its progress as "hashed" — it now reads "matching".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The dedup pairs rebuild reads this tunable (default 10/64); it was added to the
backend config but never documented for operators. No other new env vars were
introduced by duplicate detection — the dedup compose service reuses the
existing PUID/PGID/FILES_DIR/THUMBS_DIR.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a `dedup` task service under the "tools" profile so it's kept out of
`docker compose up` and run on demand:
docker compose run --rm dedup # hashes, then rebuild pairs
docker compose run --rm dedup -pairs # only rebuild pairs
docker compose run --rm dedup -hashes # only backfill hashes
It reuses the app image, .env, volumes and networks, overriding only the
entrypoint to /app/dedup. Unlike `docker exec` on the live server, this runs in
its own container and is self-documented for cron/CI use.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The backend build stage compiled only ./cmd/server, so the dedup maintenance
tool was never available on deploy. Build it alongside the server and copy
/out/dedup to /app/dedup in the runtime image (which already has ffmpeg/ffprobe
for video frames and the /data volume). Run it with `docker exec`.
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>
Adds GET /files/duplicates, POST /files/duplicates/dismiss and POST
/files/duplicates/resolve to the OpenAPI spec, plus the DuplicateCluster,
DuplicateClusterPage and DuplicateResolve (with MergeScalarChoice /
MergeRelationChoice) schemas describing the field-by-field merge contract.
Also fills a pre-existing gap in the File schema: it now documents the `tags`
array (always returned by the API) and marks the always-present fields required,
so generated clients type these as non-optional.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
cmd/dedup is the offline maintenance tool for duplicate detection. It reuses the
server's config and runs two phases (both by default; -hashes / -pairs to pick):
- hashes: compute the perceptual hash of every live image/video missing one —
images from their bytes, videos from a middle frame via DiskStorage.
VideoFrameMiddle. Per-file failures are reported and counted, not fatal.
- pairs: rebuild data.duplicate_pairs from all current hashes (DuplicateService.
Rescan).
Idempotent and safe to re-run: hashing only touches NULL phashes, the pairs
rebuild is a full replace. This is how video phashes and any backlog get
computed, and how newly uploaded duplicates become visible.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the duplicate-detection backend on top of perceptual hashing:
- Two tables (edited into the original migrations): data.duplicate_pairs holds
precomputed near-duplicate candidates (rebuilt wholesale by the rescan), and
data.duplicate_dismissals is a global "not a duplicate" overlay that survives
rescans. New audit actions file_merge / duplicate_dismiss.
- DuplicateService:
- Rescan builds every pair within DUPLICATE_HASH_THRESHOLD via a BK-tree over
the perceptual hashes and replaces the pairs table. This is the only thing
that populates pairs, so GET never compares all-vs-all (scales to 110k+).
- Clusters reads the precomputed pairs (ACL-filtered, non-trashed, non-
dismissed), groups them into connected components via union-find, and
paginates whole clusters.
- Resolve merges a pair field-by-field: each scalar from keep or discard,
metadata keep/discard/shallow-merge, tags/pools keep or union; then trashes
the discarded file. Enforces edit ACL on both.
- Dismiss records a canonical pair (view ACL on both).
- Endpoints under /files: GET /files/duplicates, POST /files/duplicates/dismiss,
POST /files/duplicates/resolve (registered before /:id to avoid collision).
Plain delete reuses /files/bulk/delete.
- Repo support: ListMissingPHash, ListAllPHashes, CopyPoolMemberships, plus the
DuplicatePairRepo (ReplaceAll via COPY, ListVisible) and DismissalRepo.
Unit tests cover the BK-tree pairing, union-find clustering, metadata merge and
field validation; an integration test covers rescan -> list -> merge -> dismiss
(including that a dismissal survives a re-rescan).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a 64-bit dHash perceptual hash (internal/imagehash, built on the existing
disintegration/imaging — no new dependency) and starts populating the long-unused
data.files.phash column:
- Upload sets phash inline for images (cheap, from the in-memory bytes).
- Replace recomputes it from new content for images and clears it for anything
else, so a stale hash never survives a content swap.
- FileRepo.SetPHash sets/clears the hash (used by Replace and, later, the dedup
backfill).
- DiskStorage.VideoFrameMiddle extracts a frame from the middle of a clip
(ffprobe duration -> ffmpeg -ss duration/2), avoiding the shared-intro collision
a fixed early offset causes. It is a concrete method, not part of the storage
port: only the dedup CLI needs it, keeping ffmpeg off the upload path. Video
phashes are therefore computed by that CLI, not at upload time.
- DUPLICATE_HASH_THRESHOLD config (default 10/64) for the later pair rescan.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The `# syntax=docker/dockerfile:1` line made BuildKit fetch its frontend
image from Docker Hub on every build, even when all base images and layers
were already cached. On a host that briefly can't resolve registry-1.docker.io
this is the first and only mandatory network round-trip, so the build fails at
"resolve image config for docker-image://docker.io/docker/dockerfile:1" before
any stage runs.
This Dockerfile uses no frontend-specific syntax (no heredocs, no RUN --mount,
no COPY --link) — only multi-stage, COPY --from/--chown, RUN, ENV, etc., all
handled by the engine's built-in frontend. Dropping the directive removes the
Docker Hub dependency and lets a fully cached build complete offline.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tag pooled connections with application_name="tanabata-backend" via the
parsed pgxpool config, so the backend's sessions are identifiable in
pg_stat_activity and server logs. An application_name supplied in the
DSN (or PGAPPNAME) still takes precedence.
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>