23 Commits

Author SHA1 Message Date
H1K0 528b7004e3 fix(frontend): open original only when tapping the preview image
deploy / deploy (push) Successful in 1m24s
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>
2026-07-06 22:36:16 +03:00
H1K0 41c4667eb4 feat(frontend): enlarge viewer prev/next into full-height tap zones
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>
2026-07-06 22:35:47 +03:00
H1K0 c3ed36f178 fix(frontend): fit PWA icons into maskable safe zone
deploy / deploy (push) Successful in 1m20s
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>
2026-07-03 19:50:57 +03:00
H1K0 93e55e8e37 fix(frontend): show content date in local time, not UTC
deploy / deploy (push) Successful in 1m19s
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>
2026-07-03 18:41:17 +03:00
H1K0 3f12b60261 fix(frontend): generate API types before svelte-check
deploy / deploy (push) Successful in 2m13s
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>
2026-07-03 18:22:48 +03:00
H1K0 6d13fa425a fix(backend): swap db name in URL-style test DSN
deploy / deploy (push) Failing after 54s
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>
2026-07-03 18:13:40 +03:00
H1K0 a0a987d561 ci(project): run the test suite before deploying
deploy / deploy (push) Failing after 1m35s
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>
2026-07-03 10:57:16 +03:00
H1K0 3e04da3926 docs(project): clarify API path vs product version in openapi
deploy / deploy (push) Successful in 1m0s
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>
2026-07-03 10:30:55 +03:00
H1K0 87d3c27b65 style: apply prettier and gofmt
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>
2026-07-03 10:13:56 +03:00
H1K0 36546c46c9 fix(frontend): clear svelte-check a11y and reactivity warnings
- 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>
2026-07-03 10:13:43 +03:00
H1K0 ac27ea3176 chore(project): license the project under AGPL-3.0
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>
2026-07-03 10:13:29 +03:00
H1K0 eb3cf72969 feat(frontend): richer pool multi-select and clearer date label
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>
2026-07-03 08:50:21 +03:00
H1K0 4b6db84afc feat: per-pool automatic file sorting
deploy / deploy (push) Successful in 2s
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>
2026-07-03 08:36:12 +03:00
H1K0 28f5b5d150 docs(project): sync docs with code and bump to 3.0.0
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>
2026-07-03 08:35:16 +03:00
H1K0 4883820f25 test(backend): encode the JPEG test fixture so it decodes
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>
2026-07-03 08:34:59 +03:00
H1K0 a6a46af12e feat(backend): graceful shutdown on SIGTERM/SIGINT
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>
2026-07-03 08:12:10 +03:00
H1K0 bf7fa49a16 feat(frontend): support nested objects in the metadata editor
deploy / deploy (push) Successful in 22s
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>
2026-07-03 00:09:11 +03:00
H1K0 76d9c35363 feat(frontend): show and edit file metadata
deploy / deploy (push) Successful in 1m1s
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>
2026-06-23 00:37:41 +03:00
H1K0 71cf79eeaa fix(backend): preserve omitted fields on file update
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>
2026-06-23 00:37:31 +03:00
H1K0 a16accf443 feat(frontend): show keep-to-other distance in the duplicates view
deploy / deploy (push) Successful in 1m1s
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 Δ—.
2026-06-22 22:42:14 +03:00
H1K0 78b5e86dd6 feat(backend): report stored pairwise distances in the duplicates response
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).
2026-06-22 22:42:14 +03:00
H1K0 281358ff04 feat(frontend): open cluster files in the full viewer for comparison
deploy / deploy (push) Successful in 18s
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.
2026-06-22 22:26:08 +03:00
H1K0 d8cccbb9e0 fix(frontend): resolve duplicate actions in place without a full reload
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.
2026-06-22 22:20:39 +03:00
57 changed files with 2998 additions and 1210 deletions
+9
View File
@@ -62,6 +62,15 @@ JWT_REFRESH_TTL=720h
# long as a viewing session lasts.
CONTENT_TOKEN_TTL=6h
# How long a graceful shutdown waits for in-flight requests to finish after the
# app receives SIGTERM/SIGINT (e.g. on `docker compose up --build`, which
# recreates the container). This SAME value is fed to the container's
# `stop_grace_period` in docker-compose.yml, so Docker won't SIGKILL the app
# mid-drain — set it in one place here and both stay in sync. A single upload or
# video stream that outlasts this window is still cut; raise it if you routinely
# move very large files. Accepts Go/Compose durations (s, m, h).
SHUTDOWN_TIMEOUT=15s
# Reverse-proxy hops (comma-separated CIDRs/IPs) whose X-Forwarded-For is trusted,
# so the auth rate limiter sees real client IPs instead of the proxy's. The default
# covers loopback and the Docker bridge ranges a host nginx reaches the container
+44 -1
View File
@@ -19,7 +19,9 @@ jobs:
# existing clone in /opt/tanabata. See docs/DEPLOY.md for runner setup.
#
# Only shell steps here (no `uses:` actions), so the host needs git + docker
# and nothing else — no node, no rsync.
# and nothing else — no node, no go, no rsync. The test step below runs the
# Go and Node toolchains inside throwaway containers, so nothing has to be
# installed on the host.
runs-on: host
env:
@@ -35,6 +37,47 @@ jobs:
git fetch --prune origin
git reset --hard origin/master
- name: Run tests
working-directory: /opt/tanabata
# Everything runs INSIDE throwaway toolchain containers — the host only
# needs docker (which it already uses for `docker compose`). Nothing (Go,
# Node, Postgres, vips/ffmpeg/exiftool) has to be installed on the host.
# The orchestration below uses only bash builtins + docker. A failure here
# fails the job, so a red build never reaches production. Module/npm caches
# persist in named volumes for speed.
shell: bash
run: |
set -euo pipefail
docker rm -f tfm-ci-db >/dev/null 2>&1 || true
docker network rm tfm-ci-net >/dev/null 2>&1 || true
docker network create tfm-ci-net >/dev/null
trap 'docker rm -f tfm-ci-db >/dev/null 2>&1 || true; docker network rm tfm-ci-net >/dev/null 2>&1 || true' EXIT
docker run -d --name tfm-ci-db --network tfm-ci-net \
-e POSTGRES_PASSWORD=postgres postgres:14-alpine >/dev/null
# Wait for Postgres, using pg_isready inside the DB container (no host tools).
for ((i = 0; i < 30; i++)); do
docker exec tfm-ci-db pg_isready -U postgres >/dev/null 2>&1 && break
sleep 1
done
# Backend: full suite, including the integration tests, against the
# ephemeral Postgres. -buildvcs=false since the .git dir isn't mounted.
docker run --rm --network tfm-ci-net \
-v /opt/tanabata/backend:/src -w /src \
-v tfm-ci-gomod:/go/pkg/mod -v tfm-ci-gocache:/root/.cache/go-build \
-e CGO_ENABLED=0 \
-e TANABATA_TEST_ADMIN_DSN="postgres://postgres:postgres@tfm-ci-db:5432/postgres?sslmode=disable" \
golang:1.26-alpine go test -buildvcs=false -count=1 ./...
# Frontend: type-check + production build (also validates openapi via
# the generate:types prestep).
docker run --rm \
-v /opt/tanabata:/repo -w /repo/frontend \
-v tfm-ci-npm:/root/.npm \
node:22-alpine sh -c "npm ci && npm run check && npm run build"
- name: Build image and start the stack
working-directory: /opt/tanabata
# .env must already exist in DEPLOY_DIR on the host (secrets + DB mode).
+7 -3
View File
@@ -13,16 +13,18 @@ Monorepo: `backend/` (Go) + `frontend/` (SvelteKit).
## Key documents (read before coding)
- `openapi.yaml` — full REST API specification (36 paths, 58 operations)
- `openapi.yaml` — full REST API specification (44 paths, 67 operations)
- `docs/REQUIREMENTS.md` — product requirements (functional + non-functional)
- `docs/ARCHITECTURE.md` — system architecture overview (layers, data flow, decisions)
- `docs/GO_PROJECT_STRUCTURE.md` — backend architecture, layer rules, DI pattern
- `docs/FRONTEND_STRUCTURE.md` — frontend architecture, CSS approach, API client
- `docs/Описание.md` — product requirements in Russian
- `backend/migrations/001_init.sql` — database schema (4 schemas, 16 tables)
- `backend/migrations/` — database schema as goose migrations (4 schemas, 19 tables)
## Design reference
Visual design tokens for the frontend (carried over from the previous
Python/Flask version):
- Color palette: #312F45 (bg), #9592B5 (accent), #444455 (tag default), #111118 (elevated)
- Font: Epilogue (variable weight)
- Dark theme is primary
@@ -32,6 +34,7 @@ Python/Flask version):
- Floating selection bar for multi-select
## Backend commands
```bash
cd backend
go run ./cmd/server # run dev server
@@ -39,6 +42,7 @@ go test ./... # run all tests
```
## Frontend commands
```bash
cd frontend
npm run dev # vite dev server
+661
View File
@@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
+7
View File
@@ -8,6 +8,8 @@ built SPA on one port.
## Documentation
- [`openapi.yaml`](openapi.yaml) — full REST API specification
- [`docs/REQUIREMENTS.md`](docs/REQUIREMENTS.md) — product requirements
- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — system architecture overview
- [`docs/DEPLOY.md`](docs/DEPLOY.md) — production deploy (Gitea Actions → host)
- [`docs/GO_PROJECT_STRUCTURE.md`](docs/GO_PROJECT_STRUCTURE.md) — backend architecture
- [`docs/FRONTEND_STRUCTURE.md`](docs/FRONTEND_STRUCTURE.md) — frontend architecture
@@ -96,3 +98,8 @@ npm run dev # Vite dev server
npm run build # production build
npm run generate:types # regenerate API types from openapi.yaml
```
## License
Licensed under the GNU Affero General Public License v3.0 or later
(AGPL-3.0-or-later). See [`LICENSE`](LICENSE) for the full text.
+32 -3
View File
@@ -2,9 +2,12 @@ package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/jackc/pgx/v5/stdlib"
@@ -142,9 +145,35 @@ func main() {
IdleTimeout: 120 * time.Second,
}
slog.Info("starting server", "addr", cfg.ListenAddr)
if err := srv.ListenAndServe(); err != nil {
slog.Error("server error", "err", err)
// Trigger a graceful shutdown on SIGINT/SIGTERM (the latter is what Docker
// sends when the container is stopped or recreated on deploy).
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
go func() {
slog.Info("starting server", "addr", cfg.ListenAddr)
// ListenAndServe returns ErrServerClosed after a graceful Shutdown; that
// is the expected exit, not a failure.
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("server error", "err", err)
os.Exit(1)
}
}()
<-ctx.Done()
// Restore default signal handling so a second Ctrl+C / SIGTERM force-quits
// instead of waiting on the drain.
stop()
slog.Info("shutting down", "timeout", cfg.ShutdownTimeout)
// Stop accepting new connections and let in-flight requests finish, up to the
// timeout. Docker's stop grace period reads the same SHUTDOWN_TIMEOUT, so it
// won't SIGKILL before this returns.
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
slog.Error("graceful shutdown failed", "err", err)
os.Exit(1)
}
slog.Info("shutdown complete")
}
+8
View File
@@ -25,6 +25,12 @@ type Config struct {
// expiry and refresh rotation. Keep it only as long as a viewing session
// plausibly lasts — it is a bearer credential for that one file until expiry.
ContentTokenTTL time.Duration
// ShutdownTimeout bounds how long a graceful shutdown waits for in-flight
// requests to finish after a SIGINT/SIGTERM before the process exits. Keep it
// in step with the container's stop grace period — docker-compose.yml reads
// the same SHUTDOWN_TIMEOUT for `stop_grace_period`, so Docker doesn't SIGKILL
// mid-drain. A long upload/stream can still be cut if it outlasts this window.
ShutdownTimeout time.Duration
// TrustedProxies lists the reverse-proxy hops (CIDRs or IPs) whose
// X-Forwarded-For header is trusted. The auth rate limiter keys on the
// client IP, so this must match the proxy in front of the app — otherwise
@@ -162,6 +168,8 @@ func Load() (*Config, error) {
ContentTokenTTL: parseDuration("CONTENT_TOKEN_TTL", "6h"),
ShutdownTimeout: parseDuration("SHUTDOWN_TIMEOUT", "15s"),
TrustedProxies: parseCSV("TRUSTED_PROXIES", "127.0.0.1/32,::1/128,172.16.0.0/12"),
AdminUsername: defaultStr("ADMIN_USERNAME", "admin"),
+100 -20
View File
@@ -30,6 +30,8 @@ type poolRow struct {
CreatorID int16 `db:"creator_id"`
CreatorName string `db:"creator_name"`
IsPublic bool `db:"is_public"`
SortKey string `db:"sort_key"`
SortOrder string `db:"sort_order"`
FileCount int `db:"file_count"`
}
@@ -68,6 +70,8 @@ func toPool(r poolRow) domain.Pool {
CreatorID: r.CreatorID,
CreatorName: r.CreatorName,
IsPublic: r.IsPublic,
SortKey: r.SortKey,
SortOrder: r.SortOrder,
FileCount: r.FileCount,
CreatedAt: domain.UUIDCreatedAt(r.ID),
}
@@ -103,9 +107,15 @@ func toPoolFile(r poolFileRow) domain.PoolFile {
// Cursor
// ---------------------------------------------------------------------------
// poolFileCursor is the keyset paging cursor for pool files. Which fields are
// populated depends on the pool's sort key: Pos for manual (position), Val for
// content_datetime (RFC3339Nano) / original_name (the coalesced name); the
// "created" sort orders by file id alone, so only FileID matters. FileID is
// always the final tiebreak.
type poolFileCursor struct {
Position int `json:"p"`
FileID string `json:"id"`
Pos int `json:"p,omitempty"`
Val string `json:"v,omitempty"`
FileID string `json:"id"`
}
func encodePoolCursor(c poolFileCursor) string {
@@ -135,6 +145,7 @@ const poolCountSubquery = `(SELECT pool_id, COUNT(*) AS cnt FROM data.file_pool
const poolSelectFrom = `
SELECT p.id, p.name, p.notes, p.metadata,
p.creator_id, u.name AS creator_name, p.is_public,
p.sort_key, p.sort_order,
COALESCE(fc.cnt, 0) AS file_count
FROM data.pools p
JOIN core.users u ON u.id = p.creator_id
@@ -147,6 +158,29 @@ func poolSortColumn(s string) string {
return "p.id" // "created"
}
// poolFileSort maps a pool's stored sort settings to the SQL column expression,
// the ORDER BY direction, and the keyset comparison operator used for paging.
// The column is chosen so it is never NULL (original_name is coalesced), which
// keeps the keyset comparison total.
func poolFileSort(sortKey, sortOrder string) (col, dir, cmp string) {
dir, cmp = "ASC", ">"
if strings.EqualFold(sortOrder, domain.SortOrderDesc) {
dir, cmp = "DESC", "<"
}
switch sortKey {
case domain.PoolSortContentDatetime:
col = "f.content_datetime"
case domain.PoolSortOriginalName:
col = "COALESCE(f.original_name, '')"
case domain.PoolSortCreated:
col = "f.id"
default: // manual — the user-arranged sequence; direction does not apply
col = "fp.position"
dir, cmp = "ASC", ">"
}
return col, dir, cmp
}
// ---------------------------------------------------------------------------
// PoolRepo
// ---------------------------------------------------------------------------
@@ -207,6 +241,7 @@ func (r *PoolRepo) List(ctx context.Context, params port.OffsetParams) (*domain.
query := fmt.Sprintf(`
SELECT p.id, p.name, p.notes, p.metadata,
p.creator_id, u.name AS creator_name, p.is_public,
p.sort_key, p.sort_order,
COALESCE(fc.cnt, 0) AS file_count,
COUNT(*) OVER() AS total
FROM data.pools p
@@ -289,6 +324,7 @@ WITH ins AS (
)
SELECT ins.id, ins.name, ins.notes, ins.metadata,
ins.creator_id, u.name AS creator_name, ins.is_public,
ins.sort_key, ins.sort_order,
0 AS file_count
FROM ins
JOIN core.users u ON u.id = ins.creator_id`
@@ -322,15 +358,18 @@ func (r *PoolRepo) Update(ctx context.Context, id uuid.UUID, p *domain.Pool) (*d
const query = `
WITH upd AS (
UPDATE data.pools SET
name = $2,
notes = $3,
metadata = COALESCE($4, metadata),
is_public = $5
name = $2,
notes = $3,
metadata = COALESCE($4, metadata),
is_public = $5,
sort_key = $6,
sort_order = $7
WHERE id = $1
RETURNING *
)
SELECT upd.id, upd.name, upd.notes, upd.metadata,
upd.creator_id, u.name AS creator_name, upd.is_public,
upd.sort_key, upd.sort_order,
COALESCE(fc.cnt, 0) AS file_count
FROM upd
JOIN core.users u ON u.id = upd.creator_id
@@ -343,7 +382,7 @@ LEFT JOIN (SELECT pool_id, COUNT(*) AS cnt FROM data.file_pool WHERE pool_id = $
}
q := connOrTx(ctx, r.pool)
rows, err := q.Query(ctx, query, id, p.Name, p.Notes, meta, p.IsPublic)
rows, err := q.Query(ctx, query, id, p.Name, p.Notes, meta, p.IsPublic, p.SortKey, p.SortOrder)
if err != nil {
return nil, fmt.Errorf("PoolRepo.Update: %w", err)
}
@@ -412,8 +451,16 @@ func (r *PoolRepo) ListFiles(ctx context.Context, poolID uuid.UUID, params port.
}
}
// Cursor condition.
var orderBy string
// Resolve the pool's sort setting (defaulting to manual position order) into
// the ORDER BY column, direction, and keyset comparison operator.
sortKey := params.SortKey
if !domain.ValidPoolSortKey(sortKey) {
sortKey = domain.PoolSortManual
}
col, dir, cmp := poolFileSort(sortKey, params.SortOrder)
// Keyset cursor condition. For "created" the file id is both the sort key and
// the tiebreak, so a single comparison suffices; the others compare (col, id).
if params.Cursor != "" {
cur, err := decodePoolCursor(params.Cursor)
if err != nil {
@@ -423,13 +470,36 @@ func (r *PoolRepo) ListFiles(ctx context.Context, poolID uuid.UUID, params port.
if err != nil {
return nil, domain.ErrValidation
}
conds = append(conds, fmt.Sprintf(
"(fp.position > $%d OR (fp.position = $%d AND fp.file_id > $%d))",
n, n, n+1))
args = append(args, cur.Position, fileID)
n += 2
if sortKey == domain.PoolSortCreated {
conds = append(conds, fmt.Sprintf("f.id %s $%d", cmp, n))
args = append(args, fileID)
n++
} else {
conds = append(conds, fmt.Sprintf(
"(%s %s $%d OR (%s = $%d AND f.id %s $%d))", col, cmp, n, col, n, cmp, n+1))
switch sortKey {
case domain.PoolSortContentDatetime:
t, err := time.Parse(time.RFC3339Nano, cur.Val)
if err != nil {
return nil, domain.ErrValidation
}
args = append(args, t)
case domain.PoolSortOriginalName:
args = append(args, cur.Val)
default: // manual
args = append(args, cur.Pos)
}
args = append(args, fileID)
n += 2
}
}
var orderBy string
if sortKey == domain.PoolSortCreated {
orderBy = fmt.Sprintf("f.id %s", dir)
} else {
orderBy = fmt.Sprintf("%s %s, f.id %s", col, dir, dir)
}
orderBy = "fp.position ASC, fp.file_id ASC"
where := "WHERE " + strings.Join(conds, " AND ")
args = append(args, limit+1)
@@ -468,11 +538,21 @@ LIMIT $%d`, fileSelectForPool, where, orderBy, n)
if hasMore && len(collected) > 0 {
last := collected[len(collected)-1]
cur := encodePoolCursor(poolFileCursor{
Position: last.Position,
FileID: last.ID.String(),
})
page.NextCursor = &cur
cursor := poolFileCursor{FileID: last.ID.String()}
switch sortKey {
case domain.PoolSortContentDatetime:
cursor.Val = last.ContentDatetime.UTC().Format(time.RFC3339Nano)
case domain.PoolSortOriginalName:
if last.OriginalName != nil {
cursor.Val = *last.OriginalName
}
case domain.PoolSortCreated:
// file id alone orders; nothing else to carry
default: // manual
cursor.Pos = last.Position
}
enc := encodePoolCursor(cursor)
page.NextCursor = &enc
}
// Batch-load tags.
+33 -2
View File
@@ -7,6 +7,32 @@ import (
"github.com/google/uuid"
)
// Pool file sort keys. PoolSortManual keeps the user-defined order in
// file_pool.position; the others sort the pool's files by that file field.
const (
PoolSortManual = "manual"
PoolSortContentDatetime = "content_datetime"
PoolSortCreated = "created"
PoolSortOriginalName = "original_name"
SortOrderAsc = "asc"
SortOrderDesc = "desc"
)
// ValidPoolSortKey reports whether s is an accepted pool sort key.
func ValidPoolSortKey(s string) bool {
switch s {
case PoolSortManual, PoolSortContentDatetime, PoolSortCreated, PoolSortOriginalName:
return true
}
return false
}
// ValidSortOrder reports whether s is an accepted sort direction.
func ValidSortOrder(s string) bool {
return s == SortOrderAsc || s == SortOrderDesc
}
// Pool is an ordered collection of files.
type Pool struct {
ID uuid.UUID
@@ -16,8 +42,13 @@ type Pool struct {
CreatorID int16
CreatorName string // denormalized
IsPublic bool
FileCount int
CreatedAt time.Time // extracted from UUID v7 via UUIDCreatedAt
// SortKey / SortOrder control how the pool's files are ordered. When SortKey
// is PoolSortManual, files follow the manual position order and can be
// reordered; otherwise they are sorted automatically and reordering is a no-op.
SortKey string
SortOrder string
FileCount int
CreatedAt time.Time // extracted from UUID v7 via UUIDCreatedAt
}
// PoolFile is a File with its ordering position within a pool.
@@ -47,12 +47,16 @@ func (h *DuplicateHandler) List(c *gin.Context) {
}
items := make([]gin.H, len(clusters))
for i, files := range clusters {
fs := make([]fileJSON, len(files))
for j, f := range files {
for i, cl := range clusters {
fs := make([]fileJSON, len(cl.Files))
for j, f := range cl.Files {
fs[j] = toFileJSON(f)
}
items[i] = gin.H{"files": fs}
dists := make([]gin.H, len(cl.Distances))
for j, d := range cl.Distances {
dists[j] = gin.H{"a": d.A, "b": d.B, "distance": d.Distance}
}
items[i] = gin.H{"files": fs, "distances": dists}
}
respondJSON(c, http.StatusOK, gin.H{
"items": items,
+14
View File
@@ -34,6 +34,8 @@ type poolJSON struct {
CreatorID int16 `json:"creator_id"`
CreatorName string `json:"creator_name"`
IsPublic bool `json:"is_public"`
SortKey string `json:"sort_key"`
SortOrder string `json:"sort_order"`
FileCount int `json:"file_count"`
CreatedAt string `json:"created_at"`
}
@@ -51,6 +53,8 @@ func toPoolJSON(p domain.Pool) poolJSON {
CreatorID: p.CreatorID,
CreatorName: p.CreatorName,
IsPublic: p.IsPublic,
SortKey: p.SortKey,
SortOrder: p.SortOrder,
FileCount: p.FileCount,
CreatedAt: p.CreatedAt.UTC().Format(time.RFC3339),
}
@@ -214,6 +218,16 @@ func (h *PoolHandler) Update(c *gin.Context) {
params.IsPublic = &b
}
}
if v, ok := raw["sort_key"]; ok {
if s, ok := v.(string); ok {
params.SortKey = &s
}
}
if v, ok := raw["sort_order"]; ok {
if s, ok := v.(string); ok {
params.SortOrder = &s
}
}
updated, err := h.poolSvc.Update(c.Request.Context(), id, params)
if err != nil {
+147 -34
View File
@@ -16,6 +16,8 @@ import (
"context"
"encoding/json"
"fmt"
"image"
"image/jpeg"
"io"
"mime/multipart"
"net"
@@ -24,6 +26,7 @@ import (
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"time"
@@ -1575,41 +1578,25 @@ func TestPoolOperationsRequireACL(t *testing.T) {
// Test helpers
// ---------------------------------------------------------------------------
// minimalJPEG returns the bytes of a 1×1 white JPEG image.
// Generated offline; no external dependency needed.
// minimalJPEG returns the bytes of a small solid-white JPEG produced by the
// standard library encoder. Encoding is deterministic, so every call yields
// byte-identical output — two uploads therefore hash to the same perceptual
// hash (a solid image dHashes to 0), which is what the duplicate test relies on.
//
// It is encoded (rather than a hand-written byte literal) so the bytes decode
// cleanly through image.Decode: that is the same path FileService.Upload takes
// to compute the perceptual hash, and a fixture that only passes MIME sniffing
// but fails to fully decode would leave phash NULL and silently break dedup.
func minimalJPEG() []byte {
// This is a valid minimal JPEG: SOI + APP0 + DQT + SOF0 + DHT + SOS + EOI.
// 1×1 white pixel, quality ~50. Verified with `file` and browsers.
return []byte{
0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01,
0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xff, 0xdb, 0x00, 0x43,
0x00, 0x08, 0x06, 0x06, 0x07, 0x06, 0x05, 0x08, 0x07, 0x07, 0x07, 0x09,
0x09, 0x08, 0x0a, 0x0c, 0x14, 0x0d, 0x0c, 0x0b, 0x0b, 0x0c, 0x19, 0x12,
0x13, 0x0f, 0x14, 0x1d, 0x1a, 0x1f, 0x1e, 0x1d, 0x1a, 0x1c, 0x1c, 0x20,
0x24, 0x2e, 0x27, 0x20, 0x22, 0x2c, 0x23, 0x1c, 0x1c, 0x28, 0x37, 0x29,
0x2c, 0x30, 0x31, 0x34, 0x34, 0x34, 0x1f, 0x27, 0x39, 0x3d, 0x38, 0x32,
0x3c, 0x2e, 0x33, 0x34, 0x32, 0xff, 0xc0, 0x00, 0x0b, 0x08, 0x00, 0x01,
0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xff, 0xc4, 0x00, 0x1f, 0x00, 0x00,
0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
0x09, 0x0a, 0x0b, 0xff, 0xc4, 0x00, 0xb5, 0x10, 0x00, 0x02, 0x01, 0x03,
0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7d,
0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06,
0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xa1, 0x08,
0x23, 0x42, 0xb1, 0xc1, 0x15, 0x52, 0xd1, 0xf0, 0x24, 0x33, 0x62, 0x72,
0x82, 0x09, 0x0a, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x25, 0x26, 0x27, 0x28,
0x29, 0x2a, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x43, 0x44, 0x45,
0x46, 0x47, 0x48, 0x49, 0x4a, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59,
0x5a, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x73, 0x74, 0x75,
0x76, 0x77, 0x78, 0x79, 0x7a, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89,
0x8a, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0xa2, 0xa3, 0xa4,
0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, 0xb7,
0xb8, 0xb9, 0xba, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9, 0xca,
0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, 0xda, 0xe1, 0xe2, 0xe3,
0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5,
0xf6, 0xf7, 0xf8, 0xf9, 0xfa, 0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00,
0x00, 0x3f, 0x00, 0xfb, 0xd3, 0xff, 0xd9,
img := image.NewGray(image.Rect(0, 0, 16, 16))
for i := range img.Pix {
img.Pix[i] = 0xff // white
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 75}); err != nil {
panic("minimalJPEG: encode: " + err.Error())
}
return buf.Bytes()
}
// replaceDSNDatabase returns a copy of dsn with the dbname parameter replaced.
@@ -1627,7 +1614,13 @@ func replaceDSNDatabase(dsn, newDB string) string {
}
return dsn + " dbname=" + newDB
}
// URL style: not used in our defaults, but handled for completeness.
// URL style (e.g. postgres://user:pass@host:port/dbname?opts): the database
// is the URL path. CI passes this form via TANABATA_TEST_ADMIN_DSN, so it
// must swap the path to point the suite at its per-run database.
if u, err := url.Parse(dsn); err == nil {
u.Path = "/" + newDB
return u.String()
}
return dsn
}
@@ -1661,6 +1654,11 @@ type dupListResponse struct {
ID string `json:"id"`
} `json:"tags"`
} `json:"files"`
Distances []struct {
A string `json:"a"`
B string `json:"b"`
Distance int `json:"distance"`
} `json:"distances"`
} `json:"items"`
Total int `json:"total"`
}
@@ -1703,6 +1701,9 @@ func TestDuplicateDetection(t *testing.T) {
require.Equal(t, 1, list.Total, "expected one duplicate cluster: %s", resp)
require.Len(t, list.Items, 1)
require.Len(t, list.Items[0].Files, 2)
// The pair's stored distance rides along; identical 1×1 uploads are distance 0.
require.Len(t, list.Items[0].Distances, 1, "the pair's distance should be reported")
assert.Equal(t, 0, list.Items[0].Distances[0].Distance)
// --- resolve: keep f1, union tags from f2, trash f2 ----------------------
resp = h.doJSON("POST", "/files/duplicates/resolve", map[string]any{
@@ -1751,3 +1752,115 @@ func TestDuplicateDetection(t *testing.T) {
resp.decode(t, &list)
assert.Equal(t, 0, list.Total, "dismissal must survive a rescan")
}
// TestPoolAutomaticSort exercises per-pool file ordering: the default manual
// order, switching to automatic sorts (by name and by creation), that an
// auto-sorted pool rejects manual reordering, and that keyset paging honours the
// active sort.
func TestPoolAutomaticSort(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
h := setupSuite(t)
admin := h.login("admin", "admin")
// Upload three files with distinct names in a non-alphabetical order.
idB := h.uploadJPEG(admin, "b.jpg")["id"].(string)
idA := h.uploadJPEG(admin, "a.jpg")["id"].(string)
idC := h.uploadJPEG(admin, "c.jpg")["id"].(string)
// "created" order is UUID (byte) order — lexicographic on the canonical string.
createdAsc := []string{idA, idB, idC}
sort.Strings(createdAsc)
createdDesc := []string{createdAsc[2], createdAsc[1], createdAsc[0]}
// New pool defaults to manual ordering.
resp := h.doJSON("POST", "/pools", map[string]any{"name": "sortpool"}, admin)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
var pool map[string]any
resp.decode(t, &pool)
poolID := pool["id"].(string)
require.Equal(t, "manual", pool["sort_key"])
// Add in upload order (b, a, c) → that becomes the manual position order.
resp = h.doJSON("POST", "/pools/"+poolID+"/files",
map[string]any{"file_ids": []string{idB, idA, idC}}, admin)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
listIDs := func() []string {
resp := h.doJSON("GET", "/pools/"+poolID+"/files", nil, admin)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
var page struct {
Items []struct {
ID string `json:"id"`
} `json:"items"`
}
resp.decode(t, &page)
ids := make([]string, len(page.Items))
for i, it := range page.Items {
ids[i] = it.ID
}
return ids
}
setSort := func(key, order string) {
body := map[string]any{"sort_key": key}
if order != "" {
body["sort_order"] = order
}
resp := h.doJSON("PATCH", "/pools/"+poolID, body, admin)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
}
// Manual = insertion order.
require.Equal(t, []string{idB, idA, idC}, listIDs())
// By name ascending → a, b, c.
setSort("original_name", "asc")
require.Equal(t, []string{idA, idB, idC}, listIDs())
// By creation descending → reverse UUID order.
setSort("created", "desc")
require.Equal(t, createdDesc, listIDs())
// Reordering an auto-sorted pool is rejected.
resp = h.doJSON("PUT", "/pools/"+poolID+"/files/reorder",
map[string]any{"file_ids": []string{idC, idB, idA}}, admin)
require.Equal(t, http.StatusBadRequest, resp.StatusCode, resp.String())
// Back to manual → reordering works again and sticks.
setSort("manual", "")
resp = h.doJSON("PUT", "/pools/"+poolID+"/files/reorder",
map[string]any{"file_ids": []string{idC, idB, idA}}, admin)
require.Equal(t, http.StatusNoContent, resp.StatusCode, resp.String())
require.Equal(t, []string{idC, idB, idA}, listIDs())
// Keyset paging (limit 1) under an automatic sort returns the full sequence.
setSort("original_name", "asc")
var paged []string
cursor := ""
for i := 0; i < 10; i++ {
url := "/pools/" + poolID + "/files?limit=1"
if cursor != "" {
url += "&cursor=" + cursor
}
resp := h.doJSON("GET", url, nil, admin)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
var page struct {
Items []struct {
ID string `json:"id"`
} `json:"items"`
NextCursor *string `json:"next_cursor"`
}
resp.decode(t, &page)
for _, it := range page.Items {
paged = append(paged, it.ID)
}
if page.NextCursor == nil {
break
}
cursor = *page.NextCursor
}
require.Equal(t, []string{idA, idB, idC}, paged)
}
+4
View File
@@ -36,6 +36,10 @@ type PoolFileListParams struct {
Cursor string
Limit int
Filter string // filter DSL expression
// SortKey / SortOrder come from the pool itself (not the request) and select
// how files are ordered: manual position order or an automatic file-field sort.
SortKey string
SortOrder string
}
// FileRepo is the persistence interface for file records.
@@ -103,6 +103,31 @@ func buildPairs(entries []domain.PHashEntry, threshold int, onProgress func(done
return pairs
}
// orderedPair returns the two ids in canonical (a < b by UUID byte order) order,
// matching how the pairs table keys a distance so a lookup hits regardless of the
// argument order.
func orderedPair(a, b uuid.UUID) [2]uuid.UUID {
if bytes.Compare(a[:], b[:]) > 0 {
return [2]uuid.UUID{b, a}
}
return [2]uuid.UUID{a, b}
}
// clusterDistances returns the stored Hamming distance for every pair of files in
// the cluster that has one. Pairs present only transitively have no stored
// distance and are left out.
func clusterDistances(files []domain.File, distByPair map[[2]uuid.UUID]int) []PairDistance {
var out []PairDistance
for i := 0; i < len(files); i++ {
for j := i + 1; j < len(files); j++ {
if d, ok := distByPair[orderedPair(files[i].ID, files[j].ID)]; ok {
out = append(out, PairDistance{A: files[i].ID, B: files[j].ID, Distance: d})
}
}
}
return out
}
// clusterPairs groups pairs into connected components (transitive closure) via
// union-find. Every returned cluster has at least two files; clusters and the ids
// within them are sorted by UUID for stable pagination.
+28 -5
View File
@@ -125,10 +125,27 @@ func NewDuplicateService(
}
}
// Cluster is a group of near-duplicate files together with the pairwise Hamming
// distances known between them. Distances are read from the stored pairs, so two
// files linked into the cluster only transitively (through an intermediate) may
// have no direct distance — that pair is simply omitted.
type Cluster struct {
Files []domain.File
Distances []PairDistance
}
// PairDistance is the stored Hamming distance between two files of a cluster.
type PairDistance struct {
A uuid.UUID
B uuid.UUID
Distance int
}
// Clusters returns a page of duplicate clusters visible to the caller. Pairs are
// read from the precomputed table (no all-pairs scan here) and grouped into
// connected components; pagination is over whole clusters.
func (s *DuplicateService) Clusters(ctx context.Context, limit, offset int) (clusters [][]domain.File, total int, err error) {
// connected components; pagination is over whole clusters. Each cluster carries
// the stored pairwise distances so callers can show how close the files are.
func (s *DuplicateService) Clusters(ctx context.Context, limit, offset int) (clusters []Cluster, total int, err error) {
userID, isAdmin, _ := domain.UserFromContext(ctx)
pairs, err := s.pairs.ListVisible(ctx, userID, isAdmin)
@@ -142,14 +159,20 @@ func (s *DuplicateService) Clusters(ctx context.Context, limit, offset int) (clu
offset = 0
}
if offset >= len(groups) {
return [][]domain.File{}, total, nil
return []Cluster{}, total, nil
}
end := offset + limit
if end > len(groups) || limit <= 0 {
end = len(groups)
}
out := make([][]domain.File, 0, end-offset)
// Index the stored distances once; each page cluster looks up its own pairs.
distByPair := make(map[[2]uuid.UUID]int, len(pairs))
for _, p := range pairs {
distByPair[orderedPair(p.FileA, p.FileB)] = p.Distance
}
out := make([]Cluster, 0, end-offset)
for _, ids := range groups[offset:end] {
files := make([]domain.File, 0, len(ids))
for _, id := range ids {
@@ -164,7 +187,7 @@ func (s *DuplicateService) Clusters(ctx context.Context, limit, offset int) (clu
files = append(files, *f)
}
if len(files) >= 2 {
out = append(out, files)
out = append(out, Cluster{Files: files, Distances: clusterDistances(files, distByPair)})
}
}
return out, total, nil
+11 -1
View File
@@ -286,7 +286,17 @@ func (s *FileService) Update(ctx context.Context, id uuid.UUID, p UpdateParams)
return nil, domain.ErrForbidden
}
patch := &domain.File{}
// The repo rewrites every editable column, so the patch must carry the final
// value for each. Seed the fields that have no always-present input in the
// editor (original_name, metadata) with their current values so a partial
// update that omits them leaves them untouched instead of clearing them. The
// remaining scalars are always supplied by the editor (notes uses an explicit
// null to clear). The merge path builds its own complete patch and calls the
// repo directly, so it is unaffected by this.
patch := &domain.File{
OriginalName: f.OriginalName,
Metadata: f.Metadata,
}
if p.OriginalName != nil {
patch.OriginalName = p.OriginalName
}
+50 -10
View File
@@ -13,12 +13,15 @@ import (
const poolObjectType = "pool"
const poolObjectTypeID int16 = 4 // fourth row in 007_seed_data.sql object_types
// PoolParams holds the fields for creating or patching a pool.
// PoolParams holds the fields for creating or patching a pool. SortKey and
// SortOrder are pointers so a patch can leave them unchanged (nil) vs set them.
type PoolParams struct {
Name string
Notes *string
Metadata json.RawMessage
IsPublic *bool
Name string
Notes *string
Metadata json.RawMessage
IsPublic *bool
SortKey *string
SortOrder *string
}
// PoolService handles pool CRUD and poolfile management with ACL + audit.
@@ -164,6 +167,18 @@ func (s *PoolService) Update(ctx context.Context, id uuid.UUID, p PoolParams) (*
if p.IsPublic != nil {
patch.IsPublic = *p.IsPublic
}
if p.SortKey != nil {
if !domain.ValidPoolSortKey(*p.SortKey) {
return nil, domain.ErrValidation
}
patch.SortKey = *p.SortKey
}
if p.SortOrder != nil {
if !domain.ValidSortOrder(*p.SortOrder) {
return nil, domain.ErrValidation
}
patch.SortOrder = *p.SortOrder
}
updated, err := s.pools.Update(ctx, id, &patch)
if err != nil {
@@ -205,12 +220,24 @@ func (s *PoolService) Delete(ctx context.Context, id uuid.UUID) error {
// Poolfile operations
// ---------------------------------------------------------------------------
// ListFiles returns cursor-paginated files within a pool ordered by position,
// enforcing view ACL on the pool.
// ListFiles returns cursor-paginated files within a pool, enforcing view ACL.
// The ordering is the pool's own stored sort setting (manual position order or
// an automatic file-field sort), not a request parameter.
func (s *PoolService) ListFiles(ctx context.Context, poolID uuid.UUID, params port.PoolFileListParams) (*domain.PoolFilePage, error) {
if err := s.authorizeView(ctx, poolID); err != nil {
userID, isAdmin, _ := domain.UserFromContext(ctx)
pool, err := s.pools.GetByID(ctx, poolID)
if err != nil {
return nil, err
}
ok, err := s.acl.CanView(ctx, userID, isAdmin, pool.CreatorID, pool.IsPublic, poolObjectTypeID, poolID)
if err != nil {
return nil, err
}
if !ok {
return nil, domain.ErrForbidden
}
params.SortKey = pool.SortKey
params.SortOrder = pool.SortOrder
return s.pools.ListFiles(ctx, poolID, params)
}
@@ -242,11 +269,24 @@ func (s *PoolService) RemoveFiles(ctx context.Context, poolID uuid.UUID, fileIDs
}
// Reorder sets the ordered sequence of file IDs within a pool, enforcing edit
// ACL on the pool.
// ACL on the pool. Manual ordering only applies when the pool's sort key is
// "manual"; reordering an auto-sorted pool is rejected as a validation error.
func (s *PoolService) Reorder(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error {
if err := s.authorizeEdit(ctx, poolID); err != nil {
userID, isAdmin, _ := domain.UserFromContext(ctx)
pool, err := s.pools.GetByID(ctx, poolID)
if err != nil {
return err
}
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, pool.CreatorID, poolObjectTypeID, poolID)
if err != nil {
return err
}
if !ok {
return domain.ErrForbidden
}
if pool.SortKey != domain.PoolSortManual {
return domain.ErrValidation
}
if err := s.pools.Reorder(ctx, poolID, fileIDs); err != nil {
return err
}
+10 -1
View File
@@ -76,8 +76,17 @@ CREATE TABLE data.pools (
creator_id smallint NOT NULL REFERENCES core.users(id)
ON UPDATE CASCADE ON DELETE RESTRICT,
is_public boolean NOT NULL DEFAULT false,
-- File ordering within the pool. 'manual' keeps the user-defined order in
-- data.file_pool.position (drag-to-reorder); any other key sorts the pool's
-- files automatically by that file field, in which case reordering is disabled.
sort_key varchar(32) NOT NULL DEFAULT 'manual',
sort_order varchar(4) NOT NULL DEFAULT 'asc',
CONSTRAINT uni__pools__name UNIQUE (name)
CONSTRAINT uni__pools__name UNIQUE (name),
CONSTRAINT chk__pools__sort_key
CHECK (sort_key IN ('manual', 'content_datetime', 'created', 'original_name')),
CONSTRAINT chk__pools__sort_order
CHECK (sort_order IN ('asc', 'desc'))
);
-- `position` uses integer with gaps (e.g. 1000, 2000, 3000) to allow
+6
View File
@@ -26,6 +26,12 @@ services:
container_name: tfm
restart: unless-stopped
# Give the app time to drain in-flight requests on stop before SIGKILL. Reads
# the same SHUTDOWN_TIMEOUT the app uses for its graceful-shutdown deadline
# (via env_file below), so the two never drift. Interpolated from .env at
# `docker compose` time; defaults to 15s if unset.
stop_grace_period: ${SHUTDOWN_TIMEOUT:-15s}
# All application config (secrets, DATABASE_URL, tunables) comes from .env.
env_file: .env
+180
View File
@@ -0,0 +1,180 @@
# Tanabata File Manager — Architecture
System-level overview of Tanabata File Manager (TFM). For the product-level
requirements see [REQUIREMENTS.md](REQUIREMENTS.md); for per-side detail see
[GO_PROJECT_STRUCTURE.md](GO_PROJECT_STRUCTURE.md) (backend) and
[FRONTEND_STRUCTURE.md](FRONTEND_STRUCTURE.md) (frontend). The full HTTP contract
lives in [`openapi.yaml`](../openapi.yaml).
## System Context
TFM is a multi-user, tag-based web file manager for images and video. It is a
single deployable unit — one Docker image that serves both the REST API and the
built single-page app on one port — plus a PostgreSQL database.
```
┌───────────────────────────────────────────┐
Browser / installed │ Reverse proxy (nginx, TLS) │
PWA (desktop/mobile) │ host: 443 → 127.0.0.1:${APP_PORT} │
│ HTTPS └─────────────────────┬─────────────────────┘
└─────────────────────────────────────┼──────────────► 127.0.0.1:42776
┌─────────────────────────▼────────────────────────┐
│ Tanabata container (single image) │
│ │
│ Go server (Gin) │
│ ├─ /api/v1/* REST API │
│ ├─ /health liveness │
│ └─ /* static SPA + index.html │
│ fallback │
│ │
│ Disk: /data/files (originals, name = UUID) │
│ /data/thumbs (thumbnail/preview cache) │
│ /data/import (server-side import drop) │
└─────────────────────────┬────────────────────────┘
│ pgx (private network)
┌─────────▼─────────┐
│ PostgreSQL 14+ │
│ (bundled or host)│
└───────────────────┘
```
Optional companion process: a one-shot **dedup CLI** (same image, different
entrypoint) that backfills perceptual hashes and rebuilds the duplicate-pairs
table. It is not a daemon — it is run on demand.
## Components
| Component | Tech | Responsibility |
| -------------- | -------------------------------------------------------------------------- | -------------------------------------------------------- |
| Frontend (SPA) | SvelteKit (adapter-static, `ssr=false`), Svelte 5, Tailwind v4, TypeScript | UI, client routing, PWA/offline, calls the REST API |
| API server | Go + Gin, Clean Architecture | REST API, auth, ACL, business logic, thumbnailing, audit |
| Database | PostgreSQL 14+ (pgx v5, goose) | All structured data across 4 schemas / 19 tables |
| File storage | Local disk, flat, keyed by UUID | Originals + a regenerable thumbnail/preview cache |
| dedup CLI | Go (same image) | Offline perceptual-hash backfill + pairs rescan |
| Reverse proxy | nginx (host, not shipped) | TLS termination, large-body/streaming config |
## Backend Architecture (Clean Architecture)
Dependencies point inward; no layer imports a layer above it.
```
handler → service → port (interfaces) ← db/postgres, storage, imagehash
domain (entities, value objects, errors) — stdlib only
```
- **domain** — entities and errors, zero internal imports.
- **port** — interfaces (repositories, `FileStorage`, `Transactor`).
- **service** — use cases; the only place business rules live.
- **handler** — Gin HTTP layer; maps domain errors to HTTP status codes.
- **db/postgres**, **storage**, **imagehash** — adapters implementing the ports.
Wiring is manual in `cmd/server/main.go` (no DI framework). See
[GO_PROJECT_STRUCTURE.md](GO_PROJECT_STRUCTURE.md) for the file-by-file layout,
the transaction/context patterns, and the DI sketch.
## Request Flow (typical authenticated call)
1. The SPA sends `Authorization: Bearer <access token>` to `/api/v1/...`.
2. Gin middleware runs: security headers → (for `/auth`) per-IP rate limiter →
auth middleware validates the JWT and puts `(userID, isAdmin, sessionID)`
into the request context.
3. The handler parses/validates input and calls a service method
(`ctx` first arg).
4. The service enforces ACL via `ACLService`, performs the use case — composing
repository calls inside a `Transactor.WithTx` when several writes must be
atomic — and writes an audit entry.
5. Repositories run SQL through pgx (pool or the tx carried in `ctx`).
6. The handler serializes the result; domain errors are mapped to
`{ code, message, details? }` with the right HTTP status.
## Cross-Cutting Concerns
### Authentication & sessions
JWT bearer auth. A short-lived **access token** (15 min default) authorizes API
calls; a long-lived **refresh token** (30 days default) rotates on use and is
stored as a hash in `activity.sessions`. A separate **content token** (6 h
default) is a single-file capability embedded in media URLs so long video keeps
streaming past access-token expiry. The `/auth` endpoints are rate-limited per
client IP.
### Authorization (ACL)
Private-by-default. Admins see everything; otherwise access requires a `public`
flag, creator ownership, or an explicit grant in `acl.permissions` (read / edit).
All checks are centralized in `ACLService` and applied before reads and writes.
### File storage
Originals are stored flat under `FILES_PATH`, each named by its file UUID (no
directory tree, no original-name collisions). Thumbnails and previews are a
**regenerable cache** under `THUMBS_CACHE_PATH`: still images via vipsthumbnail
(shrink-on-load) with a pure-Go `imaging` fallback, video frames via ffmpeg;
metadata/EXIF via exiftool with a pure-Go fallback. Uploads are rejected unless
their sniffed MIME type is whitelisted in `core.mime_types`.
### Near-duplicate detection
Images are dHash-ed (64-bit perceptual hash) inline on upload; video hashes are
backfilled by the dedup CLI. A rescan rebuilds `data.duplicate_pairs` using a
BK-tree over Hamming distance (within `DUPLICATE_HASH_THRESHOLD`), and the API
groups pairs into connected-component clusters. Dismissed pairs are remembered so
they stop resurfacing. See the duplicate sections in
[GO_PROJECT_STRUCTURE.md](GO_PROJECT_STRUCTURE.md).
### Audit logging
User-visible actions (file/tag/category/pool CRUD, relations, ACL changes,
auth, session termination, admin user actions) are recorded in
`activity.audit_log` against a seeded set of action types.
### Frontend / PWA
Pure client-side SPA: static assets served by the Go binary, with `index.html`
as the fallback for client routes. Installable PWA with a service worker for
app-shell caching and optional offline viewing of pinned files. See
[FRONTEND_STRUCTURE.md](FRONTEND_STRUCTURE.md).
## Data Model
PostgreSQL, four schemas (see `backend/migrations/`):
- **core** — users, MIME whitelist, object types.
- **data** — categories, tags, tag rules, files, filetag, pools, filepool,
duplicate pairs, duplicate dismissals.
- **acl** — per-object permission grants.
- **activity** — sessions, file/pool views, tag uses, audit log, action types.
Migrations are goose files embedded via `go:embed` and applied automatically on
server startup, so a fresh database bootstraps itself.
## Deployment
- **One image, one port.** The multi-stage `Dockerfile` builds the SPA (Node
stage) and the static Go binary (Go stage), then ships an Alpine runtime with
vips-tools / ffmpeg / exiftool and a non-root user. The server serves both the
API and the SPA on port **42776** (the sum of the code points of 七夕).
- **Compose.** `docker-compose.yml` runs the app plus, optionally, a bundled
PostgreSQL (`with-db` profile); a host Postgres is supported by leaving the
profile empty. The app is published on loopback only and expects a host
reverse proxy; the DB sits on a private `internal` network with no route
off-host. The dedup CLI is a `tools`-profile, run-on-demand service.
- **Config.** All runtime config is environment variables, fully documented in
[`.env.example`](../.env.example) (1:1 with `config.Config`). Secrets
(`JWT_SECRET`, `ADMIN_PASSWORD`, `DATABASE_URL`) are never baked into the image.
- **First run.** Migrations auto-apply and the initial admin is bootstrapped
from `ADMIN_USERNAME` / `ADMIN_PASSWORD`, so setup is: fill `.env`,
`docker compose up`.
See [DEPLOY.md](DEPLOY.md) for the production deploy (Gitea Actions → host) and
the reverse-proxy notes in [README.md](../README.md).
## Design Constraints & Future Direction
- **DDD / Clean Architecture** on the server keeps business rules independent of
Gin and pgx.
- **PostgreSQL-specific adapters are isolated** behind the `port` interfaces (the
filter DSL → SQL translation lives in `db/postgres`), leaving room for other
database engines in a future version without touching the service layer.
+6 -3
View File
@@ -3,13 +3,16 @@
Tanabata is deployed by a [Gitea Actions](https://docs.gitea.com/usage/actions/overview)
workflow ([`.gitea/workflows/deploy.yml`](../.gitea/workflows/deploy.yml)) that
runs on the **production host itself**. On every push to `master` it updates the
git clone in `/opt/tanabata` and runs `docker compose up -d --build` there, so the
image is built from the freshly-pushed code and the stack is restarted.
git clone in `/opt/tanabata`, runs the test suite (backend + frontend, in
throwaway toolchain containers), and — only if it passes — runs
`docker compose up -d --build` there, so the image is built from the
freshly-pushed code and the stack is restarted.
```
push master ──> Gitea (container) ──> act_runner (host, "host" label)
│ git fetch + reset --hard (in /opt/tanabata)
└ docker compose up -d --build
│ run tests (go + node in containers; ephemeral Postgres)
└ docker compose up -d --build (only if tests pass)
```
The Gitea server runs in a container, but the **runner runs directly on the host**
+169 -315
View File
@@ -1,13 +1,21 @@
# Tanabata File Manager — Frontend Structure
> Frontend counterpart of [ARCHITECTURE.md](ARCHITECTURE.md). This document
> details the SvelteKit layout, the CSS approach, and the API client.
## Stack
- **Framework**: SvelteKit (SPA mode, `ssr: false`)
- **Language**: TypeScript
- **CSS**: Tailwind CSS + CSS custom properties (hybrid)
- **API types**: Auto-generated via openapi-typescript
- **PWA**: Service worker + web manifest
- **Framework**: SvelteKit in **SPA mode** (`adapter-static`, `ssr = false`),
Svelte 5 (runes)
- **Language**: TypeScript (strict)
- **Build**: Vite 7
- **CSS**: Tailwind CSS **v4** via `@tailwindcss/vite` + CSS custom properties
(`@theme` in `app.css`) — no `tailwind.config.*` / `postcss.config.*` file
- **API types**: auto-generated via `openapi-typescript` (`src/lib/api/schema.ts`)
- **PWA**: service worker + web manifest
- **Font**: Epilogue (variable weight)
- **Dev**: `vite-mock-plugin.ts` serves a mock API so the UI can run without the
Go backend
- **Package manager**: npm
## SPA mode — why SvelteKit without the server
@@ -19,7 +27,7 @@ static assets, and the only backend is the Go API. SvelteKit is used here
purely as an SPA framework: file-based routing, the client router, and build
tooling.
**SvelteKit features we *do* use:**
**SvelteKit features we _do_ use:**
- File-based routing with nested layouts (`admin/` has its own guard) and
dynamic segments (`[id]`).
@@ -30,11 +38,11 @@ tooling.
over the still-mounted list via shallow routing, so the browser back button
dismisses it without reloading the grid. This is the single biggest reason we
stay on SvelteKit rather than a plain router.
- `load` functions, used *only* as client-side route guards (auth redirect,
- `load` functions, used _only_ as client-side route guards (auth redirect,
admin redirect, `/``/files`).
- `$lib` alias, generated `./$types`, Vite/HMR integration.
**SvelteKit features we deliberately do *not* use** (the "server half"):
**SvelteKit features we deliberately do _not_ use** (the "server half"):
- SSR / hydration.
- `+page.server.ts`, `+server.ts` endpoints, form actions — all data goes
@@ -43,7 +51,7 @@ tooling.
`hooks.client.ts` files exist.
**Decision: stay on SvelteKit, do not migrate to a bare Svelte + router SPA.**
The project already *is* an SPA, so there is no runtime gain from switching
The project already _is_ an SPA, so there is no runtime gain from switching
(adapter-static tree-shakes the unused server bits; the client-runtime size
difference is negligible). A migration would mean re-implementing nested
layouts, guards, dynamic params, and — most painfully — shallow routing /
@@ -55,15 +63,7 @@ expect SSR, endpoints, or hooks to do anything here; that is intentional.
```
tanabata/
├── backend/ ← Go project (go.mod in here)
│ ├── cmd/
│ ├── internal/
│ ├── migrations/
│ ├── go.mod
│ └── go.sum
├── frontend/ ← SvelteKit project (package.json in here)
│ └── (see below)
├── openapi.yaml ← Shared API contract (root level)
├── docker-compose.yml
├── Dockerfile
@@ -71,352 +71,206 @@ tanabata/
└── README.md
```
`openapi.yaml` lives at repository root — both backend and frontend
reference it. The frontend generates types from it; the backend
validates its handlers against it.
`openapi.yaml` lives at repository root — both backend and frontend reference
it. The frontend generates types from it; the backend implements it.
## Frontend Directory Layout
```
frontend/
├── package.json
├── svelte.config.js
├── vite.config.ts
├── svelte.config.js # adapter-static, fallback: index.html
├── vite.config.ts # plugins: tailwindcss(), sveltekit(), mockApiPlugin()
├── vite-mock-plugin.ts # dev-only mock API (run the UI without the Go backend)
├── tsconfig.json
├── tailwind.config.ts
├── postcss.config.js
├── src/
│ ├── app.html # Shell HTML (PWA meta, font preload)
│ ├── app.css # Tailwind directives + CSS custom properties
│ # (no hooks.* — see "SPA mode" above)
│ ├── lib/ # Shared code ($lib/ alias)
│ │
│ ├── api/ # API client layer
│ │ │ ├── client.ts # Base fetch wrapper: auth headers, token refresh,
│ │ │ │ # error parsing, base URL
│ │ │ ├── files.ts # listFiles, getFile, uploadFile, deleteFile, etc.
│ │ │ ├── tags.ts # listTags, createTag, getTag, updateTag, etc.
│ │ │ ├── categories.ts # Category API functions
│ │ │ ├── pools.ts # Pool API functions
│ │ │ ├── auth.ts # login, logout, refresh, listSessions
│ │ │ ├── acl.ts # getPermissions, setPermissions
│ │ │ ├── users.ts # getMe, updateMe, admin user CRUD
│ │ │ ├── audit.ts # queryAuditLog
│ │ │ ├── schema.ts # AUTO-GENERATED from openapi.yaml (do not edit)
│ │ │ └── types.ts # Friendly type aliases:
│ │ │ # export type File = components["schemas"]["File"]
│ │ │ # export type Tag = components["schemas"]["Tag"]
│ │ │
│ │ ├── components/ # Reusable UI components
│ │ │ │
│ │ │ ├── layout/ # App shell
│ │ │ │ ├── Navbar.svelte # Bottom navigation bar (mobile-first)
│ │ │ │ ├── Header.svelte # Section header with sorting controls
│ │ │ │ ├── SelectionBar.svelte # Floating bar for multi-select actions
│ │ │ │ └── Loader.svelte # Full-screen loading overlay
│ │ │ │
│ │ │ ├── file/ # File-related components
│ │ │ │ ├── FileGrid.svelte # Thumbnail grid with infinite scroll
│ │ │ │ ├── FileCard.svelte # Single thumbnail (160×160, selectable)
│ │ │ │ ├── FileViewer.svelte # Full-screen preview with prev/next navigation
│ │ │ │ ├── FileUpload.svelte # Upload form + drag-and-drop zone
│ │ │ │ ├── FileDetail.svelte # Metadata editor (notes, datetime, tags)
│ │ │ │ └── FilterBar.svelte # DSL filter builder UI
│ │ │ │
│ │ │ ├── tag/ # Tag-related components
│ │ │ │ ├── TagBadge.svelte # Colored pill with tag name
│ │ │ │ ├── TagPicker.svelte # Searchable tag selector (add/remove)
│ │ │ │ ├── TagList.svelte # Tag grid for section view
│ │ │ │ └── TagRuleEditor.svelte # Auto-tag rule management
│ │ │ │
│ │ │ ├── pool/ # Pool-related components
│ │ │ │ ├── PoolCard.svelte # Pool preview card
│ │ │ │ ├── PoolFileList.svelte # Ordered file list with drag reorder
│ │ │ │ └── PoolDetail.svelte # Pool metadata editor
│ │ │ │
│ │ │ ├── acl/ # Access control components
│ │ │ │ └── PermissionEditor.svelte # User permission grid
│ │ │ │
│ │ │ └── common/ # Shared primitives
│ │ │ ├── Button.svelte
│ │ │ ├── Modal.svelte
│ │ │ ├── ConfirmDialog.svelte
│ │ │ ├── Toast.svelte
│ │ │ ├── InfiniteScroll.svelte
│ │ │ ├── Pagination.svelte
│ │ │ ├── SortDropdown.svelte
│ │ │ ├── SearchInput.svelte
│ │ │ ├── ColorPicker.svelte
│ │ │ ├── Checkbox.svelte # Three-state: checked, unchecked, partial
│ │ │ └── EmptyState.svelte
│ │ │
│ │ ├── stores/ # Svelte stores (global state)
│ │ │ ├── auth.ts # Current user, JWT tokens, isAuthenticated
│ │ │ ├── selection.ts # Selected item IDs, selection mode toggle
│ │ │ ├── sorting.ts # Per-section sort key + order (persisted to localStorage)
│ │ │ ├── theme.ts # Dark/light mode (persisted, respects prefers-color-scheme)
│ │ │ └── toast.ts # Notification queue (success, error, info)
│ │ │
│ │ └── utils/ # Pure helper functions
│ │ ├── format.ts # formatDate, formatFileSize, formatDuration
│ │ ├── dsl.ts # Filter DSL builder: UI state → query string
│ │ ├── pwa.ts # PWA reset, cache clear, update prompt
│ │ └── keyboard.ts # Keyboard shortcut helpers (Ctrl+A, Escape, etc.)
│ │
│ ├── routes/ # SvelteKit file-based routing
│ │ │
│ │ ├── +layout.svelte # Root layout: Navbar, theme wrapper, toast container
│ │ ├── +layout.ts # Root load: auth guard → redirect to /login if no token
│ │ │
│ │ ├── +page.svelte # / → redirect to /files
│ │ │
│ │ ├── login/
│ │ │ └── +page.svelte # Login form (decorative Tanabata images)
│ │ │
│ │ ├── files/
│ │ │ ├── +page.svelte # File grid: filter bar, sort, multi-select, upload
│ │ │ ├── +page.ts # Load: initial file list (cursor page)
│ │ │ ├── [id]/
│ │ │ │ ├── +page.svelte # File view: preview, metadata, tags, ACL
│ │ │ │ └── +page.ts # Load: file detail + tags
│ │ │ └── trash/
│ │ │ ├── +page.svelte # Trash: restore / permanent delete
│ │ │ └── +page.ts
│ │ │
│ │ ├── tags/
│ │ │ ├── +page.svelte # Tag list: search, sort, multi-select
│ │ │ ├── +page.ts
│ │ │ ├── new/
│ │ │ │ └── +page.svelte # Create tag form
│ │ │ └── [id]/
│ │ │ ├── +page.svelte # Tag detail: edit, category, rules, parent tags
│ │ │ └── +page.ts
│ │ │
│ │ ├── categories/
│ │ │ ├── +page.svelte # Category list
│ │ │ ├── +page.ts
│ │ │ ├── new/
│ │ │ │ └── +page.svelte
│ │ │ └── [id]/
│ │ │ ├── +page.svelte # Category detail: edit, view tags
│ │ │ └── +page.ts
│ │ │
│ │ ├── pools/
│ │ │ ├── +page.svelte # Pool list
│ │ │ ├── +page.ts
│ │ │ ├── new/
│ │ │ │ └── +page.svelte
│ │ │ └── [id]/
│ │ │ ├── +page.svelte # Pool detail: files (reorderable), filter, edit
│ │ │ └── +page.ts
│ │ │
│ │ ├── settings/
│ │ │ ├── +page.svelte # Profile: name, password, active sessions
│ │ │ └── +page.ts
│ │ │
│ │ └── admin/
│ │ ├── +layout.svelte # Admin layout: restrict to is_admin
│ │ ├── users/
│ │ │ ├── +page.svelte # User management list
│ │ │ ├── +page.ts
│ │ │ └── [id]/
│ │ │ ├── +page.svelte # User detail: role, block/unblock
│ │ │ └── +page.ts
│ │ └── audit/
│ │ ├── +page.svelte # Audit log with filters
│ │ └── +page.ts
│ │
│ └── service-worker.ts # PWA: offline cache for pinned files, app shell caching
├── static/ # Copied verbatim into the build
│ ├── manifest.webmanifest # PWA manifest
│ ├── browserconfig.xml
├── robots.txt
├── favicon.ico
│ ├── fonts/
│ │ └── Epilogue-VariableFont_wght.ttf
└── images/ # PWA icons, section icons (svg), login decorations
└── static/
├── favicon.png
├── favicon.ico
├── manifest.webmanifest # PWA manifest (name, icons, theme_color)
├── images/
├── tanabata-left.png # Login page decorations (from current design)
│ ├── tanabata-right.png
── icons/ # PWA icons (192×192, 512×512, etc.)
└── fonts/
── Epilogue-VariableFont_wght.ttf
└── src/
├── app.html # Shell HTML (PWA meta, font preload)
├── app.css # `@import 'tailwindcss'` + `@theme` custom properties
├── app.d.ts # Ambient types
├── service-worker.ts # PWA: app-shell + pinned-file offline cache
├── lib/ # Shared code ($lib alias)
── index.ts
│ │
── api/ # API client layer
│ │ ├── client.ts # fetch wrapper: bearer auth, 401 refresh+retry, error parsing,
│ │ │ # upload-with-progress (XHR), NDJSON streaming; exports `api`
│ │ ├── auth.ts # login, refresh, logout, sessions
│ │ ├── tags.ts # tag + tag-rule calls
│ │ ├── categories.ts # category calls
│ │ ├── duplicates.ts # duplicate list / dismiss / resolve
│ │ ├── schema.ts # AUTO-GENERATED from openapi.yaml (gitignored; do not edit)
│ │ └── types.ts # Friendly aliases: components['schemas'][...]
│ │
│ ├── components/
│ │ ├── layout/
│ │ │ ├── Header.svelte # Section header with sorting controls
│ │ │ ├── SelectionBar.svelte # Floating bar for multi-select actions
│ │ │ └── KeyboardHelp.svelte # Keyboard-shortcut overlay
│ │ │
│ │ ├── file/
│ │ │ ├── FileCard.svelte # Single thumbnail (160×160, selectable)
│ │ │ ├── Thumb.svelte # Lazy-loaded thumbnail (IntersectionObserver)
│ │ │ ├── FileViewer.svelte # Full-screen viewer with prev/next
│ │ │ ├── FileUpload.svelte # Upload form + drag-and-drop
│ │ │ ├── FilterBar.svelte # DSL filter builder UI
│ │ │ ├── MetadataEditor.svelte # Notes / datetime / metadata (nested) editor
│ │ │ ├── TagPicker.svelte # Searchable tag selector (add/remove)
│ │ │ ├── PoolPicker.svelte # Add-to-pool dialog
│ │ │ ├── BulkTagEditor.svelte # Multi-select tag add/remove
│ │ │ └── DuplicateMergeDialog.svelte # Field-by-field duplicate resolution
│ │ │
│ │ ├── tag/
│ │ │ ├── TagBadge.svelte # Colored pill
│ │ │ └── TagRuleEditor.svelte # Auto-tag rule management
│ │ │
│ │ └── common/
│ │ ├── ConfirmDialog.svelte
│ │ └── InfiniteScroll.svelte # Below-the-fold lazy loading on scroll
│ │
│ ├── stores/ # Svelte stores (global state)
│ │ ├── auth.ts # Current user + JWT tokens (persisted, cross-tab sync)
│ │ ├── selection.ts # Selected item IDs, selection mode
│ │ ├── sorting.ts # Per-section sort key + order (persisted)
│ │ ├── theme.ts # Dark/light theme (persisted)
│ │ ├── appSettings.ts # Misc client-side settings
│ │ ├── listScroll.ts # Restore list scroll position after overlay/back
│ │ └── sectionCache.ts # Cached list snapshots, invalidated on mutation
│ │
│ └── utils/ # Pure helpers
│ ├── dsl.ts # Filter DSL builder: UI state → query string
│ ├── metadata.ts # Nested metadata <-> editor rows
│ ├── pwa.ts # PWA reset / update prompt
│ └── rovingGrid.svelte.ts # Roving-tabindex keyboard grid navigation
└── routes/ # SvelteKit file-based routing (guards only in load)
├── +layout.svelte # Root layout: nav, theme
├── +layout.ts # ssr=false; root auth guard
├── +page.svelte / +page.ts # / → redirect to /files
├── login/+page.svelte
├── files/
│ ├── +page.svelte / +page.ts # Grid: filter, sort, multi-select, upload
│ ├── [id]/+page.svelte # File view (also opened as shallow-routing overlay)
│ ├── duplicates/+page.svelte # Duplicate clusters
│ └── trash/+page.svelte # Trash: restore / permanent delete
├── tags/ { +page.svelte, new/, [id]/ }
├── categories/ { +page.svelte, new/, [id]/ }
├── pools/ { +page.svelte, new/, [id]/ }
├── settings/+page.svelte # Profile: name, password, sessions, import path
└── admin/
├── +layout.svelte / +layout.ts # Restrict to admins
├── users/{ +page.svelte, [id]/ }
└── audit/+page.svelte
```
## Key Architecture Decisions
### CSS Hybrid: Tailwind + Custom Properties
### CSS: Tailwind v4 + Custom Properties
Theme colors defined as CSS custom properties in `app.css`:
Tailwind v4 is configured **in CSS**, not in a JS config file. `app.css` imports
Tailwind and declares the theme tokens as CSS custom properties inside `@theme`;
Tailwind then generates utilities (`bg-bg-primary`, `text-text-primary`,
`font-sans`, …) from those tokens automatically.
```css
@tailwind base;
@tailwind components;
@tailwind utilities;
/* src/app.css */
@import "tailwindcss";
:root {
--color-bg-primary: #312F45;
--color-bg-secondary: #181721;
--color-bg-elevated: #111118;
--color-accent: #9592B5;
--color-accent-hover: #7D7AA4;
--color-text-primary: #f0f0f0;
--color-text-muted: #9999AD;
--color-danger: #DB6060;
--color-info: #4DC7ED;
--color-warning: #F5E872;
--color-tag-default: #444455;
}
@theme {
--color-bg-primary: #312f45;
--color-bg-secondary: #181721;
--color-bg-elevated: #111118;
--color-accent: #9592b5;
--color-accent-hover: #7d7aa4;
--color-text-primary: #f0f0f0;
--color-tag-default: #444455;
/* … info / danger / warning / success / nav tokens … */
:root[data-theme="light"] {
--color-bg-primary: #f5f5f5;
--color-bg-secondary: #ffffff;
/* ... */
--font-sans: "Epilogue", sans-serif;
}
```
Tailwind references them in `tailwind.config.ts`:
```ts
export default {
theme: {
extend: {
colors: {
bg: {
primary: 'var(--color-bg-primary)',
secondary: 'var(--color-bg-secondary)',
elevated: 'var(--color-bg-elevated)',
},
accent: {
DEFAULT: 'var(--color-accent)',
hover: 'var(--color-accent-hover)',
},
// ...
},
fontFamily: {
sans: ['Epilogue', 'sans-serif'],
},
},
},
darkMode: 'class', // controlled via data-theme attribute
};
```
Dark theme is primary; the light theme overrides the same custom properties.
Usage in components: `<div class="bg-bg-primary text-text-primary rounded-xl p-4">`.
Complex cases use scoped `<style>` inside `.svelte` files.
### API Client Pattern
`client.ts` thin wrapper around fetch:
`src/lib/api/client.ts` is a thin fetch wrapper exporting a generic `api`
object. It attaches the bearer token, transparently handles a single `401`
refresh-and-retry (deduplicating concurrent refreshes and syncing rotated
tokens across tabs), parses `{ code, message, details }` errors into `ApiError`,
and invalidates cached list snapshots on mutation. It also provides
`uploadWithProgress` (XHR, for upload progress) and `postStream` (NDJSON, for
the live import progress).
```ts
// $lib/api/client.ts
import { authStore } from '$lib/stores/auth';
const BASE = '/api/v1';
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const token = get(authStore).accessToken;
const res = await fetch(BASE + path, {
...init,
headers: {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
...init?.headers,
},
});
if (res.status === 401) {
// attempt refresh, retry once
}
if (!res.ok) {
const err = await res.json();
throw new ApiError(res.status, err.code, err.message, err.details);
}
if (res.status === 204) return undefined as T;
return res.json();
}
// $lib/api/client.ts (shape)
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'POST', body: JSON.stringify(body) }),
patch: <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
put: <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
upload: <T>(path: string, formData: FormData) =>
request<T>(path, { method: 'POST', body: formData, headers: {} }),
get: <T>(path) => request<T>(path),
post: <T>(path, body?) =>
request<T>(path, { method: "POST", body: JSON.stringify(body) }),
patch: <T>(path, body?) =>
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
put: <T>(path, body?) =>
request<T>(path, { method: "PUT", body: JSON.stringify(body) }),
delete: <T>(path) => request<T>(path, { method: "DELETE" }),
upload: <T>(path, fd) => request<T>(path, { method: "POST", body: fd }),
};
```
Domain-specific modules use it:
```ts
// $lib/api/files.ts
import { api } from './client';
import type { File, FileCursorPage } from './types';
export function listFiles(params: Record<string, string>) {
const qs = new URLSearchParams(params).toString();
return api.get<FileCursorPage>(`/files?${qs}`);
}
export function uploadFile(formData: FormData) {
return api.upload<File>('/files', formData);
}
```
Resource modules (`auth.ts`, `tags.ts`, `categories.ts`, `duplicates.ts`) wrap
`api` with typed helpers. Endpoints without a dedicated module (files, pools,
users, acl, audit) are called through `api.*` directly from their route
components.
### Type Generation
Script in `package.json`:
```json
// package.json
{
"scripts": {
"generate:types": "openapi-typescript ../openapi.yaml -o src/lib/api/schema.ts",
"dev": "npm run generate:types && vite dev",
"build": "npm run generate:types && vite build"
}
"scripts": {
"generate:types": "openapi-typescript ../openapi.yaml -o src/lib/api/schema.ts",
"dev": "npm run generate:types && vite dev",
"build": "npm run generate:types && vite build"
}
}
```
Friendly aliases in `types.ts`:
`schema.ts` is generated (and gitignored) — never edit it by hand. `types.ts`
re-exports friendly aliases:
```ts
import type { components } from './schema';
export type File = components['schemas']['File'];
export type Tag = components['schemas']['Tag'];
export type Category = components['schemas']['Category'];
export type Pool = components['schemas']['Pool'];
export type FileCursorPage = components['schemas']['FileCursorPage'];
export type TagOffsetPage = components['schemas']['TagOffsetPage'];
export type Error = components['schemas']['Error'];
// ...
import type { components } from "./schema";
export type File = components["schemas"]["File"];
export type Tag = components["schemas"]["Tag"];
// …
```
### SPA Mode
`svelte.config.js`:
### SPA Mode (build)
```js
import adapter from '@sveltejs/adapter-static';
export default {
kit: {
adapter: adapter({ fallback: 'index.html' }),
// SPA: all routes handled client-side
},
};
// svelte.config.js
import adapter from "@sveltejs/adapter-static";
export default { kit: { adapter: adapter({ fallback: "index.html" }) } };
```
The Go backend serves `index.html` for all non-API routes (SPA fallback).
In development, Vite dev server proxies `/api` to the Go backend.
The Go backend serves `index.html` for all non-API routes (SPA fallback, see
`handler/static.go`). In development the Vite dev server serves the UI and the
mock plugin (or a proxied Go backend) answers `/api`.
### PWA
`service-worker.ts` handles:
- App shell caching (HTML, CSS, JS, fonts)
- User-pinned file caching (explicit, via UI button)
- Cache versioning and cleanup on update
- Reset function (clear all caches except pinned files)
`service-worker.ts` handles app-shell caching (HTML/CSS/JS/fonts) and optional
user-pinned file caching for offline viewing; `utils/pwa.ts` exposes the reset /
update flow (clear caches and reload from the server, keeping pinned files).
+168 -156
View File
@@ -1,18 +1,28 @@
# Tanabata File Manager — Go Project Structure
> Backend counterpart of [ARCHITECTURE.md](ARCHITECTURE.md). This document
> details the Go layout, the layer rules, and the key backend decisions.
## Stack
- **Router**: Gin
- **Database**: pgx v5 (pgxpool)
- **Migrations**: goose v3 + go:embed (auto-migrate on startup)
- **Auth**: JWT (golang-jwt/jwt/v5)
- **Config**: environment variables via .env (joho/godotenv)
- **Logging**: slog (stdlib, Go 1.21+)
- **Validation**: go-playground/validator/v10
- **EXIF**: rwcarlsen/goexif or dsoprea/go-exif
- **Image processing**: disintegration/imaging (thumbnails, previews)
- **Migrations**: goose v3 + `go:embed` (auto-applied on startup)
- **Auth**: JWT (golang-jwt/jwt/v5), Bearer access tokens + rotating refresh tokens
- **Config**: environment variables via `.env` (joho/godotenv)
- **Logging**: slog (stdlib)
- **Metadata**: exiftool (external, preferred) with a pure-Go EXIF fallback
(rwcarlsen/goexif)
- **Thumbnails / previews**: vipsthumbnail (external, shrink-on-load) and ffmpeg
(video frames), with a pure-Go fallback (disintegration/imaging)
- **Near-duplicate detection**: 64-bit dHash perceptual hashing + a BK-tree /
Hamming-distance pairing (`internal/imagehash`, `internal/service/duplicate_*`)
- **Architecture**: Clean Architecture (domain → service → repository/handler)
The binary is fully static (`CGO_ENABLED=0`). External tools are invoked as
subprocesses when present and are optional — the pure-Go paths keep the server
working without them.
## Monorepo Layout
```
@@ -31,81 +41,98 @@ tanabata/
```
backend/
├── cmd/
── server/
└── main.go # Entrypoint: config → DB → migrate → wire → run
── server/
└── main.go # Entrypoint: config → DB → migrate → bootstrap admin → wire → serve
│ └── dedup/
│ └── main.go # Offline maintenance CLI: perceptual-hash backfill + duplicate-pairs rescan
├── internal/
│ │
│ ├── domain/ # Pure business entities & value objects
│ │ ├── file.go # File, FileFilter, FilePage
│ ├── domain/ # Pure business entities & value objects (stdlib only)
│ │ ├── file.go # File, FileFilter, FileListParams, FilePage
│ │ ├── tag.go # Tag, TagRule
│ │ ├── category.go # Category
│ │ ├── pool.go # Pool, PoolFile
│ │ ├── user.go # User, Session
│ │ ├── acl.go # Permission, ObjectType
│ │ ├── audit.go # AuditEntry, ActionType
│ │ ── errors.go # Domain error types (ErrNotFound, ErrForbidden, etc.)
│ │ ── duplicate.go # DuplicatePair, PHashEntry
│ │ ├── context.go # WithUser / UserFromContext (identity + session in ctx)
│ │ └── errors.go # Domain error types (ErrNotFound, ErrForbidden, …)
│ │
│ ├── port/ # Interfaces (ports) — contracts between layers
│ │ ├── repository.go # FileRepo, TagRepo, CategoryRepo, PoolRepo,
│ │ │ # UserRepo, SessionRepo, ACLRepo, AuditRepo,
│ │ │ # MimeRepo, TagRuleRepo
│ │ └── storage.go # FileStorage interface (disk operations)
│ │ ├── repository.go # Transactor, FileRepo, TagRepo, TagRuleRepo, CategoryRepo,
│ │ │ # PoolRepo, UserRepo, SessionRepo, ACLRepo, AuditRepo,
│ │ │ # MimeRepo, DuplicatePairRepo, DismissalRepo
│ │ └── storage.go # FileStorage (originals + thumbnail/preview cache)
│ │
│ ├── service/ # Business logic (use cases)
│ │ ├── file_service.go # Upload, update, delete, trash/restore, replace,
│ │ │ # import, filter/list, duplicate detection
│ │ ├── tag_service.go # CRUD + auto-tag application logic
│ │ ├── category_service.go # CRUD (thin, delegates to repo + ACL + audit)
│ │ ├── file_service.go # Upload, update, delete, trash/restore, replace, import, filter/list
│ │ ├── tag_service.go # CRUD + auto-tag (rule) application
│ │ ├── category_service.go # CRUD (thin: repo + ACL + audit)
│ │ ├── pool_service.go # CRUD + file ordering, add/remove files
│ │ ├── auth_service.go # Login, logout, JWT issue/refresh, session management
│ │ ├── auth_service.go # Login, logout, JWT issue/refresh, content tokens, sessions
│ │ ├── acl_service.go # Permission checks, grant/revoke
│ │ ├── audit_service.go # Log actions, query audit log
│ │ ── user_service.go # Profile update, admin CRUD, block/unblock
│ │ ── user_service.go # Profile update, admin CRUD, block/unblock, EnsureAdmin
│ │ ├── duplicate_service.go # Cluster / resolve (merge) / dismiss + rescan orchestration
│ │ ├── duplicate_index.go # BK-tree, Hamming pairing, connected-component clustering
│ │ └── metadata.go # EXIF / media metadata extraction (exiftool + pure-Go fallback)
│ │
│ ├── handler/ # HTTP layer (Gin handlers)
│ │ ├── router.go # Route registration, middleware wiring
│ │ ├── middleware.go # Auth middleware (JWT extraction → context)
│ │ ├── request.go # Common request parsing helpers
│ │ ├── response.go # Error/success response builders
│ │ ├── router.go # Route registration, middleware, security headers, SPA fallback
│ │ ├── middleware.go # Auth middleware (JWT / content token → context)
│ │ ├── ratelimit.go # Per-IP token-bucket limiter for /auth
│ │ ├── response.go # Error/success builders, domain-error → HTTP mapping
│ │ ├── static.go # Built SPA serving + index.html fallback
│ │ ├── file_handler.go # /files endpoints
│ │ ├── tag_handler.go # /tags endpoints
│ │ ├── duplicate_handler.go # /files/duplicates endpoints
│ │ ├── tag_handler.go # /tags endpoints (+ filetag relations)
│ │ ├── category_handler.go # /categories endpoints
│ │ ├── pool_handler.go # /pools endpoints
│ │ ├── auth_handler.go # /auth endpoints
│ │ ├── acl_handler.go # /acl endpoints
│ │ ├── user_handler.go # /users endpoints
│ │ └── audit_handler.go # /audit endpoints
│ │ └── audit_handler.go # /audit endpoint
│ │
│ ├── db/ # Database adapters
│ │ ├── db.go # Common helpers: pagination, repo factory, transactor base
│ │ ├── db.go # Shared helpers: Querier, tx-from-context, ScanRow, limit/offset clamps
│ │ └── postgres/ # PostgreSQL implementation
│ │ ├── postgres.go # pgxpool init, tx-from-context helpers
│ │ ├── file_repo.go # FileRepo implementation
│ │ ├── tag_repo.go # TagRepo + TagRuleRepo implementation
│ │ ├── category_repo.go # CategoryRepo implementation
│ │ ├── pool_repo.go # PoolRepo implementation
│ │ ├── user_repo.go # UserRepo implementation
│ │ ├── session_repo.go # SessionRepo implementation
│ │ ├── acl_repo.go # ACLRepo implementation
│ │ ├── audit_repo.go # AuditRepo implementation
│ │ ├── mime_repo.go # MimeRepo implementation
│ │ ── filter_parser.go # DSL → SQL WHERE clause builder
│ │ ├── postgres.go # pgxpool init, Transactor, conn-or-tx helper
│ │ ├── file_repo.go # FileRepo (incl. perceptual-hash projections)
│ │ ├── tag_repo.go # TagRepo + TagRuleRepo
│ │ ├── category_repo.go # CategoryRepo
│ │ ├── pool_repo.go # PoolRepo
│ │ ├── user_repo.go # UserRepo
│ │ ├── session_repo.go # SessionRepo
│ │ ├── acl_repo.go # ACLRepo
│ │ ├── audit_repo.go # AuditRepo
│ │ ├── mime_repo.go # MimeRepo
│ │ ── duplicate_repo.go # DuplicatePairRepo + DismissalRepo
│ │ └── filter_parser.go # Filter DSL → SQL WHERE clause builder
│ │
│ ├── storage/ # File storage adapter
│ │ └── disk.go # FileStorage implementation (read/write/delete on disk)
│ │ └── disk.go # FileStorage on disk: originals + thumbnail/preview cache
│ │ # (vipsthumbnail / ffmpeg / pure-Go imaging)
│ │
│ ├── imagehash/ # Perceptual hashing (64-bit dHash) for near-duplicate detection
│ │ └── imagehash.go
│ │
│ ├── integration/ # End-to-end HTTP tests against a disposable Postgres
│ │ └── server_test.go
│ │
│ └── config/ # Configuration
│ └── config.go # Struct + loader from env vars
│ └── config.go # Config struct + loader from env vars
├── migrations/ # SQL migration files (goose format)
├── migrations/ # SQL migration files (goose format), embedded via go:embed
│ ├── 001_init_schemas.sql
│ ├── 002_core_tables.sql
│ ├── 003_data_tables.sql
│ ├── 004_acl_tables.sql
│ ├── 005_activity_tables.sql
│ ├── 006_indexes.sql
── 007_seed_data.sql
── 007_seed_data.sql
│ └── embed.go # //go:embed *.sql → migrations.FS
├── go.mod
└── go.sum
@@ -126,6 +153,7 @@ handler → service → port (interfaces) ← db/postgres / storage
- **db/postgres/**: imports domain/, port/, and db/ (common helpers). Implements port interfaces.
- **db/**: imports domain/ and port/. Shared utilities for all DB adapters.
- **storage/**: imports domain/ and port/. Implements FileStorage.
- **imagehash/**: leaf package (stdlib + image libs); used by service/ and storage/.
No layer may import a layer above it. No circular dependencies.
@@ -133,138 +161,126 @@ No layer may import a layer above it. No circular dependencies.
### Dependency Injection (Wiring)
Manual wiring in `cmd/server/main.go`. No DI frameworks.
Manual wiring in `cmd/server/main.go`. No DI frameworks. Constructors take their
collaborators explicitly; the shape below matches the real signatures.
```go
// Pseudocode
pool := postgres.NewPool(cfg.DatabaseURL)
goose.Up(pool, migrations)
// Repos (all from internal/db/postgres/)
fileRepo := postgres.NewFileRepo(pool)
tagRepo := postgres.NewTagRepo(pool)
// ...
// Pseudocode — see cmd/server/main.go for the exact calls.
pool := postgres.NewPool(ctx, cfg.DatabaseURL)
goose.Up(stdlib.OpenDBFromPool(pool), ".") // migrations.FS embedded
// Storage
diskStore := storage.NewDiskStorage(cfg.FilesPath)
diskStorage := storage.NewDiskStorage(
cfg.FilesPath, cfg.ThumbsCachePath,
cfg.ThumbWidth, cfg.ThumbHeight, cfg.PreviewWidth, cfg.PreviewHeight,
cfg.ThumbMaxPixels, cfg.ThumbConcurrency,
)
// Repos (all from internal/db/postgres/)
fileRepo := postgres.NewFileRepo(pool)
// … tag, tagRule, category, pool, user, session, acl, audit, mime,
// duplicatePair, dismissal repos + transactor
// Services
aclSvc := service.NewACLService(aclRepo, objectTypeRepo)
auditSvc := service.NewAuditService(auditRepo, actionTypeRepo)
fileSvc := service.NewFileService(fileRepo, mimeRepo, tagRepo, diskStore, aclSvc, auditSvc)
tagSvc := service.NewTagService(tagRepo, tagRuleRepo, aclSvc, auditSvc)
// ...
authSvc := service.NewAuthService(userRepo, sessionRepo,
cfg.JWTSecret, cfg.JWTAccessTTL, cfg.JWTRefreshTTL, cfg.ContentTokenTTL)
aclSvc := service.NewACLService(aclRepo, fileRepo, tagRepo, categoryRepo, poolRepo, transactor)
auditSvc := service.NewAuditService(auditRepo)
tagSvc := service.NewTagService(tagRepo, tagRuleRepo, aclSvc, auditSvc, transactor)
dupSvc := service.NewDuplicateService(fileRepo, duplicatePairRepo, dismissalRepo,
aclSvc, auditSvc, transactor, cfg.DuplicateHashThreshold)
fileSvc := service.NewFileService(fileRepo, mimeRepo, diskStorage,
aclSvc, auditSvc, tagSvc, transactor, cfg.ImportPath)
// … category, pool, user services
// Handlers
fileHandler := handler.NewFileHandler(fileSvc, tagSvc)
// ...
// Bootstrap the initial admin from env (idempotent).
userSvc.EnsureAdmin(ctx, cfg.AdminUsername, cfg.AdminPassword)
router := handler.NewRouter(cfg, fileHandler, tagHandler, ...)
router.Run(cfg.ListenAddr)
// Handlers → router (also wires trusted proxies + optional static SPA dir)
router, _ := handler.NewRouter(authMiddleware, authHandler, fileHandler,
duplicateHandler, tagHandler, categoryHandler, poolHandler,
userHandler, aclHandler, auditHandler, cfg.StaticDir, cfg.TrustedProxies)
srv.ListenAndServe()
```
### Context Propagation
Every service method receives `context.Context` as the first argument.
The handler extracts user info from JWT (via middleware) and puts it
into context. Services read the current user from context for ACL checks
and audit logging.
The auth middleware parses the JWT and puts the caller's identity (user id,
admin flag, session id) into the context. Services read it for ACL checks and
audit logging.
```go
// middleware.go
func (m *AuthMiddleware) Handle(c *gin.Context) {
claims := parseJWT(c.GetHeader("Authorization"))
ctx := domain.WithUser(c.Request.Context(), claims.UserID, claims.IsAdmin)
c.Request = c.Request.WithContext(ctx)
c.Next()
}
// handler/middleware.go
claims := parseJWT(c.GetHeader("Authorization"))
ctx := domain.WithUser(c.Request.Context(), claims.UserID, claims.IsAdmin, claims.SessionID)
c.Request = c.Request.WithContext(ctx)
// domain/context.go
type ctxKey int
const userKey ctxKey = iota
func WithUser(ctx context.Context, userID int16, isAdmin bool) context.Context { ... }
func UserFromContext(ctx context.Context) (userID int16, isAdmin bool) { ... }
func WithUser(ctx context.Context, userID int16, isAdmin bool, sessionID int) context.Context
func UserFromContext(ctx context.Context) (userID int16, isAdmin bool, sessionID int)
```
### Transaction Management
Repository interfaces include a `Transactor`:
The `Transactor` port lets services compose multiple repo calls atomically.
The postgres implementation stores the active `pgx.Tx` in the context; repo
methods pick it up via a conn-or-tx helper, so the same method works inside or
outside a transaction.
```go
// port/repository.go
type Transactor interface {
WithTx(ctx context.Context, fn func(ctx context.Context) error) error
}
```
The postgres implementation wraps `pgxpool.Pool.BeginTx`. Inside `fn`,
all repo calls use the transaction from context. This allows services
to compose multiple repo calls in a single transaction:
```go
// service/file_service.go
func (s *FileService) Upload(ctx context.Context, input UploadInput) (*domain.File, error) {
// service/file_service.go (sketch)
func (s *FileService) Upload(ctx context.Context, p UploadParams) (*domain.File, error) {
return s.tx.WithTx(ctx, func(ctx context.Context) error {
file, err := s.fileRepo.Create(ctx, ...) // uses tx
if err != nil { return err }
for _, tagID := range input.TagIDs {
s.tagRepo.AddFileTag(ctx, file.ID, tagID) // same tx
}
s.auditRepo.Log(ctx, ...) // same tx
return nil
created, err := s.files.Create(ctx, f) // uses tx from ctx
// apply initial tags, etc., in the same tx
return err
})
}
```
### ACL Check Pattern
ACL logic is centralized in `ACLService`. Other services call it before
any data mutation or retrieval:
ACL logic is centralized in `ACLService`. Other services call it before any
mutation or retrieval. The model is private-by-default: admins see everything;
otherwise a `public` flag, creator ownership, or an explicit `acl.permissions`
grant is required.
```go
// service/acl_service.go
func (s *ACLService) CanView(ctx context.Context, objectType string, objectID uuid.UUID) error {
userID, isAdmin := domain.UserFromContext(ctx)
if isAdmin { return nil }
// Check is_public on the object
// If not public, check creator_id == userID
// If not creator, check acl.permissions
// Return domain.ErrForbidden if none match
}
// service/acl_service.go (shape)
func (s *ACLService) CanView(ctx context.Context, userID int16, isAdmin bool,
creatorID int16, isPublic bool, objectType int16, objectID uuid.UUID) (bool, error)
func (s *ACLService) CanEdit(ctx context.Context, userID int16, isAdmin bool,
creatorID int16, objectType int16, objectID uuid.UUID) (bool, error)
```
### Error Mapping
Domain errors → HTTP status codes (handled in handler/response.go):
| Domain Error | HTTP Status | Error Code |
|-----------------------|-------------|-------------------|
| ErrNotFound | 404 | not_found |
| ErrForbidden | 403 | forbidden |
| ErrUnauthorized | 401 | unauthorized |
| ErrConflict | 409 | conflict |
| ErrValidation | 400 | validation_error |
| ErrUnsupportedMIME | 415 | unsupported_mime |
| (unexpected) | 500 | internal_error |
| Domain Error | HTTP Status | Error Code |
| ------------------ | ----------- | ---------------- |
| ErrNotFound | 404 | not_found |
| ErrForbidden | 403 | forbidden |
| ErrUnauthorized | 401 | unauthorized |
| ErrConflict | 409 | conflict |
| ErrValidation | 400 | validation_error |
| ErrUnsupportedMIME | 415 | unsupported_mime |
| (unexpected) | 500 | internal_error |
### Filter DSL
The DSL parser lives in `db/postgres/filter_parser.go` because it produces
SQL WHERE clauses — it is a PostgreSQL-specific adapter concern.
The service layer passes the raw DSL string to the repository; the
repository parses it and builds the query.
The DSL parser lives in `db/postgres/filter_parser.go` because it produces SQL
WHERE clauses — a PostgreSQL-specific adapter concern. The service layer passes
the raw DSL string down; the repository parses it and builds the query. For a
different DBMS, a corresponding parser would live in `db/<dbms>/filter_parser.go`.
For a different DBMS, a corresponding parser would live in
`db/<dbms>/filter_parser.go`.
The interface:
```go
// port/repository.go
type FileRepo interface {
List(ctx context.Context, params FileListParams) (*domain.FilePage, error)
// ...
}
// domain/file.go
type FileListParams struct {
Filter string // raw DSL string
@@ -279,42 +295,38 @@ type FileListParams struct {
}
```
The DSL grammar itself is documented in `openapi.yaml` (the `filter` query
parameter), so the contract stays in one place.
### JWT Structure
```go
type Claims struct {
jwt.RegisteredClaims
UserID int16 `json:"uid"`
IsAdmin bool `json:"adm"`
SessionID int `json:"sid"`
UserID int16 `json:"uid"`
IsAdmin bool `json:"adm"`
SessionID int `json:"sid"`
}
```
Access token: short-lived (15 min). Refresh token: long-lived (30 days),
stored as hash in `activity.sessions.token_hash`.
Access token: short-lived (15 min default). Refresh token: long-lived (30 days
default), rotated on use, stored as a hash in `activity.sessions`. A separate
**content token** (default 6 h) is a single-file capability minted for media
URLs, so a long video keeps streaming past access-token expiry — see
`CONTENT_TOKEN_TTL` in `.env.example`.
### Perceptual Duplicate Detection
Images are dHash-ed inline on upload (`internal/imagehash`); video hashes are
backfilled by the `dedup` CLI (ffmpeg stays off the upload path). A rescan
rebuilds `data.duplicate_pairs` by inserting every pair within
`DUPLICATE_HASH_THRESHOLD` Hamming distance (BK-tree lookups, not O(N²)); the
duplicates API then groups pairs into connected-component clusters. See
`service/duplicate_service.go` and `service/duplicate_index.go`.
### Configuration (.env)
```env
# Server
LISTEN_ADDR=:42776
JWT_SECRET=<random-32-bytes>
JWT_ACCESS_TTL=15m
JWT_REFRESH_TTL=720h
# Database
DATABASE_URL=postgres://user:pass@host:5432/tanabata?sslmode=disable
# Storage
FILES_PATH=/data/files
THUMBS_CACHE_PATH=/data/thumbs
# Thumbnails
THUMB_WIDTH=160
THUMB_HEIGHT=160
PREVIEW_WIDTH=1920
PREVIEW_HEIGHT=1080
# Import
IMPORT_PATH=/data/import
```
Every variable the server reads is documented in `.env.example` (1:1 with
`config.Config`). Required at startup: `JWT_SECRET`, `ADMIN_PASSWORD`,
`DATABASE_URL`, `FILES_PATH`, `THUMBS_CACHE_PATH`, `IMPORT_PATH`. Everything
else has a sensible default (see `config.go`).
+179
View File
@@ -0,0 +1,179 @@
# Tanabata File Manager — Requirements
> Product requirements for Tanabata File Manager (TFM). Architecture and code
> layout are described separately in [ARCHITECTURE.md](ARCHITECTURE.md),
> [GO_PROJECT_STRUCTURE.md](GO_PROJECT_STRUCTURE.md) and
> [FRONTEND_STRUCTURE.md](FRONTEND_STRUCTURE.md).
## Overview
Tanabata File Manager (TFM) is a multi-user, tag-based web file manager. It runs
on a clientserver architecture and is operated entirely through a web
interface. Its goal is centralized, server-side storage of files with access and
management from both desktop and mobile browsers. The application is primarily
oriented toward **images and video**.
The app is a PWA that can be installed on a desktop or a phone. Files managed by
Tanabata are stored flat in a single directory; each file's on-disk name equals
its UUID in the database.
Support for additional database engines is planned for future versions.
## Core Concepts
- **File** — a single file on the server. It may carry any number of tags and
belong to any number of pools. It has a creator and optional access settings
(a user — which may be null, making the file public — plus read and edit
permission flags). It has an original name and metadata (keyvalue, including
all EXIF data).
- **Tag** — a label on a file. It may be attached to any number of files and
belong to at most one category. It has a name, a description, keyvalue
metadata, and may define auto-tag rules.
- **Auto-tag (tag rule)** — a rule stating that when tag A is attached to a file,
tag B is attached to the same file automatically.
- **Category** — an entity that logically groups several tags. It has a name, a
description, and keyvalue metadata.
- **Pool** — a logical grouping of files. It has a name, a description, and
keyvalue metadata. Files in a pool can be sorted automatically or arranged in
a user-defined manual order.
## Functional Requirements
### 1. File management
1. Browse the file list (lazy load, pagination).
2. Filter files by tags and metadata.
3. View and edit sort settings (persisted per user).
4. Multi-select files (Ctrl, Shift) and act on the selection:
1. Attach / detach tags.
2. Copy / paste tags.
3. Add to a pool.
4. View and edit access settings.
5. Delete (with a confirmation prompt).
5. View a single file.
6. Single-file actions:
1. Attach / detach tags.
2. Copy / paste tags.
3. Add to a pool.
4. View and edit access settings.
5. Replace the file (upload new content under the same ID).
6. Delete (with a confirmation prompt).
7. Browse files gallery-style (prev/next paging through the viewer).
8. Upload new files through the web UI (form or drag-and-drop onto the list).
9. Import new files from a folder on the server.
10. Near-duplicate detection for images and video:
1. Show groups (clusters) of duplicates.
2. Dismiss false duplicates (the app remembers that file A is _not_ a
duplicate of file B).
3. Choose which duplicate to keep and which to delete.
4. Choose, per field, which duplicate the surviving file inherits it from.
11. Trash:
1. Browse trashed files.
2. Restore from trash.
3. Delete permanently.
### 2. Tag management
1. Browse the tag list (lazy load, pagination).
2. Search by name.
3. View and edit sort settings (persisted per user).
4. Multi-select tags (Ctrl, Shift) and act on the selection:
1. Assign auto-tag rules.
2. Change category.
3. Delete (with a confirmation prompt).
5. View a single tag.
6. Single-tag actions:
1. Edit name, description, and metadata (keyvalue).
2. Change category.
3. Assign auto-tag rules.
4. Delete (with a confirmation prompt).
7. Create a tag:
1. Enter name, description, and metadata (keyvalue).
2. Assign a category (optional).
3. Assign auto-tag rules.
### 3. Category management
1. Browse the category list (lazy load, pagination).
2. Search by name.
3. View and edit sort settings (persisted per user).
4. Multi-select categories (Ctrl, Shift) and act on the selection:
1. View shared tags and tags attached to some (but not all) of them.
2. Attach / detach tags.
3. Delete (with a confirmation prompt).
5. View a single category.
6. Single-category actions:
1. Edit name, description, and metadata (keyvalue).
2. View attached tags.
3. Attach / detach tags.
4. Delete (with a confirmation prompt).
7. Create a category:
1. Enter name, description, and metadata (keyvalue).
2. Attach tags.
### 4. Pool management
1. Browse the pool list (lazy load, pagination).
2. Search by name.
3. View and edit sort settings (persisted per user).
4. Multi-select pools (Ctrl, Shift) and act on the selection:
1. View and edit access settings.
2. Delete (with a confirmation prompt).
5. View a single pool.
6. Single-pool actions:
1. Edit name, description, and metadata (keyvalue).
2. View and edit access settings.
3. View all files in the pool.
4. Filter the pool's files by tags.
5. Change the file sort setting (including disabling automatic sorting).
6. Reorder files manually (when automatic sorting is disabled).
7. Delete (with a confirmation prompt).
7. Create a pool:
1. Enter name, description, and metadata (keyvalue).
2. Attach files.
### 5. User settings
1. Username.
2. Password.
3. Sessions:
1. Terminate a session.
4. Path to the server folder scanned during file import.
### 6. Server administration (admin panel)
1. Users:
1. Browse the list.
2. View a single user.
3. Create.
4. Delete.
5. Block / unblock.
6. Set role (reader / editor).
### 7. Audit logging (in the database)
Log the following user actions:
1. File views.
2. Changes to file access settings.
3. Create / edit / delete of a file, tag, category, pool, or filetag relation.
4. Create / block / unblock / delete of a user.
5. User role changes.
6. User login / logout.
7. Session termination.
## Non-Functional Requirements
1. The interface must be as simple and convenient as possible: everything needed
should be at hand, reachable in the fewest possible actions.
2. The interface must adapt to both desktop and mobile devices.
3. The interface must offer dark and light themes.
4. Use PWA technology, including a button that fully resets the PWA (except the
cache) and reloads it from the server.
5. Allow selected files to be cached and viewed offline in the installed PWA.
6. First-run setup must require minimal effort: automatic database migration, a
ready-made Docker Compose file, and a `.env` file with the configurable
installation parameters.
7. Use a Domain-Driven Design approach on the API server.
8. Reject files whose MIME type is not present in the database (no DB entry — no
support).
-148
View File
@@ -1,148 +0,0 @@
## О проекте
Tanabata File Manager или сокращенно TFM — многопользовательский веб-файловый менеджер, организующий файлы по тегам. Работает на клиент-серверной архитектуре, управляется через веб-интерфейс. Главная цель проекта — обеспечить централизованное хранение файлов на сервере, доступ к ним и управление ими через веб как с компьютера, так и со смартфона. В первую очередь данное приложение ориентировано на изображения и видео.
## Общая архитектура
- File storage
- Relational database (PostgreSQL)
- REST API service (Go)
- Frontend (SvelteKit)
Приложение предполагается разворачивать внутри контейнера Docker. Фронтенд и бэкенд - в одном контейнере, СУБД - отдельно (на моем сервере планируется подключать к СУБД на хосте). Все файлы, управляемые Танабатой, будут храниться кучей в одной папке. Имя файла на диске совпадает с его UUID в БД.
Приложение является PWA, которое можно установить на компьютер или смартфон.
В будущих версиях планируется введение поддержки других СУБД.
## Основные понятия
**Файл** — один файл на сервере. Может иметь сколько угодно тегов, может принадлежать скольким угодно пулам. Имеет автора, а также может иметь настройки доступа (пользователь (может быть null - таким образом можно делать файл публичным), флаг права на чтение, флаг права на изменение). Имеет оригинальное название и метаданные (ключ-значение, в том числе все данные EXIF).
**Тег** — метка файла. Может быть привязан к скольким угодно файлам, может быть привязан к одной категории. Имеет название, описание, метаданные (ключ-значение). Может иметь автотеги.
**Автотег** — правило, согласно которому при привязке к файлу условного тега А к этому же файлу автоматически привязывается условный тег Б.
**Категория** — сущность, логически объединяющая собой несколько тегов. Имеет название, описание, метаданные (ключ-значение).
**Пул** — логическое объединение файлов. Имеет название, описание, метаданные (ключ-значение). Файлы внутри могут быть как отсортированы автоматически, так и расположены в порядке, заданном пользователем вручную.
## Функциональные требования
1. Управление файлами
1. Просмотр списка файлов (lazy load, pagination)
2. Фильтрация файлов по тегам и метаданным
3. Просмотр и редактирование настроек сортировки (сохраняется для каждого пользователя)
4. Выделение нескольких файлов (Ctrl, Shift) и действия с ними
1. Привязка/отвязка тегов
2. Копирование/вставка тегов
3. Добавление в пул
4. Просмотр и редактирование настроек доступа
5. Удаление (с запросом подтверждения)
5. Просмотр одного файла
6. Действия с одним файлом
1. Привязка/отвязка тегов
2. Копирование/вставка тегов
3. Добавление в пул
4. Просмотр и редактирование настроек доступа
5. Замена файла (загрузка нового под таким же ID)
6. Удаление (с запросом подтверждения)
7. Листание файлов, как в галерее
8. Загрузка новых файлов через веб-интерфейс (через форму или drag-n-drop прямо на список)
9. Импорт новых файлов из папки на сервере
10. Выявление дубликатов, в частности, изображений и видео
1. Отображение групп дубликатов
2. Возможность отвязывания фальшивых дубликатов (чтобы приложение запомнило, что изображение А не является дубликатом изображения Б)
3. Возможность выбора дубликата для удаления/сохранения
4. Возможность выбора, какие поля от какого дубликата подтягивать
11. Корзина
1. Просмотр файлов в корзине
2. Восстановление из корзины
3. Окончательное удаление
2. Управление тегами
1. Просмотр списка тегов (lazy load, pagination)
2. Поиск по названию
3. Просмотр и редактирование настроек сортировки (сохраняется для каждого пользователя)
4. Выделение нескольких тегов (Ctrl, Shift) и действия с ними
1. Назначение автотегов
2. Изменение категории
3. Удаление (с запросом подтверждения)
5. Просмотр одного тега
6. Действия с одним тегом
1. Редактирование названия, описания и метаданных (ключ-значение)
2. Изменение категории
3. Назначение автотегов
4. Удаление (с запросом подтверждения)
7. Создание тега
1. Внесение названия, описания и метаданных (ключ-значение)
2. Назначение категории (опционально)
3. Назначение автотегов
3. Управление категориями
1. Просмотр списка категорий (lazy load, pagination)
2. Поиск по названию
3. Просмотр и редактирование настроек сортировки (сохраняется для каждого пользователя)
4. Выделение нескольких категорий (Ctrl, Shift) и действия с ними
1. Просмотр привязанных общих тегов и тегов, привязанных к некоторым, но не ко всем
2. Привязка/отвязка тегов
3. Удаление (с запросом подтверждения)
5. Просмотр одной категории
6. Действия с одной категорией
1. Редактирование названия, описания и метаданных (ключ-значение)
2. Просмотр привязанных тегов
3. Привязка/отвязка тегов
4. Удаление (с запросом подтверждения)
7. Создание категории
1. Внесение названия, описания и метаданных (ключ-значение)
2. Привязка тегов
4. Управление пулами
1. Просмотр списка пулов (lazy load, pagination)
2. Поиск по названию
3. Просмотр и редактирование настроек сортировки (сохраняется для каждого пользователя)
4. Выделение нескольких пулов (Ctrl, Shift) и действия с ними
1. Просмотр и редактирование настроек доступа
2. Удаление (с запросом подтверждения)
5. Просмотр одного пула
6. Действия с одним пулом
1. Редактирование названия, описания и метаданных (ключ-значение)
2. Просмотр и редактирование настроек доступа
3. Просмотр всех файлов, входящих в пул
4. Фильтрация файлов по тегам
5. Изменение настройки сортировки файлов (в том числе можно отключить автоматическую сортировку)
6. Ручное изменение порядка файлов (при отключенной сортировке)
7. Удаление (с запросом подтверждения)
7. Создание категории
1. Внесение названия, описания и метаданных (ключ-значение)
2. Привязка тегов
5. Управление пользовательскими настройками
1. Имя пользователя
2. Пароль
3. Сессии
1. Завершение сессии
4. Путь к папке на сервере, которая будет сканироваться при импорта файлов
6. Управление настройками сервера (админка)
1. Пользователи
1. Просмотр списка
2. Просмотр одного
3. Создание
4. Удаление
5. Блокировка/разблокировка
6. Установка роли (читатель/редактор)
7. Журналирование пользовательских действий в БД
1. Просмотры файлов
2. Смены настроек доступа к файлам
3. Создание/редактирование/удаление файла, тега, категории, пула, связи файл-тег
4. Создание/блокировка/разблокировка/удаление пользователя
5. Смена роли пользователя
6. Авторизация/логаут пользователя
7. Завершение сессии
## Нефункциональные требования
1. Интерфейс должен быть максимально простым и удобным, все необходимое должно быть под рукой, доступным за минимальное количество действий
2. Интерфейс должен быть адаптирован под десктоп и под мобильные устройства
3. Интерфейс должен иметь темную и светлую темы
4. Использование технологии PWA (также должна быть кнопка, при нажатии которой PWA будет полностью сбрасываться (кроме кэша) и заново загружаться с сервера)
5. Возможность сохранять некоторые файлы в кэш и просматривать их оффлайн при использовании установленного PWA
6. При первичном запуске приложение должно требовать минимума действий: автоматическая миграция БД, заранее готовый файл docker compose, файл .env с настраиваемыми параметрами установки
7. Использование подхода DDD для сервера API
8. Не принимать файлы, чей MIME отсутствует в БД (нет в БД — нет поддержки)
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "0.0.1",
"version": "3.0.0",
"type": "module",
"scripts": {
"generate:types": "openapi-typescript ../openapi.yaml -o src/lib/api/schema.ts",
@@ -9,8 +9,8 @@
"build": "npm run generate:types && vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"check": "npm run generate:types && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "npm run generate:types && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
+11 -1
View File
@@ -1,9 +1,19 @@
import { api } from '$lib/api/client';
import type { File } from '$lib/api/types';
/** A group of mutually similar files. */
/** A stored perceptual-hash (Hamming) distance between two files of a cluster. */
export interface DuplicatePairDistance {
a: string;
b: string;
distance: number;
}
/** A group of mutually similar files, with the pairwise distances known between
* them. A file linked into the cluster only transitively may lack a direct
* distance to some others, so that pair is absent. */
export interface DuplicateCluster {
files: File[];
distances?: DuplicatePairDistance[];
}
export interface DuplicateClusterPage {
@@ -51,6 +51,14 @@
function metaCount(m: unknown): number {
return m && typeof m === 'object' ? Object.keys(m as object).length : 0;
}
function metaEntries(m: unknown): [string, unknown][] {
return m && typeof m === 'object' ? Object.entries(m as Record<string, unknown>) : [];
}
function fmtMeta(v: unknown): string {
if (v === null || v === undefined) return '—';
if (typeof v === 'object') return JSON.stringify(v);
return String(v);
}
async function submit() {
if (busy) return;
@@ -88,7 +96,12 @@
<span class="title">Merge duplicates</span>
<button class="x" onclick={onClose} aria-label="Close">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M3 3l10 10M13 3L3 13" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
<path
d="M3 3l10 10M13 3L3 13"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
/>
</svg>
</button>
</div>
@@ -120,7 +133,13 @@
</div>
<!-- Scalar fields: keep vs discard -->
{#snippet scalarRow(label: string, value: ScalarChoice, set: (v: ScalarChoice) => void, keepVal: string, otherVal: string)}
{#snippet scalarRow(
label: string,
value: ScalarChoice,
set: (v: ScalarChoice) => void,
keepVal: string,
otherVal: string
)}
<div class="row">
<span class="label">{label}</span>
<div class="seg">
@@ -171,6 +190,27 @@
</div>
</div>
<!-- Compact side-by-side view of each side's metadata -->
{#if metaCount(a.metadata) > 0 || metaCount(b.metadata) > 0}
<div class="meta-preview">
{#each [{ side: 'Keep', m: a.metadata }, { side: 'Other', m: b.metadata }] as col (col.side)}
<div class="meta-col">
<div class="meta-col-head">{col.side}</div>
{#if metaEntries(col.m).length > 0}
<dl class="meta-list">
{#each metaEntries(col.m) as [k, v]}
<dt title={k}>{k}</dt>
<dd title={fmtMeta(v)}>{fmtMeta(v)}</dd>
{/each}
</dl>
{:else}
<span class="meta-none"></span>
{/if}
</div>
{/each}
</div>
{/if}
<!-- Relations: keep vs union both -->
<div class="row">
<span class="label">Tags</span>
@@ -357,6 +397,51 @@
color: var(--color-accent);
border-color: var(--color-accent);
}
.meta-preview {
display: flex;
gap: 8px;
padding: 2px 0 4px;
}
.meta-col {
flex: 1;
min-width: 0;
background-color: var(--color-bg-elevated);
border-radius: 7px;
padding: 6px 8px;
}
.meta-col-head {
font-size: 0.66rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
margin-bottom: 4px;
}
.meta-list {
display: grid;
grid-template-columns: minmax(0, auto) minmax(0, 1fr);
gap: 2px 8px;
margin: 0;
font-size: 0.72rem;
}
.meta-list dt {
color: var(--color-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.meta-list dd {
margin: 0;
color: var(--color-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.meta-none {
font-size: 0.72rem;
color: var(--color-text-muted);
opacity: 0.6;
}
.del {
display: flex;
align-items: center;
@@ -138,6 +138,8 @@
class:focused
use:lazyload
data-file-index={index}
role="button"
tabindex="-1"
onpointerdown={onPointerDown}
onpointermove={onPointerMoveInternal}
onpointerup={() => {
@@ -5,7 +5,9 @@
import { authStore } from '$lib/stores/auth';
import TagPicker from '$lib/components/file/TagPicker.svelte';
import PoolPicker from '$lib/components/file/PoolPicker.svelte';
import MetadataEditor from '$lib/components/file/MetadataEditor.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import { type MetaNode, objectToNodes, nodesToObject } from '$lib/utils/metadata';
import type { File, Tag } from '$lib/api/types';
interface Props {
@@ -22,8 +24,14 @@
onReviewChange?: (id: string, needsReview: boolean) => void;
}
let { fileId, prevId = null, nextId = null, onNavigate, onClose, onReviewChange }: Props =
$props();
let {
fileId,
prevId = null,
nextId = null,
onNavigate,
onClose,
onReviewChange
}: Props = $props();
let file = $state<File | null>(null);
let fileTags = $state<Tag[]>([]);
@@ -55,6 +63,10 @@
let notes = $state('');
let contentDatetime = $state('');
let isPublic = $state(false);
// User-editable metadata (the API field is a free-form, possibly nested JSON
// object). Held as a node tree that MetadataEditor renders; converted to/from
// the plain object on load and save.
let metadataNodes = $state<MetaNode[]>([]);
let dirty = $state(false);
let exifEntries = $derived(
@@ -77,6 +89,18 @@
if (previewSrc) URL.revokeObjectURL(previewSrc);
});
// content_datetime is stored/returned as a UTC ISO instant, but
// <input type="datetime-local"> works in local wall-clock time with no zone.
// Shift by the local offset so the field shows local time; the save path
// (new Date(value).toISOString()) parses it back as local and re-encodes UTC.
function isoToLocalInput(iso?: string | null): string {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
const local = new Date(d.getTime() - d.getTimezoneOffset() * 60000);
return local.toISOString().slice(0, 16); // YYYY-MM-DDTHH:mm (local)
}
async function loadFile(id: string) {
loading = true;
error = '';
@@ -89,10 +113,9 @@
if (fileId !== id) return; // paged on; ignore
file = fileData;
notes = fileData.notes ?? '';
contentDatetime = fileData.content_datetime
? fileData.content_datetime.slice(0, 16) // YYYY-MM-DDTHH:mm
: '';
contentDatetime = isoToLocalInput(fileData.content_datetime);
isPublic = fileData.is_public ?? false;
metadataNodes = objectToNodes(fileData.metadata);
dirty = false;
void fetchPreview(id);
void fetchContentToken(id);
@@ -227,9 +250,11 @@
const updated = await api.patch<File>(`/files/${file.id}`, {
notes: notes.trim() || null,
content_datetime: contentDatetime ? new Date(contentDatetime).toISOString() : undefined,
is_public: isPublic
is_public: isPublic,
metadata: nodesToObject(metadataNodes)
});
file = updated;
metadataNodes = objectToNodes(updated.metadata);
dirty = false;
} catch (e) {
error = e instanceof ApiError ? e.message : 'Failed to save';
@@ -376,7 +401,9 @@
class:needs={file.needs_review}
onclick={toggleReview}
aria-label={file.needs_review ? 'Mark as reviewed' : 'Mark as needs review'}
title={file.needs_review ? 'Tagging not done — mark reviewed' : 'Reviewed — mark as needs review'}
title={file.needs_review
? 'Tagging not done — mark reviewed'
: 'Reviewed — mark as needs review'}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<circle cx="10" cy="10" r="7.5" stroke="currentColor" stroke-width="1.6" />
@@ -472,39 +499,43 @@
</div>
{/if}
<!-- Prev / Next -->
<!-- Prev / Next: the whole left/right side of the preview is a tap zone -->
{#if prevId}
<button
class="nav-btn nav-prev"
class="nav-zone nav-prev"
onclick={() => prevId && onNavigate(prevId)}
aria-label="Previous file"
>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path
d="M11 3L5 9L11 15"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<span class="nav-chip">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path
d="M11 3L5 9L11 15"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
</button>
{/if}
{#if nextId}
<button
class="nav-btn nav-next"
class="nav-zone nav-next"
onclick={() => nextId && onNavigate(nextId)}
aria-label="Next file"
>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path
d="M7 3L13 9L7 15"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<span class="nav-chip">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path
d="M7 3L13 9L7 15"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
</button>
{/if}
</div>
@@ -537,7 +568,7 @@
</section>
<section class="section">
<label class="field-label" for="datetime">Date taken</label>
<label class="field-label" for="datetime">Content date</label>
<input
id="datetime"
type="datetime-local"
@@ -564,6 +595,12 @@
</button>
</section>
<!-- Metadata (free-form, possibly nested JSON object) -->
<section class="section">
<div class="field-label">Metadata</div>
<MetadataEditor bind:nodes={metadataNodes} onchange={() => (dirty = true)} />
</section>
<button class="save-btn" onclick={save} disabled={!dirty || saving}>
{saving ? 'Saving…' : 'Save changes'}
</button>
@@ -723,15 +760,17 @@
overflow: hidden;
}
/* Whole preview area is a link: click opens the original in a new tab. */
/* The link fills the area for centring, but only the image itself is
clickable (pointer-events below) — tapping the black margins does nothing,
so "open original" fires only on the preview. */
.preview-link {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
cursor: zoom-in;
text-decoration: none;
pointer-events: none;
}
.preview-img {
@@ -739,12 +778,14 @@
max-height: 100%;
object-fit: contain;
display: block;
cursor: zoom-in;
pointer-events: auto; /* only the image opens the original */
}
.preview-busy {
position: absolute;
inset: 0;
z-index: 2;
z-index: 4; /* above the nav zones — an in-flight replace blocks paging */
display: flex;
flex-direction: column;
align-items: center;
@@ -785,33 +826,58 @@
background-color: #1a1010;
}
/* ---- Nav buttons ---- */
.nav-btn {
/* ---- Nav zones ----
Each covers the full-height left/right portion of the preview so paging
only needs a tap on that side, not a precise hit on the arrow. They sit
above the image, so over a full-width image the sides page and the centre
still opens the original. The dark hint gradient shows on hover only, to
keep photos clean on touch where there is no hover. */
.nav-zone {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 40px;
height: 40px;
border-radius: 50%;
top: 0;
bottom: 0;
width: 30%;
max-width: 220px;
border: none;
background-color: rgba(0, 0, 0, 0.55);
color: #fff;
background: none;
padding: 0 12px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background-color 0.15s;
}
.nav-btn:hover {
background-color: rgba(0, 0, 0, 0.8);
z-index: 3;
-webkit-tap-highlight-color: transparent;
}
.nav-prev {
left: 10px;
left: 0;
justify-content: flex-start;
}
.nav-next {
right: 10px;
right: 0;
justify-content: flex-end;
}
.nav-prev:hover {
background: linear-gradient(to right, rgba(0, 0, 0, 0.3), transparent);
}
.nav-next:hover {
background: linear-gradient(to left, rgba(0, 0, 0, 0.3), transparent);
}
.nav-chip {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 50%;
background-color: rgba(0, 0, 0, 0.55);
color: #fff;
transition: background-color 0.15s;
}
.nav-zone:hover .nav-chip {
background-color: rgba(0, 0, 0, 0.8);
}
/* ---- Metadata panel ---- */
@@ -1,4 +1,5 @@
<script lang="ts">
import { untrack } from 'svelte';
import type { Tag } from '$lib/api/types';
import { fetchAllTags } from '$lib/api/tags';
import { buildDslFilter, parseDslFilter, tokenLabel } from '$lib/utils/dsl';
@@ -16,7 +17,9 @@
let tags = $state<Tag[]>([]);
let search = $state('');
let tokens = $state<string[]>(parseDslFilter(value));
// Seed from the prop once; the $effect below keeps it in sync afterwards, so
// read it untracked to avoid the state-referenced-locally warning.
let tokens = $state<string[]>(untrack(() => parseDslFilter(value)));
let tagNames = $derived(
new Map(tags.filter((t) => t.id && t.name).map((t) => [t.id as string, t.name as string]))
);
@@ -0,0 +1,223 @@
<script lang="ts">
// Recursive editor for one level of the metadata object. Nested objects render
// another instance of this component (self-import), indented under their key.
import Self from './MetadataEditor.svelte';
import {
type MetaNode,
newValueNode,
newObjectNode,
nodesToObject,
objectToNodes,
parseObject
} from '$lib/utils/metadata';
interface Props {
/** Entries at this level; bound so nested edits flow back to the parent. */
nodes: MetaNode[];
/** Fired on any structural or value change (parent marks the form dirty). */
onchange: () => void;
}
let { nodes = $bindable(), onchange }: Props = $props();
function addValue() {
nodes = [...nodes, newValueNode()];
onchange();
}
function addObject() {
nodes = [...nodes, newObjectNode()];
onchange();
}
function remove(id: number) {
nodes = nodes.filter((n) => n.id !== id);
onchange();
}
// Flip a leaf to a nested object and back. Converting keeps the data where it
// can: a leaf whose text is a JSON object expands into rows; a group collapses
// back to its JSON text.
function toggleKind(node: MetaNode) {
if (node.kind === 'value') {
const obj = parseObject(node.value);
node.children = obj ? objectToNodes(obj) : [];
node.value = '';
node.kind = 'object';
} else {
node.value = node.children.length ? JSON.stringify(nodesToObject(node.children)) : '';
node.children = [];
node.kind = 'value';
}
onchange();
}
</script>
<div class="meta-editor">
{#each nodes as node (node.id)}
<div class="node">
<div class="node-head">
<input class="key" placeholder="key" bind:value={node.key} oninput={onchange} />
<button
class="kind"
class:obj={node.kind === 'object'}
onclick={() => toggleKind(node)}
title={node.kind === 'object'
? 'Nested object — click for a plain value'
: 'Plain value — click to nest an object'}
aria-label="Toggle value / nested object"
>
{node.kind === 'object' ? '{ }' : 'a'}
</button>
{#if node.kind === 'value'}
<input class="val" placeholder="value" bind:value={node.value} oninput={onchange} />
{/if}
<button
class="del"
onclick={() => remove(node.id)}
aria-label="Remove field"
title="Remove field"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path
d="M3 3l8 8M11 3l-8 8"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
/>
</svg>
</button>
</div>
{#if node.kind === 'object'}
<div class="children">
<Self bind:nodes={node.children} {onchange} />
</div>
{/if}
</div>
{/each}
<div class="add-row">
<button class="add" onclick={addValue}>+ Field</button>
<button class="add" onclick={addObject}>+ Group</button>
</div>
</div>
<style>
.meta-editor {
display: flex;
flex-direction: column;
gap: 6px;
}
.node {
display: flex;
flex-direction: column;
gap: 6px;
}
.node-head {
display: flex;
gap: 6px;
align-items: center;
}
.key,
.val {
box-sizing: border-box;
height: 34px;
padding: 0 9px;
border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
background-color: var(--color-bg-elevated);
color: var(--color-text-primary);
font-size: 0.85rem;
font-family: inherit;
outline: none;
min-width: 0;
}
.key:focus,
.val:focus {
border-color: var(--color-accent);
}
.key {
flex: 0 0 38%;
}
.val {
flex: 1 1 auto;
}
/* Type toggle: shows 'a' for a plain value, '{ }' for a nested object. */
.kind {
flex-shrink: 0;
width: 34px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
background-color: var(--color-bg-elevated);
color: var(--color-text-muted);
font-size: 0.8rem;
font-family: inherit;
cursor: pointer;
}
.kind.obj {
color: var(--color-accent);
border-color: var(--color-accent);
}
.kind:hover {
border-color: var(--color-accent);
}
.del {
flex-shrink: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
border: none;
background: none;
color: var(--color-text-muted);
cursor: pointer;
}
.del:hover {
color: var(--color-danger);
background-color: color-mix(in srgb, var(--color-danger) 12%, transparent);
}
/* Nested level: indent and hang a rail off the parent key. */
.children {
margin-left: 12px;
padding-left: 12px;
border-left: 2px solid color-mix(in srgb, var(--color-accent) 20%, transparent);
}
.add-row {
display: flex;
gap: 6px;
}
.add {
padding: 5px 11px;
border-radius: 6px;
border: 1px dashed color-mix(in srgb, var(--color-accent) 40%, transparent);
background: none;
color: var(--color-accent);
font-size: 0.78rem;
font-family: inherit;
cursor: pointer;
}
.add:hover {
background-color: color-mix(in srgb, var(--color-accent) 12%, transparent);
}
</style>
@@ -1,252 +0,0 @@
<script lang="ts">
import { get } from 'svelte/store';
import { untrack } from 'svelte';
import { authStore } from '$lib/stores/auth';
import type { File } from '$lib/api/types';
interface Props {
/** Files to page through (e.g. a duplicate cluster). */
files: File[];
/** Id to open first. */
startId: string;
onClose: () => void;
}
let { files, startId, onClose }: Props = $props();
// Resolve the starting index once. The lightbox is remounted on each open, so
// the props are effectively init-only — untrack acknowledges that read.
let index = $state(untrack(() => Math.max(0, files.findIndex((f) => f.id === startId))));
let src = $state<string | null>(null);
let failed = $state(false);
let current = $derived(files[index]);
let hasPrev = $derived(index > 0);
let hasNext = $derived(index < files.length - 1);
// Load the full preview — the same image the single-file viewer shows, so the
// user can actually tell duplicates apart instead of squinting at thumbnails.
// Auth-gated, rendered from a blob; re-runs when the index changes.
$effect(() => {
const f = files[index];
if (!f) return;
const token = get(authStore).accessToken;
let objectUrl: string | null = null;
let cancelled = false;
src = null;
failed = false;
fetch(`/api/v1/files/${f.id}/preview`, {
headers: token ? { Authorization: `Bearer ${token}` } : {}
})
.then((res) => (res.ok ? res.blob() : null))
.then((blob) => {
if (cancelled || !blob) {
if (!cancelled) failed = true;
return;
}
objectUrl = URL.createObjectURL(blob);
src = objectUrl;
})
.catch(() => {
if (!cancelled) failed = true;
});
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
});
function prev() {
if (hasPrev) index -= 1;
}
function next() {
if (hasNext) index += 1;
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
else if (e.key === 'ArrowLeft') prev();
else if (e.key === 'ArrowRight') next();
}
</script>
<svelte:window onkeydown={onKeydown} />
<!-- Close only when the backdrop itself is clicked (not the image or controls);
Escape and the × button close from anywhere. -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="backdrop"
role="dialog"
aria-modal="true"
aria-label="Enlarged preview"
tabindex="-1"
onclick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<button class="close" onclick={onClose} aria-label="Close">
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" aria-hidden="true">
<path
d="M6 6l10 10M16 6L6 16"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</button>
<div class="stage">
{#if src}
<img class="img" {src} alt={current?.original_name ?? ''} />
{:else if failed}
<div class="ph failed">Failed to load preview</div>
{:else}
<div class="ph loading">Loading…</div>
{/if}
{#if hasPrev}
<button class="nav prev" onclick={prev} aria-label="Previous">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path
d="M12 4L6 10L12 16"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
{/if}
{#if hasNext}
<button class="nav next" onclick={next} aria-label="Next">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path
d="M8 4L14 10L8 16"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
{/if}
</div>
<div class="caption">
<span class="name" title={current?.original_name ?? ''}>{current?.original_name ?? '—'}</span>
<span class="pos">{index + 1} / {files.length}</span>
</div>
</div>
<style>
.backdrop {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 16px;
background-color: rgba(0, 0, 0, 0.88);
}
.close {
position: absolute;
top: 12px;
right: 12px;
width: 38px;
height: 38px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
border: none;
background-color: rgba(0, 0, 0, 0.5);
color: #fff;
cursor: pointer;
}
.close:hover {
background-color: rgba(0, 0, 0, 0.8);
}
.stage {
position: relative;
display: flex;
align-items: center;
justify-content: center;
max-width: 100%;
min-height: 0;
flex: 1;
}
.img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
display: block;
border-radius: 6px;
}
.ph {
display: flex;
align-items: center;
justify-content: center;
min-width: 220px;
min-height: 220px;
color: var(--color-text-muted);
font-size: 0.9rem;
}
.ph.failed {
color: var(--color-danger);
}
.nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 44px;
height: 44px;
border-radius: 50%;
border: none;
background-color: rgba(0, 0, 0, 0.55);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background-color 0.15s;
}
.nav:hover {
background-color: rgba(0, 0, 0, 0.85);
}
.nav.prev {
left: 8px;
}
.nav.next {
right: 8px;
}
.caption {
display: flex;
align-items: center;
gap: 10px;
max-width: 100%;
color: var(--color-text-primary);
font-size: 0.85rem;
flex-shrink: 0;
}
.caption .name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.caption .pos {
color: var(--color-text-muted);
flex-shrink: 0;
}
</style>
@@ -1,21 +1,32 @@
<script lang="ts">
import { selectionStore, selectionCount } from '$lib/stores/selection';
interface Props {
count: number;
onClear: () => void;
onEditTags: () => void;
onAddToPool: () => void;
onMarkReviewed: () => void;
onDelete: () => void;
// Optional: only shown in a pool context (removes the files from that pool
// without deleting them). Omitted on the global files list.
onRemoveFromPool?: () => void;
}
let { onEditTags, onAddToPool, onMarkReviewed, onDelete }: Props = $props();
let {
count,
onClear,
onEditTags,
onAddToPool,
onMarkReviewed,
onDelete,
onRemoveFromPool
}: Props = $props();
</script>
<div class="bar" role="toolbar" aria-label="Selection actions">
<div class="row">
<!-- Count / deselect all -->
<button class="count" onclick={() => selectionStore.exit()} title="Clear selection">
<span class="num">{$selectionCount}</span>
<button class="count" onclick={onClear} title="Clear selection">
<span class="num">{count}</span>
<span class="label">selected</span>
<svg
class="close-icon"
@@ -39,6 +50,9 @@
<button class="action edit-tags" onclick={onEditTags}>Edit tags</button>
<button class="action add-pool" onclick={onAddToPool}>Add to pool</button>
<button class="action mark-reviewed" onclick={onMarkReviewed}>Mark reviewed</button>
{#if onRemoveFromPool}
<button class="action remove-pool" onclick={onRemoveFromPool}>Remove from pool</button>
{/if}
<button class="action delete" onclick={onDelete}>Delete</button>
</div>
</div>
@@ -72,6 +86,7 @@
.row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
}
@@ -124,6 +139,7 @@
font-size: 0.85rem;
font-family: inherit;
font-weight: 600;
white-space: nowrap;
}
.edit-tags {
@@ -150,6 +166,15 @@
background-color: color-mix(in srgb, var(--color-success) 15%, transparent);
}
.remove-pool {
color: var(--color-text-muted);
}
.remove-pool:hover {
background-color: color-mix(in srgb, var(--color-accent) 15%, transparent);
color: var(--color-text-primary);
}
.delete {
color: var(--color-danger);
}
@@ -13,8 +13,8 @@
let { tag, onclick, size = 'md', focused = false, index }: Props = $props();
const color = tag.color ?? tag.category_color;
const style = color ? `background-color: #${color}` : '';
let color = $derived(tag.color ?? tag.category_color);
let style = $derived(color ? `background-color: #${color}` : '');
</script>
{#if onclick}
+99
View File
@@ -0,0 +1,99 @@
/**
* File metadata tree model.
*
* The `metadata` API field is a free-form JSON object. The editor renders it as
* a tree of nodes: each node is either a leaf (a scalar edited as text) or a
* branch (a nested object with its own children). Arrays and other non-object
* values stay leaves and round-trip as JSON text.
*/
/** One entry at some level of the metadata object. */
export interface MetaNode {
/** Local-only id, for keyed list rendering. Not persisted. */
id: number;
key: string;
/** `value` holds a leaf's text; `object` nests `children`. */
kind: 'value' | 'object';
value: string;
children: MetaNode[];
}
let counter = 0;
/** Monotonic id for keyed rendering; uniqueness within a list is all that matters. */
export function nextMetaId(): number {
return counter++;
}
/** A plain object (not null, not an array). */
function isPlainObject(v: unknown): v is Record<string, unknown> {
return !!v && typeof v === 'object' && !Array.isArray(v);
}
/** Expand a stored object into editor nodes. Non-object input yields no nodes. */
export function objectToNodes(m: unknown): MetaNode[] {
if (!isPlainObject(m)) return [];
return Object.entries(m).map(([key, val]) => valueToNode(key, val));
}
function valueToNode(key: string, val: unknown): MetaNode {
if (isPlainObject(val)) {
return { id: nextMetaId(), key, kind: 'object', value: '', children: objectToNodes(val) };
}
return { id: nextMetaId(), key, kind: 'value', value: valueToString(val), children: [] };
}
/** Render a leaf value for the text input. Strings pass through; everything else
* (numbers, booleans, arrays) shows as JSON so it survives a round-trip. */
export function valueToString(val: unknown): string {
if (val === null || val === undefined) return '';
if (typeof val === 'string') return val;
return JSON.stringify(val);
}
/** Collapse the node tree back into a plain object for the PATCH body. Blank keys
* are dropped; a later duplicate key wins. */
export function nodesToObject(nodes: MetaNode[]): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const n of nodes) {
const k = n.key.trim();
if (!k) continue;
out[k] = n.kind === 'object' ? nodesToObject(n.children) : parseValue(n.value);
}
return out;
}
/** Parse a leaf's text: JSON-typed values (number, boolean, array, object) keep
* their type; anything else stays a plain string. */
export function parseValue(value: string): unknown {
const v = value.trim();
if (v === '') return '';
try {
const parsed: unknown = JSON.parse(v);
if (parsed !== null && typeof parsed !== 'string') return parsed;
} catch {
// not JSON — keep the raw string
}
return value;
}
/** If the text is a JSON object, return it (used when expanding a leaf into a
* nested group); otherwise null. */
export function parseObject(value: string): Record<string, unknown> | null {
const v = value.trim();
if (!v) return null;
try {
const parsed: unknown = JSON.parse(v);
if (isPlainObject(parsed)) return parsed;
} catch {
// not JSON
}
return null;
}
export function newValueNode(): MetaNode {
return { id: nextMetaId(), key: '', kind: 'value', value: '', children: [] };
}
export function newObjectNode(): MetaNode {
return { id: nextMetaId(), key: '', kind: 'object', value: '', children: [] };
}
+1 -2
View File
@@ -42,8 +42,7 @@ export function createRovingGrid<T extends Item>(opts: RovingGridOptions<T>) {
function isFormTarget(t: EventTarget | null): boolean {
return (
t instanceof HTMLElement &&
(t.isContentEditable ||
['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON', 'A'].includes(t.tagName))
(t.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON', 'A'].includes(t.tagName))
);
}
@@ -107,6 +107,7 @@
class:on={isAdmin}
role="switch"
aria-checked={isAdmin}
aria-label="Admin"
onclick={() => (isAdmin = !isAdmin)}><span class="thumb"></span></button
>
</div>
@@ -121,6 +122,7 @@
class:on={canCreate}
role="switch"
aria-checked={canCreate}
aria-label="Can create"
onclick={() => (canCreate = !canCreate)}><span class="thumb"></span></button
>
</div>
@@ -140,6 +142,7 @@
class:danger={isBlocked}
role="switch"
aria-checked={isBlocked}
aria-label="Blocked"
onclick={() => (isBlocked = !isBlocked)}><span class="thumb"></span></button
>
</div>
+6 -5
View File
@@ -12,7 +12,7 @@
import SelectionBar from '$lib/components/layout/SelectionBar.svelte';
import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte';
import { fileSorting, type FileSortField } from '$lib/stores/sorting';
import { selectionStore, selectionActive } from '$lib/stores/selection';
import { selectionStore, selectionActive, selectionCount } from '$lib/stores/selection';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import BulkTagEditor from '$lib/components/file/BulkTagEditor.svelte';
import PoolPicker from '$lib/components/file/PoolPicker.svelte';
@@ -129,9 +129,7 @@
selectionStore.exit();
try {
await api.post('/files/bulk/review', { file_ids: ids, needs_review: false });
files = files.map((f) =>
ids.includes(f.id ?? '') ? { ...f, needs_review: false } : f
);
files = files.map((f) => (ids.includes(f.id ?? '') ? { ...f, needs_review: false } : f));
} catch {
// ignore — list already reflects the intended state optimistically
}
@@ -148,7 +146,8 @@
function handleKey(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (tagEditorOpen) tagEditorOpen = false;
else if (poolPickerOpen) return; // PoolPicker owns Escape (clear search, then close)
else if (poolPickerOpen)
return; // PoolPicker owns Escape (clear search, then close)
else if (confirmDeleteFiles) confirmDeleteFiles = false;
else if (activeFileId) return;
else if ($selectionActive) selectionStore.exit();
@@ -784,6 +783,8 @@
{#if $selectionActive}
<SelectionBar
count={$selectionCount}
onClear={() => selectionStore.exit()}
onEditTags={openTagEditor}
onAddToPool={openPoolPicker}
onMarkReviewed={markSelectionReviewed}
+171 -46
View File
@@ -1,41 +1,61 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { api } from '$lib/api/client';
import { getDuplicates, dismissDuplicate, type DuplicateCluster } from '$lib/api/duplicates';
import { getDuplicates, dismissDuplicate, type DuplicatePairDistance } from '$lib/api/duplicates';
import Thumb from '$lib/components/file/Thumb.svelte';
import DuplicateMergeDialog from '$lib/components/file/DuplicateMergeDialog.svelte';
import PreviewLightbox from '$lib/components/file/PreviewLightbox.svelte';
import FileViewer from '$lib/components/file/FileViewer.svelte';
import type { File } from '$lib/api/types';
const LIMIT = 20;
let clusters = $state<DuplicateCluster[]>([]);
// A cluster carries a stable local key so resolving one pair (delete / dismiss /
// merge) can edit it in place — no full reload, no scroll jump, no lost "keep".
interface Cluster {
key: number;
files: File[];
distances: DuplicatePairDistance[];
}
let nextKey = 0;
let clusters = $state<Cluster[]>([]);
let total = $state(0);
// Server group cursor; advances monotonically per page so local removals don't
// shift the offset and make "Load more" repeat or skip clusters.
let offset = $state(0);
let loading = $state(false);
let initialLoaded = $state(false);
let error = $state('');
let busyKey = $state(''); // cluster currently performing an action
let busyId = $state<number | null>(null); // cluster currently performing an action
// Which file is the survivor for a given cluster (keyed by its file-id set).
let keepers = $state<Record<string, string>>({});
// Which file is the survivor for a given cluster (keyed by its stable key).
let keepers = $state<Record<number, string>>({});
// Merge dialog state.
// Merge dialog state — mergeId pins the cluster so onMerged edits the right one.
let mergeId = $state<number | null>(null);
let mergeKeep = $state<File | null>(null);
let mergeDiscard = $state<File | null>(null);
// Enlarged-preview lightbox: thumbnails are too small to tell near-duplicates
// apart, so a zoom opens the full preview and pages across the cluster.
let lightbox = $state<{ files: File[]; startId: string } | null>(null);
// Full viewer (same as the files page): thumbnails are too small to compare,
// and dedup decisions need date / tags / EXIF, so the zoom opens the real
// viewer and pages across the cluster's files.
let viewer = $state<{ key: number; files: File[]; id: string } | null>(null);
$effect(() => {
if (!initialLoaded && !loading) void load();
});
function clusterKey(c: DuplicateCluster): string {
return c.files.map((f) => f.id).join(',');
function keeperId(c: Cluster): string {
return keepers[c.key] ?? c.files[0]?.id ?? '';
}
function keeperId(c: DuplicateCluster): string {
return keepers[clusterKey(c)] ?? c.files[0]?.id ?? '';
// Stored perceptual distance between the kept file and another, or null when
// the two are linked only transitively (no direct stored pair).
function distanceFromKeep(c: Cluster, keep: string, other: string): number | null {
for (const d of c.distances) {
if ((d.a === keep && d.b === other) || (d.a === other && d.b === keep)) return d.distance;
}
return null;
}
async function load() {
@@ -43,9 +63,17 @@
loading = true;
error = '';
try {
const res = await getDuplicates(LIMIT, clusters.length);
clusters = [...clusters, ...(res.items ?? [])];
total = res.total ?? clusters.length;
const res = await getDuplicates(LIMIT, offset);
const incoming = (res.items ?? []).map((c) => ({
key: nextKey++,
files: c.files,
distances: c.distances ?? []
}));
total = res.total ?? total;
// The server paginates by group index and may drop groups that fell below
// two live files, so advance by the page size (clamped), not items returned.
offset = Math.min(offset + LIMIT, total);
clusters = [...clusters, ...incoming];
} catch {
error = 'Failed to load duplicates';
} finally {
@@ -58,58 +86,114 @@
clusters = [];
keepers = {};
total = 0;
offset = 0;
initialLoaded = false;
await load();
}
function setKeeper(c: DuplicateCluster, id: string) {
keepers = { ...keepers, [clusterKey(c)]: id };
function setKeeper(c: Cluster, id: string) {
keepers = { ...keepers, [c.key]: id };
}
function openLightbox(c: DuplicateCluster, startId: string) {
lightbox = { files: c.files, startId };
// Drop one file from a cluster after it's resolved, in place. With fewer than
// two files there's nothing left to compare, so the cluster — and its slot in
// the total — goes away. The server's view is live, so a later page reconciles.
function removeFile(key: number, fileId: string) {
const target = clusters.find((c) => c.key === key);
if (!target) return;
const remaining = target.files.filter((f) => f.id !== fileId);
const dropCluster = remaining.length < 2;
if (dropCluster) {
clusters = clusters.filter((c) => c.key !== key);
total = Math.max(0, total - 1);
} else {
clusters = clusters.map((c) => (c.key === key ? { ...c, files: remaining } : c));
}
// Forget a stale survivor pick when its cluster is gone or the pick was removed.
if (dropCluster || keepers[key] === fileId) {
const next = { ...keepers };
delete next[key];
keepers = next;
}
}
function openMerge(c: DuplicateCluster, other: File) {
function openViewer(c: Cluster, id: string) {
viewer = { key: c.key, files: c.files, id };
}
// Prev/next within the cluster currently open in the viewer.
let viewerPrevId = $derived.by(() => {
const v = viewer;
if (!v) return null;
const i = v.files.findIndex((f) => f.id === v.id);
return i > 0 ? (v.files[i - 1]?.id ?? null) : null;
});
let viewerNextId = $derived.by(() => {
const v = viewer;
if (!v) return null;
const i = v.files.findIndex((f) => f.id === v.id);
return i >= 0 && i < v.files.length - 1 ? (v.files[i + 1]?.id ?? null) : null;
});
function viewerNavigate(id: string) {
if (viewer) viewer = { ...viewer, id };
}
// Mirror a review toggle made inside the viewer back into the cluster list and
// the viewer's own navigation snapshot so both stay consistent.
function onViewerReviewChange(id: string, needsReview: boolean) {
const apply = (f: File) => (f.id === id ? { ...f, needs_review: needsReview } : f);
clusters = clusters.map((c) =>
c.key === viewer?.key ? { ...c, files: c.files.map(apply) } : c
);
if (viewer) viewer = { ...viewer, files: viewer.files.map(apply) };
}
function openMerge(c: Cluster, other: File) {
const keep = c.files.find((f) => f.id === keeperId(c));
if (!keep) return;
mergeId = c.key;
mergeKeep = keep;
mergeDiscard = other;
}
async function deleteFile(c: DuplicateCluster, id: string) {
if (busyKey) return;
busyKey = clusterKey(c);
async function deleteFile(c: Cluster, id: string) {
if (busyId !== null) return;
busyId = c.key;
try {
await api.post('/files/bulk/delete', { file_ids: [id] });
await reload();
removeFile(c.key, id);
} catch {
error = 'Failed to delete file';
} finally {
busyKey = '';
busyId = null;
}
}
async function notDuplicate(c: DuplicateCluster, other: File) {
if (busyKey) return;
busyKey = clusterKey(c);
async function notDuplicate(c: Cluster, other: File) {
if (busyId !== null) return;
busyId = c.key;
try {
await dismissDuplicate(keeperId(c), other.id);
await reload();
removeFile(c.key, other.id);
} catch {
error = 'Failed to dismiss pair';
} finally {
busyKey = '';
busyId = null;
}
}
function onMerged() {
const key = mergeId;
const discardId = mergeDiscard?.id;
mergeId = null;
mergeKeep = null;
mergeDiscard = null;
void reload();
if (key !== null && discardId) removeFile(key, discardId);
}
let hasMore = $derived(clusters.length < total);
let hasMore = $derived(offset < total);
</script>
<svelte:head>
@@ -155,9 +239,9 @@
</div>
{/if}
{#each clusters as c (clusterKey(c))}
{#each clusters as c (c.key)}
{@const keep = keeperId(c)}
<section class="cluster" class:busy={busyKey === clusterKey(c)}>
<section class="cluster" class:busy={busyId === c.key}>
<div class="files">
{#each c.files as f (f.id)}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
@@ -175,10 +259,10 @@
class="zoom"
onclick={(e) => {
e.stopPropagation();
openLightbox(c, f.id);
openViewer(c, f.id);
}}
aria-label="Enlarge preview"
title="Enlarge preview"
aria-label="Open in viewer"
title="Open in viewer"
>
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
<circle cx="6.5" cy="6.5" r="4.5" stroke="currentColor" stroke-width="1.5" />
@@ -206,8 +290,20 @@
<div class="actions">
{#each c.files.filter((f) => f.id !== keep) as other (other.id)}
{@const dist = distanceFromKeep(c, keep, other.id)}
<div class="actrow">
<span class="aname" title={other.original_name ?? ''}>{other.original_name ?? '—'}</span>
<span class="aname" title={other.original_name ?? ''}
>{other.original_name ?? '—'}</span
>
<span
class="dist"
class:unknown={dist === null}
title={dist === null
? 'No direct match — linked through another file'
: 'Perceptual distance from the kept file (lower = more similar)'}
>
Δ{dist ?? '—'}
</span>
<button class="abtn" onclick={() => openMerge(c, other)}>Merge</button>
<button class="abtn" onclick={() => deleteFile(c, other.id)}>Delete</button>
<button class="abtn ghost" onclick={() => notDuplicate(c, other)}>Not a dup</button>
@@ -227,12 +323,17 @@
</main>
</div>
{#if lightbox}
<PreviewLightbox
files={lightbox.files}
startId={lightbox.startId}
onClose={() => (lightbox = null)}
/>
{#if viewer}
<div class="viewer-overlay">
<FileViewer
fileId={viewer.id}
prevId={viewerPrevId}
nextId={viewerNextId}
onNavigate={viewerNavigate}
onClose={() => (viewer = null)}
onReviewChange={onViewerReviewChange}
/>
</div>
{/if}
{#if mergeKeep && mergeDiscard}
@@ -418,6 +519,20 @@
overflow: hidden;
text-overflow: ellipsis;
}
.dist {
flex-shrink: 0;
font-size: 0.72rem;
font-variant-numeric: tabular-nums;
color: var(--color-accent);
background-color: color-mix(in srgb, var(--color-accent) 14%, transparent);
border-radius: 5px;
padding: 1px 6px;
cursor: help;
}
.dist.unknown {
color: var(--color-text-muted);
background-color: var(--color-bg-elevated);
}
.abtn {
padding: 5px 10px;
border-radius: 7px;
@@ -452,4 +567,14 @@
color: var(--color-text-muted);
font-size: 0.9rem;
}
/* Full-screen overlay for the file viewer, mirroring the files page. */
.viewer-overlay {
position: fixed;
inset: 0;
z-index: 200;
background-color: var(--color-bg-primary);
overflow-y: auto;
overscroll-behavior: contain;
}
</style>
+251 -102
View File
@@ -7,6 +7,8 @@
import FileViewer from '$lib/components/file/FileViewer.svelte';
import FilterBar from '$lib/components/file/FilterBar.svelte';
import PoolPicker from '$lib/components/file/PoolPicker.svelte';
import BulkTagEditor from '$lib/components/file/BulkTagEditor.svelte';
import SelectionBar from '$lib/components/layout/SelectionBar.svelte';
import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import { parseDslFilter } from '$lib/utils/dsl';
@@ -44,11 +46,13 @@
let filterOpen = $state(false);
let activeTokens = $derived(parseDslFilter(filterParam));
// ---- Selection (for removal) ----
// ---- Selection + bulk actions (mirrors the files page) ----
let selectedIds = $state(new Set<string>());
let selectionMode = $derived(selectedIds.size > 0);
let lastSelectedIdx = $state<number | null>(null);
let confirmRemove = $state(false);
let confirmDeleteFiles = $state(false);
let tagEditorOpen = $state(false);
let poolPickerOpen = $state(false);
// ---- Add files mode ----
@@ -61,8 +65,15 @@
let addSelected = $state(new Set<string>());
let addSearchPrev = $state('');
// ---- Drag-to-reorder (disabled when filter active) ----
let canReorder = $derived(!filterParam);
// ---- Sorting ----
// The pool stores its own file sort. "manual" keeps the drag order; any other
// key sorts automatically (server-side) and disables reordering.
let sortKey = $derived(pool?.sort_key ?? 'manual');
let sortOrder = $derived(pool?.sort_order ?? 'asc');
let sortChanging = $state(false);
// ---- Drag-to-reorder (only in manual order, and not while filtering) ----
let canReorder = $derived(!filterParam && sortKey === 'manual');
let dragSrcIdx = $state<number | null>(null);
let dragOverIdx = $state<number | null>(null);
let reorderPending = $state(false);
@@ -131,6 +142,27 @@
}
}
// ---- Change the pool's file sort ----
// Persist the new sort on the pool, then reload the files from the top so the
// new server-side order takes effect. A no-op if the setting hasn't changed.
async function changeSort(key: string, order: string) {
if (!pool || sortChanging) return;
if (key === sortKey && order === sortOrder) return;
sortChanging = true;
try {
pool = await api.patch<Pool>(`/pools/${poolId}`, { sort_key: key, sort_order: order });
files = [];
nextCursor = null;
hasMore = true;
filesError = '';
await loadMore();
} catch (e) {
filesError = e instanceof ApiError ? e.message : 'Failed to change sort';
} finally {
sortChanging = false;
}
}
// ---- Save pool ----
async function save() {
if (!name.trim() || saving) return;
@@ -210,10 +242,20 @@
lastSelectedIdx = idx;
}
function clearSelection() {
selectedIds = new Set();
lastSelectedIdx = null;
}
function openTagEditor() {
tagEditorOpen = true;
void tick().then(() => document.querySelector<HTMLInputElement>('.tag-sheet input')?.focus());
}
async function removeSelected() {
confirmRemove = false;
const ids = [...selectedIds];
selectedIds = new Set();
clearSelection();
try {
await api.post(`/pools/${poolId}/files/remove`, { file_ids: ids });
files = files.filter((f) => !ids.includes(f.id ?? ''));
@@ -223,6 +265,34 @@
}
}
// Mark the selection as review-done; optimistically clear the local badges.
async function markSelectionReviewed() {
const ids = [...selectedIds];
if (ids.length === 0) return;
clearSelection();
try {
await api.post('/files/bulk/review', { file_ids: ids, needs_review: false });
files = files.map((f) => (ids.includes(f.id ?? '') ? { ...f, needs_review: false } : f));
} catch {
// ignore — the list already reflects the intended state
}
}
// Move the selection to trash. Trashed files drop out of the pool view (which
// only lists live files); pool membership is kept, so file_count is left to
// the server's truth on the next load rather than adjusted optimistically.
async function deleteSelected() {
confirmDeleteFiles = false;
const ids = [...selectedIds];
clearSelection();
try {
await api.post('/files/bulk/delete', { file_ids: ids });
files = files.filter((f) => !ids.includes(f.id ?? ''));
} catch {
// silently ignore — list already updated optimistically
}
}
// ---- File viewer overlay (shallow routing) ----
// Open the viewer on top of the still-mounted pool grid so the back button (and
// the viewer's own close) returns here — with the pool's list and scroll intact —
@@ -591,6 +661,32 @@
{#if pool?.file_count != null}<span class="count">({pool.file_count})</span>{/if}
</span>
<div class="files-header-actions">
<div class="sort-control">
<select
class="sort-select"
value={sortKey}
disabled={sortChanging}
onchange={(e) => changeSort((e.currentTarget as HTMLSelectElement).value, sortOrder)}
title="Sort files"
aria-label="Sort files"
>
<option value="manual">Manual order</option>
<option value="content_datetime">Content date</option>
<option value="created">Created</option>
<option value="original_name">Name</option>
</select>
{#if sortKey !== 'manual'}
<button
class="order-btn"
disabled={sortChanging}
onclick={() => changeSort(sortKey, sortOrder === 'asc' ? 'desc' : 'asc')}
title={sortOrder === 'asc' ? 'Ascending' : 'Descending'}
aria-label="Toggle sort direction"
>
{sortOrder === 'asc' ? '↑' : '↓'}
</button>
{/if}
</div>
{#if canReorder && files.length > 1}
<span class="reorder-hint" title="Drag thumbnails to reorder">
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true">
@@ -685,46 +781,61 @@
</div>
{/if}
<!-- Selection bar (remove mode) -->
<!-- Selection actions — same set as the files page, plus Remove from pool. -->
{#if selectionMode && !addMode}
<div class="selection-bar" role="toolbar">
<button
class="sel-cancel"
onclick={() => {
selectedIds = new Set();
lastSelectedIdx = null;
}}
>
<span class="sel-num">{selectedIds.size}</span>
<span class="sel-label">selected</span>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path
d="M2 2l10 10M12 2L2 12"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
/>
</svg>
</button>
<div class="sel-spacer"></div>
<button class="sel-action add-action" onclick={() => (poolPickerOpen = true)}>
Add to pool
</button>
<button class="sel-action remove-action" onclick={() => (confirmRemove = true)}>
Remove from pool
</button>
<SelectionBar
count={selectedIds.size}
onClear={clearSelection}
onEditTags={openTagEditor}
onAddToPool={() => (poolPickerOpen = true)}
onMarkReviewed={markSelectionReviewed}
onRemoveFromPool={() => (confirmRemove = true)}
onDelete={() => (confirmDeleteFiles = true)}
/>
{/if}
<!-- Bulk tag editor sheet -->
{#if tagEditorOpen}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="picker-backdrop" role="presentation" onclick={() => (tagEditorOpen = false)}></div>
<div class="picker-sheet tag-sheet" role="dialog" aria-label="Edit tags">
<div class="picker-header">
<span class="picker-title"
>Edit tags — {selectedIds.size} file{selectedIds.size !== 1 ? 's' : ''}</span
>
<button class="picker-close" onclick={() => (tagEditorOpen = false)} aria-label="Close">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M3 3l10 10M13 3L3 13"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
/>
</svg>
</button>
</div>
<div class="tag-sheet-body">
<BulkTagEditor fileIds={[...selectedIds]} onDone={() => (tagEditorOpen = false)} />
</div>
</div>
{/if}
{#if confirmDeleteFiles}
<ConfirmDialog
message={`Move ${selectedIds.size} file${selectedIds.size !== 1 ? 's' : ''} to trash?`}
confirmLabel="Move to trash"
danger
onConfirm={deleteSelected}
onCancel={() => (confirmDeleteFiles = false)}
/>
{/if}
<!-- Add the selected files to another pool (reuses the file picker; the order
follows the selection's insertion order). -->
{#if poolPickerOpen}
<PoolPicker
fileIds={[...selectedIds]}
onAdded={() => {
selectedIds = new Set();
lastSelectedIdx = null;
}}
onAdded={clearSelection}
onClose={() => (poolPickerOpen = false)}
/>
{/if}
@@ -1043,6 +1154,57 @@
border-color: var(--color-accent);
}
.sort-control {
display: flex;
align-items: center;
gap: 4px;
}
.sort-select {
height: 26px;
padding: 0 6px;
border-radius: 5px;
border: 1px solid color-mix(in srgb, var(--color-accent) 25%, transparent);
background-color: var(--color-bg-elevated);
color: var(--color-text-muted);
font-size: 0.78rem;
font-family: inherit;
cursor: pointer;
outline: none;
}
.sort-select:hover,
.sort-select:focus {
color: var(--color-text-primary);
border-color: var(--color-accent);
}
.sort-select:disabled {
opacity: 0.5;
cursor: default;
}
.order-btn {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 5px;
border: 1px solid color-mix(in srgb, var(--color-accent) 25%, transparent);
background: none;
color: var(--color-text-muted);
font-size: 0.9rem;
line-height: 1;
cursor: pointer;
}
.order-btn:hover {
color: var(--color-accent);
border-color: var(--color-accent);
}
.order-btn:disabled {
opacity: 0.5;
cursor: default;
}
/* ---- File grid ---- */
main {
flex: 1;
@@ -1117,21 +1279,64 @@
font-size: 0.9rem;
}
/* ---- Selection bar ---- */
.selection-bar {
/* ---- Bulk tag editor sheet (shared shell with the files page) ---- */
.picker-backdrop {
position: fixed;
left: 10px;
right: 10px;
bottom: 65px;
inset: 0;
z-index: 110;
background: rgba(0, 0, 0, 0.5);
}
.picker-sheet {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 111;
background-color: var(--color-bg-secondary);
border-radius: 10px;
box-shadow: 0 0 12px rgba(0, 0, 0, 0.5);
padding: 10px 14px;
z-index: 100;
border-radius: 14px 14px 0 0;
padding-bottom: env(safe-area-inset-bottom, 0px);
max-height: 70dvh;
display: flex;
flex-direction: column;
animation: slide-up 0.18s ease-out;
}
.tag-sheet {
max-height: 80dvh;
}
.picker-header {
display: flex;
align-items: center;
gap: 4px;
animation: slide-up 0.18s ease-out;
padding: 14px 16px 10px;
gap: 8px;
}
.picker-title {
flex: 1;
font-size: 0.95rem;
font-weight: 600;
}
.picker-close {
background: none;
border: none;
cursor: pointer;
color: var(--color-text-muted);
padding: 4px;
display: flex;
align-items: center;
}
.picker-close:hover {
color: var(--color-text-primary);
}
.tag-sheet-body {
padding: 0 14px 16px;
overflow-y: auto;
flex: 1;
}
@keyframes slide-up {
@@ -1145,62 +1350,6 @@
}
}
.sel-cancel {
display: flex;
align-items: center;
gap: 5px;
background: none;
border: none;
cursor: pointer;
padding: 4px 6px;
border-radius: 6px;
color: var(--color-text-muted);
font-family: inherit;
}
.sel-cancel:hover {
background-color: color-mix(in srgb, var(--color-accent) 12%, transparent);
color: var(--color-text-primary);
}
.sel-num {
font-size: 1.1rem;
font-weight: 700;
color: var(--color-text-primary);
}
.sel-label {
font-size: 0.85rem;
}
.sel-spacer {
flex: 1;
}
.sel-action {
background: none;
border: none;
cursor: pointer;
padding: 6px 10px;
border-radius: 6px;
font-size: 0.85rem;
font-family: inherit;
font-weight: 600;
}
.add-action {
color: var(--color-accent);
}
.add-action:hover {
background-color: color-mix(in srgb, var(--color-accent) 15%, transparent);
}
.remove-action {
color: var(--color-danger);
}
.remove-action:hover {
background-color: color-mix(in srgb, var(--color-danger) 15%, transparent);
}
/* ---- Add files overlay ---- */
.add-overlay {
position: absolute;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

+4 -29
View File
@@ -10,50 +10,25 @@
"theme_color": "#312F45",
"icons": [
{
"src": "/images/android-icon-36x36.png",
"sizes": "36x36",
"type": "image/png"
},
{
"src": "/images/android-icon-48x48.png",
"sizes": "48x48",
"type": "image/png"
},
{
"src": "/images/android-icon-72x72.png",
"sizes": "72x72",
"type": "image/png"
},
{
"src": "/images/android-icon-96x96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src": "/images/android-icon-144x144.png",
"sizes": "144x144",
"type": "image/png"
},
{
"src": "/images/pwa-192x192.png",
"src": "/images/icon-tile-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/images/pwa-512x512.png",
"src": "/images/icon-tile-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/images/pwa-maskable-192x192.png",
"src": "/images/icon-maskable-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/images/pwa-maskable-512x512.png",
"src": "/images/icon-maskable-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
+79 -4
View File
@@ -4,9 +4,15 @@ info:
description: |
REST API for Tanabata File Manager — a multi-user, tag-based web file manager.
## Versioning
The `version` field above is the product release (tracks the app's 3.0.0).
The URL path version (`/api/v1`) is the API compatibility version and changes
only on a breaking contract change — the two are intentionally independent.
## Authentication
All endpoints except `POST /auth/login` require a Bearer JWT token
in the `Authorization` header.
All endpoints require a Bearer JWT token in the `Authorization` header,
except `POST /auth/login`, `POST /auth/refresh` (which carry their own
credentials) and `GET /health`.
## Pagination
- **Files**: cursor-based (`cursor` parameter, returned in `next_cursor`).
@@ -32,9 +38,10 @@ info:
Example: `{t=uuid1,&,!,t=uuid2}` → has tag1 AND NOT tag2.
Example: `{r=1,&,m~image%}` → needs review AND is an image.
version: 1.0.0
version: 3.0.0
license:
name: Proprietary
name: AGPL-3.0-or-later
url: https://www.gnu.org/licenses/agpl-3.0.html
servers:
- url: /api/v1
@@ -59,12 +66,42 @@ tags:
description: User management (admin)
- name: Audit
description: Audit log (admin)
- name: System
description: Service health and liveness
# ===========================================================================
# Paths
# ===========================================================================
paths:
# -------------------------------------------------------------------------
# System
# -------------------------------------------------------------------------
/health:
# Served at the server root, outside the /api/v1 prefix — override the
# global server so the documented path is /health, not /api/v1/health.
servers:
- url: /
get:
tags: [System]
summary: Health check
description: |
Liveness probe. Requires no authentication and is used by the container
HEALTHCHECK. Always returns 200 while the process is serving.
security: []
responses:
'200':
description: Service is healthy.
content:
application/json:
schema:
type: object
required: [status]
properties:
status:
type: string
example: ok
# -------------------------------------------------------------------------
# Auth
# -------------------------------------------------------------------------
@@ -1956,6 +1993,28 @@ components:
description: Two or more mutually similar files
items:
$ref: '#/components/schemas/File'
distances:
type: array
description: >-
Stored perceptual-hash (Hamming) distances between pairs of files in
the cluster. A pair linked only transitively (through an intermediate
file) has no stored distance and is omitted.
items:
$ref: '#/components/schemas/DuplicatePairDistance'
DuplicatePairDistance:
type: object
required: [a, b, distance]
properties:
a:
type: string
format: uuid
b:
type: string
format: uuid
distance:
type: integer
description: Hamming distance (064) between the two files' perceptual hashes
DuplicateClusterPage:
type: object
@@ -2228,6 +2287,16 @@ components:
type: string
is_public:
type: boolean
sort_key:
type: string
enum: [manual, content_datetime, created, original_name]
description: >-
How the pool's files are ordered. "manual" keeps the user-defined
order (drag-to-reorder); any other key sorts the files automatically
by that file field and disables manual reordering.
sort_order:
type: string
enum: [asc, desc]
file_count:
type: integer
created_at:
@@ -2259,6 +2328,12 @@ components:
type: object
is_public:
type: boolean
sort_key:
type: string
enum: [manual, content_datetime, created, original_name]
sort_order:
type: string
enum: [asc, desc]
PoolOffsetPage:
type: object