Compare commits
26 Commits
6834b916cd
...
v3.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 528b7004e3 | |||
| 41c4667eb4 | |||
| c3ed36f178 | |||
| 93e55e8e37 | |||
| 3f12b60261 | |||
| 6d13fa425a | |||
| a0a987d561 | |||
| 3e04da3926 | |||
| 87d3c27b65 | |||
| 36546c46c9 | |||
| ac27ea3176 | |||
| eb3cf72969 | |||
| 4b6db84afc | |||
| 28f5b5d150 | |||
| 4883820f25 | |||
| a6a46af12e | |||
| bf7fa49a16 | |||
| 76d9c35363 | |||
| 71cf79eeaa | |||
| a16accf443 | |||
| 78b5e86dd6 | |||
| 281358ff04 | |||
| d8cccbb9e0 | |||
| d7bfe6b596 | |||
| e5a731eb29 | |||
| 6fe6e4cd55 |
@@ -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
|
||||
@@ -131,9 +140,12 @@ IMPORT_PATH=/data/import
|
||||
# Maximum perceptual-hash distance (Hamming, out of 64 bits) for two files to be
|
||||
# treated as duplicate candidates. Lower = stricter (fewer, more confident
|
||||
# matches); higher = looser (catches more re-encodes/resizes but risks false
|
||||
# positives). Used only by the dedup tool's pairs rebuild — see the dedup CLI /
|
||||
# `docker compose run --rm dedup`. Default 10.
|
||||
DUPLICATE_HASH_THRESHOLD=10
|
||||
# positives). On real libraries the distance histogram climbs steeply in the 8–10
|
||||
# band — coincidental "vaguely similar" pairs, not duplicates — so 4 keeps the
|
||||
# genuine-duplicate signal without that noise (and far fewer pairs to cluster).
|
||||
# Used only by the dedup tool's pairs rebuild — see the dedup CLI /
|
||||
# `docker compose run --rm dedup`. Code default is 10.
|
||||
DUPLICATE_HASH_THRESHOLD=4
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Static SPA
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/>.
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
// 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)
|
||||
if err := srv.ListenAndServe(); err != nil {
|
||||
// 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")
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,8 +107,14 @@ 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"`
|
||||
Pos int `json:"p,omitempty"`
|
||||
Val string `json:"v,omitempty"`
|
||||
FileID string `json:"id"`
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
@@ -325,12 +361,15 @@ WITH upd AS (
|
||||
name = $2,
|
||||
notes = $3,
|
||||
metadata = COALESCE($4, metadata),
|
||||
is_public = $5
|
||||
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
|
||||
}
|
||||
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(
|
||||
"(fp.position > $%d OR (fp.position = $%d AND fp.file_id > $%d))",
|
||||
n, n, n+1))
|
||||
args = append(args, cur.Position, fileID)
|
||||
"(%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
|
||||
}
|
||||
orderBy = "fp.position ASC, fp.file_id ASC"
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
@@ -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,6 +42,11 @@ type Pool struct {
|
||||
CreatorID int16
|
||||
CreatorName string // denormalized
|
||||
IsPublic bool
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
SortKey *string
|
||||
SortOrder *string
|
||||
}
|
||||
|
||||
// PoolService handles pool CRUD and pool–file 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 {
|
||||
// Pool–file 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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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, file–tag, pools, file–pool,
|
||||
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.
|
||||
@@ -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**
|
||||
|
||||
@@ -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,306 +71,173 @@ 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;
|
||||
@theme {
|
||||
--color-bg-primary: #312f45;
|
||||
--color-bg-secondary: #181721;
|
||||
--color-bg-elevated: #111118;
|
||||
--color-accent: #9592B5;
|
||||
--color-accent-hover: #7D7AA4;
|
||||
--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;
|
||||
}
|
||||
/* … 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",
|
||||
@@ -380,43 +247,30 @@ Script in `package.json`:
|
||||
}
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
@@ -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 (+ file–tag 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,104 +161,102 @@ 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)
|
||||
// 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
|
||||
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)
|
||||
tagRepo := postgres.NewTagRepo(pool)
|
||||
// ...
|
||||
|
||||
// Storage
|
||||
diskStore := storage.NewDiskStorage(cfg.FilesPath)
|
||||
// … 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) {
|
||||
// handler/middleware.go
|
||||
claims := parseJWT(c.GetHeader("Authorization"))
|
||||
ctx := domain.WithUser(c.Request.Context(), claims.UserID, claims.IsAdmin)
|
||||
ctx := domain.WithUser(c.Request.Context(), claims.UserID, claims.IsAdmin, claims.SessionID)
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
c.Next()
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -238,7 +264,7 @@ func (s *ACLService) CanView(ctx context.Context, objectType string, objectID uu
|
||||
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 |
|
||||
@@ -249,22 +275,12 @@ Domain errors → HTTP status codes (handled in handler/response.go):
|
||||
|
||||
### 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,6 +295,9 @@ 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
|
||||
@@ -290,31 +309,24 @@ type Claims struct {
|
||||
}
|
||||
```
|
||||
|
||||
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`).
|
||||
|
||||
@@ -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 client–server 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 (key–value, 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, key–value
|
||||
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 key–value metadata.
|
||||
- **Pool** — a logical grouping of files. It has a name, a description, and
|
||||
key–value 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 (key–value).
|
||||
2. Change category.
|
||||
3. Assign auto-tag rules.
|
||||
4. Delete (with a confirmation prompt).
|
||||
7. Create a tag:
|
||||
1. Enter name, description, and metadata (key–value).
|
||||
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 (key–value).
|
||||
2. View attached tags.
|
||||
3. Attach / detach tags.
|
||||
4. Delete (with a confirmation prompt).
|
||||
7. Create a category:
|
||||
1. Enter name, description, and metadata (key–value).
|
||||
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 (key–value).
|
||||
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 (key–value).
|
||||
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 file–tag 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).
|
||||
@@ -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 отсутствует в БД (нет в БД — нет поддержки)
|
||||
@@ -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 ."
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -30,8 +30,32 @@
|
||||
|
||||
let imgSrc = $state<string | null>(null);
|
||||
let failed = $state(false);
|
||||
// Gate the fetch on visibility so a long grid doesn't fire every thumbnail
|
||||
// request on mount; the tile loads once it scrolls near the viewport.
|
||||
let visible = $state(false);
|
||||
|
||||
// Svelte action: flips `visible` true the first time the card nears the
|
||||
// viewport, then stops observing — the blob is kept once loaded.
|
||||
function lazyload(node: HTMLElement) {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting) {
|
||||
visible = true;
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ rootMargin: '200px' }
|
||||
);
|
||||
observer.observe(node);
|
||||
return {
|
||||
destroy() {
|
||||
observer.disconnect();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!visible) return;
|
||||
const token = get(authStore).accessToken;
|
||||
let objectUrl: string | null = null;
|
||||
let cancelled = false;
|
||||
@@ -112,7 +136,10 @@
|
||||
class:loaded={!!imgSrc}
|
||||
class:selected
|
||||
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,13 +499,14 @@
|
||||
</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"
|
||||
>
|
||||
<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"
|
||||
@@ -488,14 +516,16 @@
|
||||
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"
|
||||
>
|
||||
<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"
|
||||
@@ -505,6 +535,7 @@
|
||||
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>
|
||||
@@ -14,17 +14,43 @@
|
||||
|
||||
let imgSrc = $state<string | null>(null);
|
||||
let failed = $state(false);
|
||||
// Gate the fetch on visibility. A duplicate cluster can hold hundreds of files,
|
||||
// and firing every thumbnail request on mount buries the server in a request
|
||||
// storm (10k+ in-flight is easy). We only load once the tile nears the viewport.
|
||||
let visible = $state(false);
|
||||
|
||||
// Svelte action: flips `visible` true the first time the tile nears the
|
||||
// viewport, then stops observing — the blob is kept once loaded.
|
||||
function lazyload(node: HTMLElement) {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting) {
|
||||
visible = true;
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ rootMargin: '200px' }
|
||||
);
|
||||
observer.observe(node);
|
||||
return {
|
||||
destroy() {
|
||||
observer.disconnect();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Thumbnails are auth-gated, so fetch with the bearer token and render the blob
|
||||
// (mirrors FileCard's loader). Re-runs whenever the id changes.
|
||||
// (mirrors FileCard's loader). Runs once visible; re-runs whenever the id changes.
|
||||
$effect(() => {
|
||||
if (!visible) return;
|
||||
const token = get(authStore).accessToken;
|
||||
const currentId = id; // track id so a reused node refetches on change
|
||||
let objectUrl: string | null = null;
|
||||
let cancelled = false;
|
||||
imgSrc = null;
|
||||
failed = false;
|
||||
|
||||
fetch(`/api/v1/files/${id}/thumbnail`, {
|
||||
fetch(`/api/v1/files/${currentId}/thumbnail`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||
})
|
||||
.then((res) => (res.ok ? res.blob() : null))
|
||||
@@ -47,7 +73,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="thumb" style="width:{size}px;height:{size}px">
|
||||
<div class="thumb" use:lazyload style="width:{size}px;height:{size}px">
|
||||
{#if imgSrc}
|
||||
<img src={imgSrc} {alt} draggable="false" />
|
||||
{:else if failed}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: [] };
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,36 +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 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);
|
||||
|
||||
// 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() {
|
||||
@@ -38,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 {
|
||||
@@ -53,54 +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 openMerge(c: DuplicateCluster, other: File) {
|
||||
// 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 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>
|
||||
@@ -146,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 -->
|
||||
@@ -160,7 +253,34 @@
|
||||
onclick={() => setKeeper(c, f.id)}
|
||||
title="Click to keep this one"
|
||||
>
|
||||
<div class="thumbwrap">
|
||||
<Thumb id={f.id} size={96} alt={f.original_name ?? ''} />
|
||||
<button
|
||||
class="zoom"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
openViewer(c, f.id);
|
||||
}}
|
||||
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" />
|
||||
<path
|
||||
d="M10 10l3.5 3.5"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M6.5 4.5v4M4.5 6.5h4"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.3"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{#if f.id === keep}<span class="kbadge">Keep</span>{/if}
|
||||
<span class="fname" title={f.original_name ?? ''}>{f.original_name ?? '—'}</span>
|
||||
<span class="fmeta">{f.mime_type} · {f.tags?.length ?? 0} tags</span>
|
||||
@@ -170,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>
|
||||
@@ -191,6 +323,19 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{#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}
|
||||
<DuplicateMergeDialog
|
||||
keep={mergeKeep}
|
||||
@@ -300,6 +445,40 @@
|
||||
border-color: var(--color-accent);
|
||||
background-color: color-mix(in srgb, var(--color-accent) 10%, transparent);
|
||||
}
|
||||
.thumbwrap {
|
||||
position: relative;
|
||||
line-height: 0;
|
||||
}
|
||||
.zoom {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background-color: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
/* Always show the zoom on touch (no hover); reveal on hover for pointers. */
|
||||
.thumbwrap:hover .zoom,
|
||||
.zoom:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
@media (hover: none) {
|
||||
.zoom {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.zoom:hover {
|
||||
background-color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
.kbadge {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
@@ -340,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;
|
||||
@@ -374,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>
|
||||
|
||||
@@ -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,35 +781,53 @@
|
||||
</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;
|
||||
}}
|
||||
<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
|
||||
>
|
||||
<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">
|
||||
<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="M2 2l10 10M12 2L2 12"
|
||||
d="M3 3l10 10M13 3L3 13"
|
||||
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>
|
||||
</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
|
||||
@@ -721,10 +835,7 @@
|
||||
{#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;
|
||||
|
||||
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 36 KiB |
@@ -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"
|
||||
|
||||
@@ -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 (0–64) 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
|
||||
|
||||