Compare commits
23 Commits
d7bfe6b596
...
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 |
@@ -62,6 +62,15 @@ JWT_REFRESH_TTL=720h
|
|||||||
# long as a viewing session lasts.
|
# long as a viewing session lasts.
|
||||||
CONTENT_TOKEN_TTL=6h
|
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,
|
# 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
|
# 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
|
# covers loopback and the Docker bridge ranges a host nginx reaches the container
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ jobs:
|
|||||||
# existing clone in /opt/tanabata. See docs/DEPLOY.md for runner setup.
|
# 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
|
# 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
|
runs-on: host
|
||||||
|
|
||||||
env:
|
env:
|
||||||
@@ -35,6 +37,47 @@ jobs:
|
|||||||
git fetch --prune origin
|
git fetch --prune origin
|
||||||
git reset --hard origin/master
|
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
|
- name: Build image and start the stack
|
||||||
working-directory: /opt/tanabata
|
working-directory: /opt/tanabata
|
||||||
# .env must already exist in DEPLOY_DIR on the host (secrets + DB mode).
|
# .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)
|
## 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/GO_PROJECT_STRUCTURE.md` — backend architecture, layer rules, DI pattern
|
||||||
- `docs/FRONTEND_STRUCTURE.md` — frontend architecture, CSS approach, API client
|
- `docs/FRONTEND_STRUCTURE.md` — frontend architecture, CSS approach, API client
|
||||||
- `docs/Описание.md` — product requirements in Russian
|
- `backend/migrations/` — database schema as goose migrations (4 schemas, 19 tables)
|
||||||
- `backend/migrations/001_init.sql` — database schema (4 schemas, 16 tables)
|
|
||||||
|
|
||||||
## Design reference
|
## Design reference
|
||||||
|
|
||||||
Visual design tokens for the frontend (carried over from the previous
|
Visual design tokens for the frontend (carried over from the previous
|
||||||
Python/Flask version):
|
Python/Flask version):
|
||||||
|
|
||||||
- Color palette: #312F45 (bg), #9592B5 (accent), #444455 (tag default), #111118 (elevated)
|
- Color palette: #312F45 (bg), #9592B5 (accent), #444455 (tag default), #111118 (elevated)
|
||||||
- Font: Epilogue (variable weight)
|
- Font: Epilogue (variable weight)
|
||||||
- Dark theme is primary
|
- Dark theme is primary
|
||||||
@@ -32,6 +34,7 @@ Python/Flask version):
|
|||||||
- Floating selection bar for multi-select
|
- Floating selection bar for multi-select
|
||||||
|
|
||||||
## Backend commands
|
## Backend commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
go run ./cmd/server # run dev server
|
go run ./cmd/server # run dev server
|
||||||
@@ -39,6 +42,7 @@ go test ./... # run all tests
|
|||||||
```
|
```
|
||||||
|
|
||||||
## Frontend commands
|
## Frontend commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
npm run dev # vite dev server
|
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
|
## Documentation
|
||||||
|
|
||||||
- [`openapi.yaml`](openapi.yaml) — full REST API specification
|
- [`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/DEPLOY.md`](docs/DEPLOY.md) — production deploy (Gitea Actions → host)
|
||||||
- [`docs/GO_PROJECT_STRUCTURE.md`](docs/GO_PROJECT_STRUCTURE.md) — backend architecture
|
- [`docs/GO_PROJECT_STRUCTURE.md`](docs/GO_PROJECT_STRUCTURE.md) — backend architecture
|
||||||
- [`docs/FRONTEND_STRUCTURE.md`](docs/FRONTEND_STRUCTURE.md) — frontend 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 build # production build
|
||||||
npm run generate:types # regenerate API types from openapi.yaml
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/stdlib"
|
"github.com/jackc/pgx/v5/stdlib"
|
||||||
@@ -142,9 +145,35 @@ func main() {
|
|||||||
IdleTimeout: 120 * time.Second,
|
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)
|
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)
|
slog.Error("server error", "err", err)
|
||||||
os.Exit(1)
|
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
|
// 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.
|
// plausibly lasts — it is a bearer credential for that one file until expiry.
|
||||||
ContentTokenTTL time.Duration
|
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
|
// TrustedProxies lists the reverse-proxy hops (CIDRs or IPs) whose
|
||||||
// X-Forwarded-For header is trusted. The auth rate limiter keys on the
|
// 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
|
// 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"),
|
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"),
|
TrustedProxies: parseCSV("TRUSTED_PROXIES", "127.0.0.1/32,::1/128,172.16.0.0/12"),
|
||||||
|
|
||||||
AdminUsername: defaultStr("ADMIN_USERNAME", "admin"),
|
AdminUsername: defaultStr("ADMIN_USERNAME", "admin"),
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ type poolRow struct {
|
|||||||
CreatorID int16 `db:"creator_id"`
|
CreatorID int16 `db:"creator_id"`
|
||||||
CreatorName string `db:"creator_name"`
|
CreatorName string `db:"creator_name"`
|
||||||
IsPublic bool `db:"is_public"`
|
IsPublic bool `db:"is_public"`
|
||||||
|
SortKey string `db:"sort_key"`
|
||||||
|
SortOrder string `db:"sort_order"`
|
||||||
FileCount int `db:"file_count"`
|
FileCount int `db:"file_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +70,8 @@ func toPool(r poolRow) domain.Pool {
|
|||||||
CreatorID: r.CreatorID,
|
CreatorID: r.CreatorID,
|
||||||
CreatorName: r.CreatorName,
|
CreatorName: r.CreatorName,
|
||||||
IsPublic: r.IsPublic,
|
IsPublic: r.IsPublic,
|
||||||
|
SortKey: r.SortKey,
|
||||||
|
SortOrder: r.SortOrder,
|
||||||
FileCount: r.FileCount,
|
FileCount: r.FileCount,
|
||||||
CreatedAt: domain.UUIDCreatedAt(r.ID),
|
CreatedAt: domain.UUIDCreatedAt(r.ID),
|
||||||
}
|
}
|
||||||
@@ -103,8 +107,14 @@ func toPoolFile(r poolFileRow) domain.PoolFile {
|
|||||||
// Cursor
|
// 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 {
|
type poolFileCursor struct {
|
||||||
Position int `json:"p"`
|
Pos int `json:"p,omitempty"`
|
||||||
|
Val string `json:"v,omitempty"`
|
||||||
FileID string `json:"id"`
|
FileID string `json:"id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +145,7 @@ const poolCountSubquery = `(SELECT pool_id, COUNT(*) AS cnt FROM data.file_pool
|
|||||||
const poolSelectFrom = `
|
const poolSelectFrom = `
|
||||||
SELECT p.id, p.name, p.notes, p.metadata,
|
SELECT p.id, p.name, p.notes, p.metadata,
|
||||||
p.creator_id, u.name AS creator_name, p.is_public,
|
p.creator_id, u.name AS creator_name, p.is_public,
|
||||||
|
p.sort_key, p.sort_order,
|
||||||
COALESCE(fc.cnt, 0) AS file_count
|
COALESCE(fc.cnt, 0) AS file_count
|
||||||
FROM data.pools p
|
FROM data.pools p
|
||||||
JOIN core.users u ON u.id = p.creator_id
|
JOIN core.users u ON u.id = p.creator_id
|
||||||
@@ -147,6 +158,29 @@ func poolSortColumn(s string) string {
|
|||||||
return "p.id" // "created"
|
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
|
// PoolRepo
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -207,6 +241,7 @@ func (r *PoolRepo) List(ctx context.Context, params port.OffsetParams) (*domain.
|
|||||||
query := fmt.Sprintf(`
|
query := fmt.Sprintf(`
|
||||||
SELECT p.id, p.name, p.notes, p.metadata,
|
SELECT p.id, p.name, p.notes, p.metadata,
|
||||||
p.creator_id, u.name AS creator_name, p.is_public,
|
p.creator_id, u.name AS creator_name, p.is_public,
|
||||||
|
p.sort_key, p.sort_order,
|
||||||
COALESCE(fc.cnt, 0) AS file_count,
|
COALESCE(fc.cnt, 0) AS file_count,
|
||||||
COUNT(*) OVER() AS total
|
COUNT(*) OVER() AS total
|
||||||
FROM data.pools p
|
FROM data.pools p
|
||||||
@@ -289,6 +324,7 @@ WITH ins AS (
|
|||||||
)
|
)
|
||||||
SELECT ins.id, ins.name, ins.notes, ins.metadata,
|
SELECT ins.id, ins.name, ins.notes, ins.metadata,
|
||||||
ins.creator_id, u.name AS creator_name, ins.is_public,
|
ins.creator_id, u.name AS creator_name, ins.is_public,
|
||||||
|
ins.sort_key, ins.sort_order,
|
||||||
0 AS file_count
|
0 AS file_count
|
||||||
FROM ins
|
FROM ins
|
||||||
JOIN core.users u ON u.id = ins.creator_id`
|
JOIN core.users u ON u.id = ins.creator_id`
|
||||||
@@ -325,12 +361,15 @@ WITH upd AS (
|
|||||||
name = $2,
|
name = $2,
|
||||||
notes = $3,
|
notes = $3,
|
||||||
metadata = COALESCE($4, metadata),
|
metadata = COALESCE($4, metadata),
|
||||||
is_public = $5
|
is_public = $5,
|
||||||
|
sort_key = $6,
|
||||||
|
sort_order = $7
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING *
|
RETURNING *
|
||||||
)
|
)
|
||||||
SELECT upd.id, upd.name, upd.notes, upd.metadata,
|
SELECT upd.id, upd.name, upd.notes, upd.metadata,
|
||||||
upd.creator_id, u.name AS creator_name, upd.is_public,
|
upd.creator_id, u.name AS creator_name, upd.is_public,
|
||||||
|
upd.sort_key, upd.sort_order,
|
||||||
COALESCE(fc.cnt, 0) AS file_count
|
COALESCE(fc.cnt, 0) AS file_count
|
||||||
FROM upd
|
FROM upd
|
||||||
JOIN core.users u ON u.id = upd.creator_id
|
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)
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("PoolRepo.Update: %w", err)
|
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.
|
// Resolve the pool's sort setting (defaulting to manual position order) into
|
||||||
var orderBy string
|
// 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 != "" {
|
if params.Cursor != "" {
|
||||||
cur, err := decodePoolCursor(params.Cursor)
|
cur, err := decodePoolCursor(params.Cursor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -423,13 +470,36 @@ func (r *PoolRepo) ListFiles(ctx context.Context, poolID uuid.UUID, params port.
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, domain.ErrValidation
|
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(
|
conds = append(conds, fmt.Sprintf(
|
||||||
"(fp.position > $%d OR (fp.position = $%d AND fp.file_id > $%d))",
|
"(%s %s $%d OR (%s = $%d AND f.id %s $%d))", col, cmp, n, col, n, cmp, n+1))
|
||||||
n, n, n+1))
|
switch sortKey {
|
||||||
args = append(args, cur.Position, fileID)
|
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
|
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 ")
|
where := "WHERE " + strings.Join(conds, " AND ")
|
||||||
args = append(args, limit+1)
|
args = append(args, limit+1)
|
||||||
@@ -468,11 +538,21 @@ LIMIT $%d`, fileSelectForPool, where, orderBy, n)
|
|||||||
|
|
||||||
if hasMore && len(collected) > 0 {
|
if hasMore && len(collected) > 0 {
|
||||||
last := collected[len(collected)-1]
|
last := collected[len(collected)-1]
|
||||||
cur := encodePoolCursor(poolFileCursor{
|
cursor := poolFileCursor{FileID: last.ID.String()}
|
||||||
Position: last.Position,
|
switch sortKey {
|
||||||
FileID: last.ID.String(),
|
case domain.PoolSortContentDatetime:
|
||||||
})
|
cursor.Val = last.ContentDatetime.UTC().Format(time.RFC3339Nano)
|
||||||
page.NextCursor = &cur
|
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.
|
// Batch-load tags.
|
||||||
|
|||||||
@@ -7,6 +7,32 @@ import (
|
|||||||
"github.com/google/uuid"
|
"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.
|
// Pool is an ordered collection of files.
|
||||||
type Pool struct {
|
type Pool struct {
|
||||||
ID uuid.UUID
|
ID uuid.UUID
|
||||||
@@ -16,6 +42,11 @@ type Pool struct {
|
|||||||
CreatorID int16
|
CreatorID int16
|
||||||
CreatorName string // denormalized
|
CreatorName string // denormalized
|
||||||
IsPublic bool
|
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
|
FileCount int
|
||||||
CreatedAt time.Time // extracted from UUID v7 via UUIDCreatedAt
|
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))
|
items := make([]gin.H, len(clusters))
|
||||||
for i, files := range clusters {
|
for i, cl := range clusters {
|
||||||
fs := make([]fileJSON, len(files))
|
fs := make([]fileJSON, len(cl.Files))
|
||||||
for j, f := range files {
|
for j, f := range cl.Files {
|
||||||
fs[j] = toFileJSON(f)
|
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{
|
respondJSON(c, http.StatusOK, gin.H{
|
||||||
"items": items,
|
"items": items,
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ type poolJSON struct {
|
|||||||
CreatorID int16 `json:"creator_id"`
|
CreatorID int16 `json:"creator_id"`
|
||||||
CreatorName string `json:"creator_name"`
|
CreatorName string `json:"creator_name"`
|
||||||
IsPublic bool `json:"is_public"`
|
IsPublic bool `json:"is_public"`
|
||||||
|
SortKey string `json:"sort_key"`
|
||||||
|
SortOrder string `json:"sort_order"`
|
||||||
FileCount int `json:"file_count"`
|
FileCount int `json:"file_count"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
@@ -51,6 +53,8 @@ func toPoolJSON(p domain.Pool) poolJSON {
|
|||||||
CreatorID: p.CreatorID,
|
CreatorID: p.CreatorID,
|
||||||
CreatorName: p.CreatorName,
|
CreatorName: p.CreatorName,
|
||||||
IsPublic: p.IsPublic,
|
IsPublic: p.IsPublic,
|
||||||
|
SortKey: p.SortKey,
|
||||||
|
SortOrder: p.SortOrder,
|
||||||
FileCount: p.FileCount,
|
FileCount: p.FileCount,
|
||||||
CreatedAt: p.CreatedAt.UTC().Format(time.RFC3339),
|
CreatedAt: p.CreatedAt.UTC().Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
@@ -214,6 +218,16 @@ func (h *PoolHandler) Update(c *gin.Context) {
|
|||||||
params.IsPublic = &b
|
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)
|
updated, err := h.poolSvc.Update(c.Request.Context(), id, params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/jpeg"
|
||||||
"io"
|
"io"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net"
|
"net"
|
||||||
@@ -24,6 +26,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -1575,41 +1578,25 @@ func TestPoolOperationsRequireACL(t *testing.T) {
|
|||||||
// Test helpers
|
// Test helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// minimalJPEG returns the bytes of a 1×1 white JPEG image.
|
// minimalJPEG returns the bytes of a small solid-white JPEG produced by the
|
||||||
// Generated offline; no external dependency needed.
|
// 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 {
|
func minimalJPEG() []byte {
|
||||||
// This is a valid minimal JPEG: SOI + APP0 + DQT + SOF0 + DHT + SOS + EOI.
|
img := image.NewGray(image.Rect(0, 0, 16, 16))
|
||||||
// 1×1 white pixel, quality ~50. Verified with `file` and browsers.
|
for i := range img.Pix {
|
||||||
return []byte{
|
img.Pix[i] = 0xff // white
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
|
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.
|
// 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
|
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
|
return dsn
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1661,6 +1654,11 @@ type dupListResponse struct {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
} `json:"tags"`
|
} `json:"tags"`
|
||||||
} `json:"files"`
|
} `json:"files"`
|
||||||
|
Distances []struct {
|
||||||
|
A string `json:"a"`
|
||||||
|
B string `json:"b"`
|
||||||
|
Distance int `json:"distance"`
|
||||||
|
} `json:"distances"`
|
||||||
} `json:"items"`
|
} `json:"items"`
|
||||||
Total int `json:"total"`
|
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.Equal(t, 1, list.Total, "expected one duplicate cluster: %s", resp)
|
||||||
require.Len(t, list.Items, 1)
|
require.Len(t, list.Items, 1)
|
||||||
require.Len(t, list.Items[0].Files, 2)
|
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 ----------------------
|
// --- resolve: keep f1, union tags from f2, trash f2 ----------------------
|
||||||
resp = h.doJSON("POST", "/files/duplicates/resolve", map[string]any{
|
resp = h.doJSON("POST", "/files/duplicates/resolve", map[string]any{
|
||||||
@@ -1751,3 +1752,115 @@ func TestDuplicateDetection(t *testing.T) {
|
|||||||
resp.decode(t, &list)
|
resp.decode(t, &list)
|
||||||
assert.Equal(t, 0, list.Total, "dismissal must survive a rescan")
|
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
|
Cursor string
|
||||||
Limit int
|
Limit int
|
||||||
Filter string // filter DSL expression
|
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.
|
// FileRepo is the persistence interface for file records.
|
||||||
|
|||||||
@@ -103,6 +103,31 @@ func buildPairs(entries []domain.PHashEntry, threshold int, onProgress func(done
|
|||||||
return pairs
|
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
|
// clusterPairs groups pairs into connected components (transitive closure) via
|
||||||
// union-find. Every returned cluster has at least two files; clusters and the ids
|
// union-find. Every returned cluster has at least two files; clusters and the ids
|
||||||
// within them are sorted by UUID for stable pagination.
|
// 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
|
// 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
|
// read from the precomputed table (no all-pairs scan here) and grouped into
|
||||||
// connected components; pagination is over whole clusters.
|
// connected components; pagination is over whole clusters. Each cluster carries
|
||||||
func (s *DuplicateService) Clusters(ctx context.Context, limit, offset int) (clusters [][]domain.File, total int, err error) {
|
// 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)
|
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
||||||
|
|
||||||
pairs, err := s.pairs.ListVisible(ctx, userID, isAdmin)
|
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
|
offset = 0
|
||||||
}
|
}
|
||||||
if offset >= len(groups) {
|
if offset >= len(groups) {
|
||||||
return [][]domain.File{}, total, nil
|
return []Cluster{}, total, nil
|
||||||
}
|
}
|
||||||
end := offset + limit
|
end := offset + limit
|
||||||
if end > len(groups) || limit <= 0 {
|
if end > len(groups) || limit <= 0 {
|
||||||
end = len(groups)
|
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] {
|
for _, ids := range groups[offset:end] {
|
||||||
files := make([]domain.File, 0, len(ids))
|
files := make([]domain.File, 0, len(ids))
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
@@ -164,7 +187,7 @@ func (s *DuplicateService) Clusters(ctx context.Context, limit, offset int) (clu
|
|||||||
files = append(files, *f)
|
files = append(files, *f)
|
||||||
}
|
}
|
||||||
if len(files) >= 2 {
|
if len(files) >= 2 {
|
||||||
out = append(out, files)
|
out = append(out, Cluster{Files: files, Distances: clusterDistances(files, distByPair)})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out, total, nil
|
return out, total, nil
|
||||||
|
|||||||
@@ -286,7 +286,17 @@ func (s *FileService) Update(ctx context.Context, id uuid.UUID, p UpdateParams)
|
|||||||
return nil, domain.ErrForbidden
|
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 {
|
if p.OriginalName != nil {
|
||||||
patch.OriginalName = p.OriginalName
|
patch.OriginalName = p.OriginalName
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,12 +13,15 @@ import (
|
|||||||
const poolObjectType = "pool"
|
const poolObjectType = "pool"
|
||||||
const poolObjectTypeID int16 = 4 // fourth row in 007_seed_data.sql object_types
|
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 {
|
type PoolParams struct {
|
||||||
Name string
|
Name string
|
||||||
Notes *string
|
Notes *string
|
||||||
Metadata json.RawMessage
|
Metadata json.RawMessage
|
||||||
IsPublic *bool
|
IsPublic *bool
|
||||||
|
SortKey *string
|
||||||
|
SortOrder *string
|
||||||
}
|
}
|
||||||
|
|
||||||
// PoolService handles pool CRUD and pool–file management with ACL + audit.
|
// 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 {
|
if p.IsPublic != nil {
|
||||||
patch.IsPublic = *p.IsPublic
|
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)
|
updated, err := s.pools.Update(ctx, id, &patch)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -205,12 +220,24 @@ func (s *PoolService) Delete(ctx context.Context, id uuid.UUID) error {
|
|||||||
// Pool–file operations
|
// Pool–file operations
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// ListFiles returns cursor-paginated files within a pool ordered by position,
|
// ListFiles returns cursor-paginated files within a pool, enforcing view ACL.
|
||||||
// enforcing view ACL on the pool.
|
// 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) {
|
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
|
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)
|
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
|
// 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 {
|
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
|
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 {
|
if err := s.pools.Reorder(ctx, poolID, fileIDs); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,8 +76,17 @@ CREATE TABLE data.pools (
|
|||||||
creator_id smallint NOT NULL REFERENCES core.users(id)
|
creator_id smallint NOT NULL REFERENCES core.users(id)
|
||||||
ON UPDATE CASCADE ON DELETE RESTRICT,
|
ON UPDATE CASCADE ON DELETE RESTRICT,
|
||||||
is_public boolean NOT NULL DEFAULT false,
|
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
|
-- `position` uses integer with gaps (e.g. 1000, 2000, 3000) to allow
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ services:
|
|||||||
container_name: tfm
|
container_name: tfm
|
||||||
restart: unless-stopped
|
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.
|
# All application config (secrets, DATABASE_URL, tunables) comes from .env.
|
||||||
env_file: .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)
|
Tanabata is deployed by a [Gitea Actions](https://docs.gitea.com/usage/actions/overview)
|
||||||
workflow ([`.gitea/workflows/deploy.yml`](../.gitea/workflows/deploy.yml)) that
|
workflow ([`.gitea/workflows/deploy.yml`](../.gitea/workflows/deploy.yml)) that
|
||||||
runs on the **production host itself**. On every push to `master` it updates the
|
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
|
git clone in `/opt/tanabata`, runs the test suite (backend + frontend, in
|
||||||
image is built from the freshly-pushed code and the stack is restarted.
|
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)
|
push master ──> Gitea (container) ──> act_runner (host, "host" label)
|
||||||
│ git fetch + reset --hard (in /opt/tanabata)
|
│ 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**
|
The Gitea server runs in a container, but the **runner runs directly on the host**
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
# Tanabata File Manager — Frontend Structure
|
# 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
|
## Stack
|
||||||
|
|
||||||
- **Framework**: SvelteKit (SPA mode, `ssr: false`)
|
- **Framework**: SvelteKit in **SPA mode** (`adapter-static`, `ssr = false`),
|
||||||
- **Language**: TypeScript
|
Svelte 5 (runes)
|
||||||
- **CSS**: Tailwind CSS + CSS custom properties (hybrid)
|
- **Language**: TypeScript (strict)
|
||||||
- **API types**: Auto-generated via openapi-typescript
|
- **Build**: Vite 7
|
||||||
- **PWA**: Service worker + web manifest
|
- **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)
|
- **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
|
- **Package manager**: npm
|
||||||
|
|
||||||
## SPA mode — why SvelteKit without the server
|
## 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
|
purely as an SPA framework: file-based routing, the client router, and build
|
||||||
tooling.
|
tooling.
|
||||||
|
|
||||||
**SvelteKit features we *do* use:**
|
**SvelteKit features we _do_ use:**
|
||||||
|
|
||||||
- File-based routing with nested layouts (`admin/` has its own guard) and
|
- File-based routing with nested layouts (`admin/` has its own guard) and
|
||||||
dynamic segments (`[id]`).
|
dynamic segments (`[id]`).
|
||||||
@@ -30,11 +38,11 @@ tooling.
|
|||||||
over the still-mounted list via shallow routing, so the browser back button
|
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
|
dismisses it without reloading the grid. This is the single biggest reason we
|
||||||
stay on SvelteKit rather than a plain router.
|
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`).
|
admin redirect, `/` → `/files`).
|
||||||
- `$lib` alias, generated `./$types`, Vite/HMR integration.
|
- `$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.
|
- SSR / hydration.
|
||||||
- `+page.server.ts`, `+server.ts` endpoints, form actions — all data goes
|
- `+page.server.ts`, `+server.ts` endpoints, form actions — all data goes
|
||||||
@@ -43,7 +51,7 @@ tooling.
|
|||||||
`hooks.client.ts` files exist.
|
`hooks.client.ts` files exist.
|
||||||
|
|
||||||
**Decision: stay on SvelteKit, do not migrate to a bare Svelte + router SPA.**
|
**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
|
(adapter-static tree-shakes the unused server bits; the client-runtime size
|
||||||
difference is negligible). A migration would mean re-implementing nested
|
difference is negligible). A migration would mean re-implementing nested
|
||||||
layouts, guards, dynamic params, and — most painfully — shallow routing /
|
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/
|
tanabata/
|
||||||
├── backend/ ← Go project (go.mod in here)
|
├── backend/ ← Go project (go.mod in here)
|
||||||
│ ├── cmd/
|
|
||||||
│ ├── internal/
|
|
||||||
│ ├── migrations/
|
|
||||||
│ ├── go.mod
|
|
||||||
│ └── go.sum
|
|
||||||
│
|
|
||||||
├── frontend/ ← SvelteKit project (package.json in here)
|
├── frontend/ ← SvelteKit project (package.json in here)
|
||||||
│ └── (see below)
|
|
||||||
│
|
|
||||||
├── openapi.yaml ← Shared API contract (root level)
|
├── openapi.yaml ← Shared API contract (root level)
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
├── Dockerfile
|
├── Dockerfile
|
||||||
@@ -71,306 +71,173 @@ tanabata/
|
|||||||
└── README.md
|
└── README.md
|
||||||
```
|
```
|
||||||
|
|
||||||
`openapi.yaml` lives at repository root — both backend and frontend
|
`openapi.yaml` lives at repository root — both backend and frontend reference
|
||||||
reference it. The frontend generates types from it; the backend
|
it. The frontend generates types from it; the backend implements it.
|
||||||
validates its handlers against it.
|
|
||||||
|
|
||||||
## Frontend Directory Layout
|
## Frontend Directory Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
frontend/
|
frontend/
|
||||||
├── package.json
|
├── package.json
|
||||||
├── svelte.config.js
|
├── svelte.config.js # adapter-static, fallback: index.html
|
||||||
├── vite.config.ts
|
├── vite.config.ts # plugins: tailwindcss(), sveltekit(), mockApiPlugin()
|
||||||
|
├── vite-mock-plugin.ts # dev-only mock API (run the UI without the Go backend)
|
||||||
├── tsconfig.json
|
├── tsconfig.json
|
||||||
├── tailwind.config.ts
|
|
||||||
├── postcss.config.js
|
|
||||||
│
|
│
|
||||||
├── src/
|
├── static/ # Copied verbatim into the build
|
||||||
│ ├── app.html # Shell HTML (PWA meta, font preload)
|
│ ├── manifest.webmanifest # PWA manifest
|
||||||
│ ├── app.css # Tailwind directives + CSS custom properties
|
│ ├── browserconfig.xml
|
||||||
│ │ # (no hooks.* — see "SPA mode" above)
|
│ ├── robots.txt
|
||||||
│ │
|
│ ├── favicon.ico
|
||||||
│ ├── lib/ # Shared code ($lib/ alias)
|
│ ├── fonts/
|
||||||
│ │ │
|
│ │ └── Epilogue-VariableFont_wght.ttf
|
||||||
│ │ ├── api/ # API client layer
|
│ └── images/ # PWA icons, section icons (svg), login decorations
|
||||||
│ │ │ ├── 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/
|
└── src/
|
||||||
├── favicon.png
|
├── app.html # Shell HTML (PWA meta, font preload)
|
||||||
├── favicon.ico
|
├── app.css # `@import 'tailwindcss'` + `@theme` custom properties
|
||||||
├── manifest.webmanifest # PWA manifest (name, icons, theme_color)
|
├── app.d.ts # Ambient types
|
||||||
├── images/
|
├── service-worker.ts # PWA: app-shell + pinned-file offline cache
|
||||||
│ ├── tanabata-left.png # Login page decorations (from current design)
|
│
|
||||||
│ ├── tanabata-right.png
|
├── lib/ # Shared code ($lib alias)
|
||||||
│ └── icons/ # PWA icons (192×192, 512×512, etc.)
|
│ ├── index.ts
|
||||||
└── fonts/
|
│ │
|
||||||
└── Epilogue-VariableFont_wght.ttf
|
│ ├── 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
|
## 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
|
```css
|
||||||
@tailwind base;
|
/* src/app.css */
|
||||||
@tailwind components;
|
@import "tailwindcss";
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
:root {
|
@theme {
|
||||||
--color-bg-primary: #312F45;
|
--color-bg-primary: #312f45;
|
||||||
--color-bg-secondary: #181721;
|
--color-bg-secondary: #181721;
|
||||||
--color-bg-elevated: #111118;
|
--color-bg-elevated: #111118;
|
||||||
--color-accent: #9592B5;
|
--color-accent: #9592b5;
|
||||||
--color-accent-hover: #7D7AA4;
|
--color-accent-hover: #7d7aa4;
|
||||||
--color-text-primary: #f0f0f0;
|
--color-text-primary: #f0f0f0;
|
||||||
--color-text-muted: #9999AD;
|
|
||||||
--color-danger: #DB6060;
|
|
||||||
--color-info: #4DC7ED;
|
|
||||||
--color-warning: #F5E872;
|
|
||||||
--color-tag-default: #444455;
|
--color-tag-default: #444455;
|
||||||
}
|
/* … info / danger / warning / success / nav tokens … */
|
||||||
|
|
||||||
:root[data-theme="light"] {
|
--font-sans: "Epilogue", sans-serif;
|
||||||
--color-bg-primary: #f5f5f5;
|
|
||||||
--color-bg-secondary: #ffffff;
|
|
||||||
/* ... */
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Tailwind references them in `tailwind.config.ts`:
|
Dark theme is primary; the light theme overrides the same custom properties.
|
||||||
|
|
||||||
```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
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Usage in components: `<div class="bg-bg-primary text-text-primary rounded-xl p-4">`.
|
Usage in components: `<div class="bg-bg-primary text-text-primary rounded-xl p-4">`.
|
||||||
Complex cases use scoped `<style>` inside `.svelte` files.
|
Complex cases use scoped `<style>` inside `.svelte` files.
|
||||||
|
|
||||||
### API Client Pattern
|
### 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
|
```ts
|
||||||
// $lib/api/client.ts
|
// $lib/api/client.ts (shape)
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
get: <T>(path: string) => request<T>(path),
|
get: <T>(path) => request<T>(path),
|
||||||
post: <T>(path: string, body?: unknown) =>
|
post: <T>(path, body?) =>
|
||||||
request<T>(path, { method: 'POST', body: JSON.stringify(body) }),
|
request<T>(path, { method: "POST", body: JSON.stringify(body) }),
|
||||||
patch: <T>(path: string, body?: unknown) =>
|
patch: <T>(path, body?) =>
|
||||||
request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
|
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
|
||||||
put: <T>(path: string, body?: unknown) =>
|
put: <T>(path, body?) =>
|
||||||
request<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
|
request<T>(path, { method: "PUT", body: JSON.stringify(body) }),
|
||||||
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
delete: <T>(path) => request<T>(path, { method: "DELETE" }),
|
||||||
upload: <T>(path: string, formData: FormData) =>
|
upload: <T>(path, fd) => request<T>(path, { method: "POST", body: fd }),
|
||||||
request<T>(path, { method: 'POST', body: formData, headers: {} }),
|
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
Domain-specific modules use it:
|
Resource modules (`auth.ts`, `tags.ts`, `categories.ts`, `duplicates.ts`) wrap
|
||||||
|
`api` with typed helpers. Endpoints without a dedicated module (files, pools,
|
||||||
```ts
|
users, acl, audit) are called through `api.*` directly from their route
|
||||||
// $lib/api/files.ts
|
components.
|
||||||
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);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Type Generation
|
### Type Generation
|
||||||
|
|
||||||
Script in `package.json`:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
// package.json
|
||||||
{
|
{
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"generate:types": "openapi-typescript ../openapi.yaml -o src/lib/api/schema.ts",
|
"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
|
```ts
|
||||||
import type { components } from './schema';
|
import type { components } from "./schema";
|
||||||
|
export type File = components["schemas"]["File"];
|
||||||
export type File = components['schemas']['File'];
|
export type Tag = components["schemas"]["Tag"];
|
||||||
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'];
|
|
||||||
// ...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### SPA Mode
|
### SPA Mode (build)
|
||||||
|
|
||||||
`svelte.config.js`:
|
|
||||||
|
|
||||||
```js
|
```js
|
||||||
import adapter from '@sveltejs/adapter-static';
|
// svelte.config.js
|
||||||
|
import adapter from "@sveltejs/adapter-static";
|
||||||
export default {
|
export default { kit: { adapter: adapter({ fallback: "index.html" }) } };
|
||||||
kit: {
|
|
||||||
adapter: adapter({ fallback: 'index.html' }),
|
|
||||||
// SPA: all routes handled client-side
|
|
||||||
},
|
|
||||||
};
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The Go backend serves `index.html` for all non-API routes (SPA fallback).
|
The Go backend serves `index.html` for all non-API routes (SPA fallback, see
|
||||||
In development, Vite dev server proxies `/api` to the Go backend.
|
`handler/static.go`). In development the Vite dev server serves the UI and the
|
||||||
|
mock plugin (or a proxied Go backend) answers `/api`.
|
||||||
|
|
||||||
### PWA
|
### PWA
|
||||||
|
|
||||||
`service-worker.ts` handles:
|
`service-worker.ts` handles app-shell caching (HTML/CSS/JS/fonts) and optional
|
||||||
- App shell caching (HTML, CSS, JS, fonts)
|
user-pinned file caching for offline viewing; `utils/pwa.ts` exposes the reset /
|
||||||
- User-pinned file caching (explicit, via UI button)
|
update flow (clear caches and reload from the server, keeping pinned files).
|
||||||
- Cache versioning and cleanup on update
|
|
||||||
- Reset function (clear all caches except pinned files)
|
|
||||||
|
|||||||
@@ -1,18 +1,28 @@
|
|||||||
# Tanabata File Manager — Go Project Structure
|
# 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
|
## Stack
|
||||||
|
|
||||||
- **Router**: Gin
|
- **Router**: Gin
|
||||||
- **Database**: pgx v5 (pgxpool)
|
- **Database**: pgx v5 (pgxpool)
|
||||||
- **Migrations**: goose v3 + go:embed (auto-migrate on startup)
|
- **Migrations**: goose v3 + `go:embed` (auto-applied on startup)
|
||||||
- **Auth**: JWT (golang-jwt/jwt/v5)
|
- **Auth**: JWT (golang-jwt/jwt/v5), Bearer access tokens + rotating refresh tokens
|
||||||
- **Config**: environment variables via .env (joho/godotenv)
|
- **Config**: environment variables via `.env` (joho/godotenv)
|
||||||
- **Logging**: slog (stdlib, Go 1.21+)
|
- **Logging**: slog (stdlib)
|
||||||
- **Validation**: go-playground/validator/v10
|
- **Metadata**: exiftool (external, preferred) with a pure-Go EXIF fallback
|
||||||
- **EXIF**: rwcarlsen/goexif or dsoprea/go-exif
|
(rwcarlsen/goexif)
|
||||||
- **Image processing**: disintegration/imaging (thumbnails, previews)
|
- **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)
|
- **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
|
## Monorepo Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -31,81 +41,98 @@ tanabata/
|
|||||||
```
|
```
|
||||||
backend/
|
backend/
|
||||||
├── cmd/
|
├── cmd/
|
||||||
│ └── server/
|
│ ├── server/
|
||||||
│ └── main.go # Entrypoint: config → DB → migrate → wire → run
|
│ │ └── main.go # Entrypoint: config → DB → migrate → bootstrap admin → wire → serve
|
||||||
|
│ └── dedup/
|
||||||
|
│ └── main.go # Offline maintenance CLI: perceptual-hash backfill + duplicate-pairs rescan
|
||||||
│
|
│
|
||||||
├── internal/
|
├── internal/
|
||||||
│ │
|
│ │
|
||||||
│ ├── domain/ # Pure business entities & value objects
|
│ ├── domain/ # Pure business entities & value objects (stdlib only)
|
||||||
│ │ ├── file.go # File, FileFilter, FilePage
|
│ │ ├── file.go # File, FileFilter, FileListParams, FilePage
|
||||||
│ │ ├── tag.go # Tag, TagRule
|
│ │ ├── tag.go # Tag, TagRule
|
||||||
│ │ ├── category.go # Category
|
│ │ ├── category.go # Category
|
||||||
│ │ ├── pool.go # Pool, PoolFile
|
│ │ ├── pool.go # Pool, PoolFile
|
||||||
│ │ ├── user.go # User, Session
|
│ │ ├── user.go # User, Session
|
||||||
│ │ ├── acl.go # Permission, ObjectType
|
│ │ ├── acl.go # Permission, ObjectType
|
||||||
│ │ ├── audit.go # AuditEntry, ActionType
|
│ │ ├── 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
|
│ ├── port/ # Interfaces (ports) — contracts between layers
|
||||||
│ │ ├── repository.go # FileRepo, TagRepo, CategoryRepo, PoolRepo,
|
│ │ ├── repository.go # Transactor, FileRepo, TagRepo, TagRuleRepo, CategoryRepo,
|
||||||
│ │ │ # UserRepo, SessionRepo, ACLRepo, AuditRepo,
|
│ │ │ # PoolRepo, UserRepo, SessionRepo, ACLRepo, AuditRepo,
|
||||||
│ │ │ # MimeRepo, TagRuleRepo
|
│ │ │ # MimeRepo, DuplicatePairRepo, DismissalRepo
|
||||||
│ │ └── storage.go # FileStorage interface (disk operations)
|
│ │ └── storage.go # FileStorage (originals + thumbnail/preview cache)
|
||||||
│ │
|
│ │
|
||||||
│ ├── service/ # Business logic (use cases)
|
│ ├── service/ # Business logic (use cases)
|
||||||
│ │ ├── file_service.go # Upload, update, delete, trash/restore, replace,
|
│ │ ├── file_service.go # Upload, update, delete, trash/restore, replace, import, filter/list
|
||||||
│ │ │ # import, filter/list, duplicate detection
|
│ │ ├── tag_service.go # CRUD + auto-tag (rule) application
|
||||||
│ │ ├── tag_service.go # CRUD + auto-tag application logic
|
│ │ ├── category_service.go # CRUD (thin: repo + ACL + audit)
|
||||||
│ │ ├── category_service.go # CRUD (thin, delegates to repo + ACL + audit)
|
|
||||||
│ │ ├── pool_service.go # CRUD + file ordering, add/remove files
|
│ │ ├── 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
|
│ │ ├── acl_service.go # Permission checks, grant/revoke
|
||||||
│ │ ├── audit_service.go # Log actions, query audit log
|
│ │ ├── 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)
|
│ ├── handler/ # HTTP layer (Gin handlers)
|
||||||
│ │ ├── router.go # Route registration, middleware wiring
|
│ │ ├── router.go # Route registration, middleware, security headers, SPA fallback
|
||||||
│ │ ├── middleware.go # Auth middleware (JWT extraction → context)
|
│ │ ├── middleware.go # Auth middleware (JWT / content token → context)
|
||||||
│ │ ├── request.go # Common request parsing helpers
|
│ │ ├── ratelimit.go # Per-IP token-bucket limiter for /auth
|
||||||
│ │ ├── response.go # Error/success response builders
|
│ │ ├── response.go # Error/success builders, domain-error → HTTP mapping
|
||||||
|
│ │ ├── static.go # Built SPA serving + index.html fallback
|
||||||
│ │ ├── file_handler.go # /files endpoints
|
│ │ ├── 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
|
│ │ ├── category_handler.go # /categories endpoints
|
||||||
│ │ ├── pool_handler.go # /pools endpoints
|
│ │ ├── pool_handler.go # /pools endpoints
|
||||||
│ │ ├── auth_handler.go # /auth endpoints
|
│ │ ├── auth_handler.go # /auth endpoints
|
||||||
│ │ ├── acl_handler.go # /acl endpoints
|
│ │ ├── acl_handler.go # /acl endpoints
|
||||||
│ │ ├── user_handler.go # /users endpoints
|
│ │ ├── user_handler.go # /users endpoints
|
||||||
│ │ └── audit_handler.go # /audit endpoints
|
│ │ └── audit_handler.go # /audit endpoint
|
||||||
│ │
|
│ │
|
||||||
│ ├── db/ # Database adapters
|
│ ├── 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/ # PostgreSQL implementation
|
||||||
│ │ ├── postgres.go # pgxpool init, tx-from-context helpers
|
│ │ ├── postgres.go # pgxpool init, Transactor, conn-or-tx helper
|
||||||
│ │ ├── file_repo.go # FileRepo implementation
|
│ │ ├── file_repo.go # FileRepo (incl. perceptual-hash projections)
|
||||||
│ │ ├── tag_repo.go # TagRepo + TagRuleRepo implementation
|
│ │ ├── tag_repo.go # TagRepo + TagRuleRepo
|
||||||
│ │ ├── category_repo.go # CategoryRepo implementation
|
│ │ ├── category_repo.go # CategoryRepo
|
||||||
│ │ ├── pool_repo.go # PoolRepo implementation
|
│ │ ├── pool_repo.go # PoolRepo
|
||||||
│ │ ├── user_repo.go # UserRepo implementation
|
│ │ ├── user_repo.go # UserRepo
|
||||||
│ │ ├── session_repo.go # SessionRepo implementation
|
│ │ ├── session_repo.go # SessionRepo
|
||||||
│ │ ├── acl_repo.go # ACLRepo implementation
|
│ │ ├── acl_repo.go # ACLRepo
|
||||||
│ │ ├── audit_repo.go # AuditRepo implementation
|
│ │ ├── audit_repo.go # AuditRepo
|
||||||
│ │ ├── mime_repo.go # MimeRepo implementation
|
│ │ ├── mime_repo.go # MimeRepo
|
||||||
│ │ └── filter_parser.go # DSL → SQL WHERE clause builder
|
│ │ ├── duplicate_repo.go # DuplicatePairRepo + DismissalRepo
|
||||||
|
│ │ └── filter_parser.go # Filter DSL → SQL WHERE clause builder
|
||||||
│ │
|
│ │
|
||||||
│ ├── storage/ # File storage adapter
|
│ ├── 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/ # 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
|
│ ├── 001_init_schemas.sql
|
||||||
│ ├── 002_core_tables.sql
|
│ ├── 002_core_tables.sql
|
||||||
│ ├── 003_data_tables.sql
|
│ ├── 003_data_tables.sql
|
||||||
│ ├── 004_acl_tables.sql
|
│ ├── 004_acl_tables.sql
|
||||||
│ ├── 005_activity_tables.sql
|
│ ├── 005_activity_tables.sql
|
||||||
│ ├── 006_indexes.sql
|
│ ├── 006_indexes.sql
|
||||||
│ └── 007_seed_data.sql
|
│ ├── 007_seed_data.sql
|
||||||
|
│ └── embed.go # //go:embed *.sql → migrations.FS
|
||||||
│
|
│
|
||||||
├── go.mod
|
├── go.mod
|
||||||
└── go.sum
|
└── 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/postgres/**: imports domain/, port/, and db/ (common helpers). Implements port interfaces.
|
||||||
- **db/**: imports domain/ and port/. Shared utilities for all DB adapters.
|
- **db/**: imports domain/ and port/. Shared utilities for all DB adapters.
|
||||||
- **storage/**: imports domain/ and port/. Implements FileStorage.
|
- **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.
|
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)
|
### 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
|
```go
|
||||||
// Pseudocode
|
// Pseudocode — see cmd/server/main.go for the exact calls.
|
||||||
pool := postgres.NewPool(cfg.DatabaseURL)
|
pool := postgres.NewPool(ctx, cfg.DatabaseURL)
|
||||||
goose.Up(pool, migrations)
|
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/)
|
// Repos (all from internal/db/postgres/)
|
||||||
fileRepo := postgres.NewFileRepo(pool)
|
fileRepo := postgres.NewFileRepo(pool)
|
||||||
tagRepo := postgres.NewTagRepo(pool)
|
// … tag, tagRule, category, pool, user, session, acl, audit, mime,
|
||||||
// ...
|
// duplicatePair, dismissal repos + transactor
|
||||||
|
|
||||||
// Storage
|
|
||||||
diskStore := storage.NewDiskStorage(cfg.FilesPath)
|
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
aclSvc := service.NewACLService(aclRepo, objectTypeRepo)
|
authSvc := service.NewAuthService(userRepo, sessionRepo,
|
||||||
auditSvc := service.NewAuditService(auditRepo, actionTypeRepo)
|
cfg.JWTSecret, cfg.JWTAccessTTL, cfg.JWTRefreshTTL, cfg.ContentTokenTTL)
|
||||||
fileSvc := service.NewFileService(fileRepo, mimeRepo, tagRepo, diskStore, aclSvc, auditSvc)
|
aclSvc := service.NewACLService(aclRepo, fileRepo, tagRepo, categoryRepo, poolRepo, transactor)
|
||||||
tagSvc := service.NewTagService(tagRepo, tagRuleRepo, aclSvc, auditSvc)
|
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
|
// Bootstrap the initial admin from env (idempotent).
|
||||||
fileHandler := handler.NewFileHandler(fileSvc, tagSvc)
|
userSvc.EnsureAdmin(ctx, cfg.AdminUsername, cfg.AdminPassword)
|
||||||
// ...
|
|
||||||
|
|
||||||
router := handler.NewRouter(cfg, fileHandler, tagHandler, ...)
|
// Handlers → router (also wires trusted proxies + optional static SPA dir)
|
||||||
router.Run(cfg.ListenAddr)
|
router, _ := handler.NewRouter(authMiddleware, authHandler, fileHandler,
|
||||||
|
duplicateHandler, tagHandler, categoryHandler, poolHandler,
|
||||||
|
userHandler, aclHandler, auditHandler, cfg.StaticDir, cfg.TrustedProxies)
|
||||||
|
srv.ListenAndServe()
|
||||||
```
|
```
|
||||||
|
|
||||||
### Context Propagation
|
### Context Propagation
|
||||||
|
|
||||||
Every service method receives `context.Context` as the first argument.
|
Every service method receives `context.Context` as the first argument.
|
||||||
The handler extracts user info from JWT (via middleware) and puts it
|
The auth middleware parses the JWT and puts the caller's identity (user id,
|
||||||
into context. Services read the current user from context for ACL checks
|
admin flag, session id) into the context. Services read it for ACL checks and
|
||||||
and audit logging.
|
audit logging.
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// middleware.go
|
// handler/middleware.go
|
||||||
func (m *AuthMiddleware) Handle(c *gin.Context) {
|
|
||||||
claims := parseJWT(c.GetHeader("Authorization"))
|
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.Request = c.Request.WithContext(ctx)
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
|
|
||||||
// domain/context.go
|
// domain/context.go
|
||||||
type ctxKey int
|
func WithUser(ctx context.Context, userID int16, isAdmin bool, sessionID int) context.Context
|
||||||
const userKey ctxKey = iota
|
func UserFromContext(ctx context.Context) (userID int16, isAdmin bool, sessionID int)
|
||||||
func WithUser(ctx context.Context, userID int16, isAdmin bool) context.Context { ... }
|
|
||||||
func UserFromContext(ctx context.Context) (userID int16, isAdmin bool) { ... }
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Transaction Management
|
### 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
|
```go
|
||||||
// port/repository.go
|
// port/repository.go
|
||||||
type Transactor interface {
|
type Transactor interface {
|
||||||
WithTx(ctx context.Context, fn func(ctx context.Context) error) error
|
WithTx(ctx context.Context, fn func(ctx context.Context) error) error
|
||||||
}
|
}
|
||||||
```
|
|
||||||
|
|
||||||
The postgres implementation wraps `pgxpool.Pool.BeginTx`. Inside `fn`,
|
// service/file_service.go (sketch)
|
||||||
all repo calls use the transaction from context. This allows services
|
func (s *FileService) Upload(ctx context.Context, p UploadParams) (*domain.File, error) {
|
||||||
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) {
|
|
||||||
return s.tx.WithTx(ctx, func(ctx context.Context) error {
|
return s.tx.WithTx(ctx, func(ctx context.Context) error {
|
||||||
file, err := s.fileRepo.Create(ctx, ...) // uses tx
|
created, err := s.files.Create(ctx, f) // uses tx from ctx
|
||||||
if err != nil { return err }
|
// apply initial tags, etc., in the same tx
|
||||||
for _, tagID := range input.TagIDs {
|
return err
|
||||||
s.tagRepo.AddFileTag(ctx, file.ID, tagID) // same tx
|
|
||||||
}
|
|
||||||
s.auditRepo.Log(ctx, ...) // same tx
|
|
||||||
return nil
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### ACL Check Pattern
|
### ACL Check Pattern
|
||||||
|
|
||||||
ACL logic is centralized in `ACLService`. Other services call it before
|
ACL logic is centralized in `ACLService`. Other services call it before any
|
||||||
any data mutation or retrieval:
|
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
|
```go
|
||||||
// service/acl_service.go
|
// service/acl_service.go (shape)
|
||||||
func (s *ACLService) CanView(ctx context.Context, objectType string, objectID uuid.UUID) error {
|
func (s *ACLService) CanView(ctx context.Context, userID int16, isAdmin bool,
|
||||||
userID, isAdmin := domain.UserFromContext(ctx)
|
creatorID int16, isPublic bool, objectType int16, objectID uuid.UUID) (bool, error)
|
||||||
if isAdmin { return nil }
|
func (s *ACLService) CanEdit(ctx context.Context, userID int16, isAdmin bool,
|
||||||
// Check is_public on the object
|
creatorID int16, objectType int16, objectID uuid.UUID) (bool, error)
|
||||||
// If not public, check creator_id == userID
|
|
||||||
// If not creator, check acl.permissions
|
|
||||||
// Return domain.ErrForbidden if none match
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Error Mapping
|
### 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 errors → HTTP status codes (handled in handler/response.go):
|
||||||
|
|
||||||
| Domain Error | HTTP Status | Error Code |
|
| Domain Error | HTTP Status | Error Code |
|
||||||
|-----------------------|-------------|-------------------|
|
| ------------------ | ----------- | ---------------- |
|
||||||
| ErrNotFound | 404 | not_found |
|
| ErrNotFound | 404 | not_found |
|
||||||
| ErrForbidden | 403 | forbidden |
|
| ErrForbidden | 403 | forbidden |
|
||||||
| ErrUnauthorized | 401 | unauthorized |
|
| ErrUnauthorized | 401 | unauthorized |
|
||||||
@@ -249,22 +275,12 @@ Domain errors → HTTP status codes (handled in handler/response.go):
|
|||||||
|
|
||||||
### Filter DSL
|
### Filter DSL
|
||||||
|
|
||||||
The DSL parser lives in `db/postgres/filter_parser.go` because it produces
|
The DSL parser lives in `db/postgres/filter_parser.go` because it produces SQL
|
||||||
SQL WHERE clauses — it is a PostgreSQL-specific adapter concern.
|
WHERE clauses — a PostgreSQL-specific adapter concern. The service layer passes
|
||||||
The service layer passes the raw DSL string to the repository; the
|
the raw DSL string down; the repository parses it and builds the query. For a
|
||||||
repository parses it and builds the query.
|
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
|
```go
|
||||||
// port/repository.go
|
|
||||||
type FileRepo interface {
|
|
||||||
List(ctx context.Context, params FileListParams) (*domain.FilePage, error)
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
|
|
||||||
// domain/file.go
|
// domain/file.go
|
||||||
type FileListParams struct {
|
type FileListParams struct {
|
||||||
Filter string // raw DSL string
|
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
|
### JWT Structure
|
||||||
|
|
||||||
```go
|
```go
|
||||||
@@ -290,31 +309,24 @@ type Claims struct {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Access token: short-lived (15 min). Refresh token: long-lived (30 days),
|
Access token: short-lived (15 min default). Refresh token: long-lived (30 days
|
||||||
stored as hash in `activity.sessions.token_hash`.
|
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)
|
### Configuration (.env)
|
||||||
|
|
||||||
```env
|
Every variable the server reads is documented in `.env.example` (1:1 with
|
||||||
# Server
|
`config.Config`). Required at startup: `JWT_SECRET`, `ADMIN_PASSWORD`,
|
||||||
LISTEN_ADDR=:42776
|
`DATABASE_URL`, `FILES_PATH`, `THUMBS_CACHE_PATH`, `IMPORT_PATH`. Everything
|
||||||
JWT_SECRET=<random-32-bytes>
|
else has a sensible default (see `config.go`).
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -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",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.1",
|
"version": "3.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"generate:types": "openapi-typescript ../openapi.yaml -o src/lib/api/schema.ts",
|
"generate:types": "openapi-typescript ../openapi.yaml -o src/lib/api/schema.ts",
|
||||||
@@ -9,8 +9,8 @@
|
|||||||
"build": "npm run generate:types && vite build",
|
"build": "npm run generate:types && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"prepare": "svelte-kit sync || echo ''",
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "npm run generate:types && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "npm run generate:types && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"format:check": "prettier --check ."
|
"format:check": "prettier --check ."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import { api } from '$lib/api/client';
|
import { api } from '$lib/api/client';
|
||||||
import type { File } from '$lib/api/types';
|
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 {
|
export interface DuplicateCluster {
|
||||||
files: File[];
|
files: File[];
|
||||||
|
distances?: DuplicatePairDistance[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DuplicateClusterPage {
|
export interface DuplicateClusterPage {
|
||||||
|
|||||||
@@ -51,6 +51,14 @@
|
|||||||
function metaCount(m: unknown): number {
|
function metaCount(m: unknown): number {
|
||||||
return m && typeof m === 'object' ? Object.keys(m as object).length : 0;
|
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() {
|
async function submit() {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
@@ -88,7 +96,12 @@
|
|||||||
<span class="title">Merge duplicates</span>
|
<span class="title">Merge duplicates</span>
|
||||||
<button class="x" onclick={onClose} aria-label="Close">
|
<button class="x" onclick={onClose} aria-label="Close">
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -120,7 +133,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Scalar fields: keep vs discard -->
|
<!-- 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">
|
<div class="row">
|
||||||
<span class="label">{label}</span>
|
<span class="label">{label}</span>
|
||||||
<div class="seg">
|
<div class="seg">
|
||||||
@@ -171,6 +190,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Relations: keep vs union both -->
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<span class="label">Tags</span>
|
<span class="label">Tags</span>
|
||||||
@@ -357,6 +397,51 @@
|
|||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
border-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 {
|
.del {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -138,6 +138,8 @@
|
|||||||
class:focused
|
class:focused
|
||||||
use:lazyload
|
use:lazyload
|
||||||
data-file-index={index}
|
data-file-index={index}
|
||||||
|
role="button"
|
||||||
|
tabindex="-1"
|
||||||
onpointerdown={onPointerDown}
|
onpointerdown={onPointerDown}
|
||||||
onpointermove={onPointerMoveInternal}
|
onpointermove={onPointerMoveInternal}
|
||||||
onpointerup={() => {
|
onpointerup={() => {
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
import { authStore } from '$lib/stores/auth';
|
import { authStore } from '$lib/stores/auth';
|
||||||
import TagPicker from '$lib/components/file/TagPicker.svelte';
|
import TagPicker from '$lib/components/file/TagPicker.svelte';
|
||||||
import PoolPicker from '$lib/components/file/PoolPicker.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 ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||||
|
import { type MetaNode, objectToNodes, nodesToObject } from '$lib/utils/metadata';
|
||||||
import type { File, Tag } from '$lib/api/types';
|
import type { File, Tag } from '$lib/api/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -22,8 +24,14 @@
|
|||||||
onReviewChange?: (id: string, needsReview: boolean) => void;
|
onReviewChange?: (id: string, needsReview: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { fileId, prevId = null, nextId = null, onNavigate, onClose, onReviewChange }: Props =
|
let {
|
||||||
$props();
|
fileId,
|
||||||
|
prevId = null,
|
||||||
|
nextId = null,
|
||||||
|
onNavigate,
|
||||||
|
onClose,
|
||||||
|
onReviewChange
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
let file = $state<File | null>(null);
|
let file = $state<File | null>(null);
|
||||||
let fileTags = $state<Tag[]>([]);
|
let fileTags = $state<Tag[]>([]);
|
||||||
@@ -55,6 +63,10 @@
|
|||||||
let notes = $state('');
|
let notes = $state('');
|
||||||
let contentDatetime = $state('');
|
let contentDatetime = $state('');
|
||||||
let isPublic = $state(false);
|
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 dirty = $state(false);
|
||||||
|
|
||||||
let exifEntries = $derived(
|
let exifEntries = $derived(
|
||||||
@@ -77,6 +89,18 @@
|
|||||||
if (previewSrc) URL.revokeObjectURL(previewSrc);
|
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) {
|
async function loadFile(id: string) {
|
||||||
loading = true;
|
loading = true;
|
||||||
error = '';
|
error = '';
|
||||||
@@ -89,10 +113,9 @@
|
|||||||
if (fileId !== id) return; // paged on; ignore
|
if (fileId !== id) return; // paged on; ignore
|
||||||
file = fileData;
|
file = fileData;
|
||||||
notes = fileData.notes ?? '';
|
notes = fileData.notes ?? '';
|
||||||
contentDatetime = fileData.content_datetime
|
contentDatetime = isoToLocalInput(fileData.content_datetime);
|
||||||
? fileData.content_datetime.slice(0, 16) // YYYY-MM-DDTHH:mm
|
|
||||||
: '';
|
|
||||||
isPublic = fileData.is_public ?? false;
|
isPublic = fileData.is_public ?? false;
|
||||||
|
metadataNodes = objectToNodes(fileData.metadata);
|
||||||
dirty = false;
|
dirty = false;
|
||||||
void fetchPreview(id);
|
void fetchPreview(id);
|
||||||
void fetchContentToken(id);
|
void fetchContentToken(id);
|
||||||
@@ -227,9 +250,11 @@
|
|||||||
const updated = await api.patch<File>(`/files/${file.id}`, {
|
const updated = await api.patch<File>(`/files/${file.id}`, {
|
||||||
notes: notes.trim() || null,
|
notes: notes.trim() || null,
|
||||||
content_datetime: contentDatetime ? new Date(contentDatetime).toISOString() : undefined,
|
content_datetime: contentDatetime ? new Date(contentDatetime).toISOString() : undefined,
|
||||||
is_public: isPublic
|
is_public: isPublic,
|
||||||
|
metadata: nodesToObject(metadataNodes)
|
||||||
});
|
});
|
||||||
file = updated;
|
file = updated;
|
||||||
|
metadataNodes = objectToNodes(updated.metadata);
|
||||||
dirty = false;
|
dirty = false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof ApiError ? e.message : 'Failed to save';
|
error = e instanceof ApiError ? e.message : 'Failed to save';
|
||||||
@@ -376,7 +401,9 @@
|
|||||||
class:needs={file.needs_review}
|
class:needs={file.needs_review}
|
||||||
onclick={toggleReview}
|
onclick={toggleReview}
|
||||||
aria-label={file.needs_review ? 'Mark as reviewed' : 'Mark as needs review'}
|
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">
|
<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" />
|
<circle cx="10" cy="10" r="7.5" stroke="currentColor" stroke-width="1.6" />
|
||||||
@@ -472,13 +499,14 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Prev / Next -->
|
<!-- Prev / Next: the whole left/right side of the preview is a tap zone -->
|
||||||
{#if prevId}
|
{#if prevId}
|
||||||
<button
|
<button
|
||||||
class="nav-btn nav-prev"
|
class="nav-zone nav-prev"
|
||||||
onclick={() => prevId && onNavigate(prevId)}
|
onclick={() => prevId && onNavigate(prevId)}
|
||||||
aria-label="Previous file"
|
aria-label="Previous file"
|
||||||
>
|
>
|
||||||
|
<span class="nav-chip">
|
||||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||||
<path
|
<path
|
||||||
d="M11 3L5 9L11 15"
|
d="M11 3L5 9L11 15"
|
||||||
@@ -488,14 +516,16 @@
|
|||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if nextId}
|
{#if nextId}
|
||||||
<button
|
<button
|
||||||
class="nav-btn nav-next"
|
class="nav-zone nav-next"
|
||||||
onclick={() => nextId && onNavigate(nextId)}
|
onclick={() => nextId && onNavigate(nextId)}
|
||||||
aria-label="Next file"
|
aria-label="Next file"
|
||||||
>
|
>
|
||||||
|
<span class="nav-chip">
|
||||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||||
<path
|
<path
|
||||||
d="M7 3L13 9L7 15"
|
d="M7 3L13 9L7 15"
|
||||||
@@ -505,6 +535,7 @@
|
|||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -537,7 +568,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<label class="field-label" for="datetime">Date taken</label>
|
<label class="field-label" for="datetime">Content date</label>
|
||||||
<input
|
<input
|
||||||
id="datetime"
|
id="datetime"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
@@ -564,6 +595,12 @@
|
|||||||
</button>
|
</button>
|
||||||
</section>
|
</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}>
|
<button class="save-btn" onclick={save} disabled={!dirty || saving}>
|
||||||
{saving ? 'Saving…' : 'Save changes'}
|
{saving ? 'Saving…' : 'Save changes'}
|
||||||
</button>
|
</button>
|
||||||
@@ -723,15 +760,17 @@
|
|||||||
overflow: hidden;
|
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 {
|
.preview-link {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: zoom-in;
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-img {
|
.preview-img {
|
||||||
@@ -739,12 +778,14 @@
|
|||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
display: block;
|
display: block;
|
||||||
|
cursor: zoom-in;
|
||||||
|
pointer-events: auto; /* only the image opens the original */
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-busy {
|
.preview-busy {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 2;
|
z-index: 4; /* above the nav zones — an in-flight replace blocks paging */
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -785,33 +826,58 @@
|
|||||||
background-color: #1a1010;
|
background-color: #1a1010;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Nav buttons ---- */
|
/* ---- Nav zones ----
|
||||||
.nav-btn {
|
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;
|
position: absolute;
|
||||||
top: 50%;
|
top: 0;
|
||||||
transform: translateY(-50%);
|
bottom: 0;
|
||||||
width: 40px;
|
width: 30%;
|
||||||
height: 40px;
|
max-width: 220px;
|
||||||
border-radius: 50%;
|
|
||||||
border: none;
|
border: none;
|
||||||
background-color: rgba(0, 0, 0, 0.55);
|
background: none;
|
||||||
color: #fff;
|
padding: 0 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.15s;
|
z-index: 3;
|
||||||
}
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
|
||||||
.nav-btn:hover {
|
|
||||||
background-color: rgba(0, 0, 0, 0.8);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-prev {
|
.nav-prev {
|
||||||
left: 10px;
|
left: 0;
|
||||||
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
.nav-next {
|
.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 ---- */
|
/* ---- Metadata panel ---- */
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
import type { Tag } from '$lib/api/types';
|
import type { Tag } from '$lib/api/types';
|
||||||
import { fetchAllTags } from '$lib/api/tags';
|
import { fetchAllTags } from '$lib/api/tags';
|
||||||
import { buildDslFilter, parseDslFilter, tokenLabel } from '$lib/utils/dsl';
|
import { buildDslFilter, parseDslFilter, tokenLabel } from '$lib/utils/dsl';
|
||||||
@@ -16,7 +17,9 @@
|
|||||||
|
|
||||||
let tags = $state<Tag[]>([]);
|
let tags = $state<Tag[]>([]);
|
||||||
let search = $state('');
|
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(
|
let tagNames = $derived(
|
||||||
new Map(tags.filter((t) => t.id && t.name).map((t) => [t.id as string, t.name as string]))
|
new Map(tags.filter((t) => t.id && t.name).map((t) => [t.id as string, t.name as string]))
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Recursive editor for one level of the metadata object. Nested objects render
|
||||||
|
// another instance of this component (self-import), indented under their key.
|
||||||
|
import Self from './MetadataEditor.svelte';
|
||||||
|
import {
|
||||||
|
type MetaNode,
|
||||||
|
newValueNode,
|
||||||
|
newObjectNode,
|
||||||
|
nodesToObject,
|
||||||
|
objectToNodes,
|
||||||
|
parseObject
|
||||||
|
} from '$lib/utils/metadata';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Entries at this level; bound so nested edits flow back to the parent. */
|
||||||
|
nodes: MetaNode[];
|
||||||
|
/** Fired on any structural or value change (parent marks the form dirty). */
|
||||||
|
onchange: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { nodes = $bindable(), onchange }: Props = $props();
|
||||||
|
|
||||||
|
function addValue() {
|
||||||
|
nodes = [...nodes, newValueNode()];
|
||||||
|
onchange();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addObject() {
|
||||||
|
nodes = [...nodes, newObjectNode()];
|
||||||
|
onchange();
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(id: number) {
|
||||||
|
nodes = nodes.filter((n) => n.id !== id);
|
||||||
|
onchange();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flip a leaf to a nested object and back. Converting keeps the data where it
|
||||||
|
// can: a leaf whose text is a JSON object expands into rows; a group collapses
|
||||||
|
// back to its JSON text.
|
||||||
|
function toggleKind(node: MetaNode) {
|
||||||
|
if (node.kind === 'value') {
|
||||||
|
const obj = parseObject(node.value);
|
||||||
|
node.children = obj ? objectToNodes(obj) : [];
|
||||||
|
node.value = '';
|
||||||
|
node.kind = 'object';
|
||||||
|
} else {
|
||||||
|
node.value = node.children.length ? JSON.stringify(nodesToObject(node.children)) : '';
|
||||||
|
node.children = [];
|
||||||
|
node.kind = 'value';
|
||||||
|
}
|
||||||
|
onchange();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="meta-editor">
|
||||||
|
{#each nodes as node (node.id)}
|
||||||
|
<div class="node">
|
||||||
|
<div class="node-head">
|
||||||
|
<input class="key" placeholder="key" bind:value={node.key} oninput={onchange} />
|
||||||
|
<button
|
||||||
|
class="kind"
|
||||||
|
class:obj={node.kind === 'object'}
|
||||||
|
onclick={() => toggleKind(node)}
|
||||||
|
title={node.kind === 'object'
|
||||||
|
? 'Nested object — click for a plain value'
|
||||||
|
: 'Plain value — click to nest an object'}
|
||||||
|
aria-label="Toggle value / nested object"
|
||||||
|
>
|
||||||
|
{node.kind === 'object' ? '{ }' : 'a'}
|
||||||
|
</button>
|
||||||
|
{#if node.kind === 'value'}
|
||||||
|
<input class="val" placeholder="value" bind:value={node.value} oninput={onchange} />
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class="del"
|
||||||
|
onclick={() => remove(node.id)}
|
||||||
|
aria-label="Remove field"
|
||||||
|
title="Remove field"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M3 3l8 8M11 3l-8 8"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.6"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if node.kind === 'object'}
|
||||||
|
<div class="children">
|
||||||
|
<Self bind:nodes={node.children} {onchange} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<div class="add-row">
|
||||||
|
<button class="add" onclick={addValue}>+ Field</button>
|
||||||
|
<button class="add" onclick={addObject}>+ Group</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.meta-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-head {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key,
|
||||||
|
.val {
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 9px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key:focus,
|
||||||
|
.val:focus {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.key {
|
||||||
|
flex: 0 0 38%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.val {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Type toggle: shows 'a' for a plain value, '{ }' for a nested object. */
|
||||||
|
.kind {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kind.obj {
|
||||||
|
color: var(--color-accent);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kind:hover {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.del {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.del:hover {
|
||||||
|
color: var(--color-danger);
|
||||||
|
background-color: color-mix(in srgb, var(--color-danger) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nested level: indent and hang a rail off the parent key. */
|
||||||
|
.children {
|
||||||
|
margin-left: 12px;
|
||||||
|
padding-left: 12px;
|
||||||
|
border-left: 2px solid color-mix(in srgb, var(--color-accent) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add {
|
||||||
|
padding: 5px 11px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px dashed color-mix(in srgb, var(--color-accent) 40%, transparent);
|
||||||
|
background: none;
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,252 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { get } from 'svelte/store';
|
|
||||||
import { untrack } from 'svelte';
|
|
||||||
import { authStore } from '$lib/stores/auth';
|
|
||||||
import type { File } from '$lib/api/types';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/** Files to page through (e.g. a duplicate cluster). */
|
|
||||||
files: File[];
|
|
||||||
/** Id to open first. */
|
|
||||||
startId: string;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { files, startId, onClose }: Props = $props();
|
|
||||||
|
|
||||||
// Resolve the starting index once. The lightbox is remounted on each open, so
|
|
||||||
// the props are effectively init-only — untrack acknowledges that read.
|
|
||||||
let index = $state(untrack(() => Math.max(0, files.findIndex((f) => f.id === startId))));
|
|
||||||
let src = $state<string | null>(null);
|
|
||||||
let failed = $state(false);
|
|
||||||
|
|
||||||
let current = $derived(files[index]);
|
|
||||||
let hasPrev = $derived(index > 0);
|
|
||||||
let hasNext = $derived(index < files.length - 1);
|
|
||||||
|
|
||||||
// Load the full preview — the same image the single-file viewer shows, so the
|
|
||||||
// user can actually tell duplicates apart instead of squinting at thumbnails.
|
|
||||||
// Auth-gated, rendered from a blob; re-runs when the index changes.
|
|
||||||
$effect(() => {
|
|
||||||
const f = files[index];
|
|
||||||
if (!f) return;
|
|
||||||
const token = get(authStore).accessToken;
|
|
||||||
let objectUrl: string | null = null;
|
|
||||||
let cancelled = false;
|
|
||||||
src = null;
|
|
||||||
failed = false;
|
|
||||||
|
|
||||||
fetch(`/api/v1/files/${f.id}/preview`, {
|
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
|
||||||
})
|
|
||||||
.then((res) => (res.ok ? res.blob() : null))
|
|
||||||
.then((blob) => {
|
|
||||||
if (cancelled || !blob) {
|
|
||||||
if (!cancelled) failed = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
objectUrl = URL.createObjectURL(blob);
|
|
||||||
src = objectUrl;
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (!cancelled) failed = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
function prev() {
|
|
||||||
if (hasPrev) index -= 1;
|
|
||||||
}
|
|
||||||
function next() {
|
|
||||||
if (hasNext) index += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onKeydown(e: KeyboardEvent) {
|
|
||||||
if (e.key === 'Escape') onClose();
|
|
||||||
else if (e.key === 'ArrowLeft') prev();
|
|
||||||
else if (e.key === 'ArrowRight') next();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:window onkeydown={onKeydown} />
|
|
||||||
|
|
||||||
<!-- Close only when the backdrop itself is clicked (not the image or controls);
|
|
||||||
Escape and the × button close from anywhere. -->
|
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
||||||
<div
|
|
||||||
class="backdrop"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label="Enlarged preview"
|
|
||||||
tabindex="-1"
|
|
||||||
onclick={(e) => {
|
|
||||||
if (e.target === e.currentTarget) onClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button class="close" onclick={onClose} aria-label="Close">
|
|
||||||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" aria-hidden="true">
|
|
||||||
<path
|
|
||||||
d="M6 6l10 10M16 6L6 16"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="stage">
|
|
||||||
{#if src}
|
|
||||||
<img class="img" {src} alt={current?.original_name ?? ''} />
|
|
||||||
{:else if failed}
|
|
||||||
<div class="ph failed">Failed to load preview</div>
|
|
||||||
{:else}
|
|
||||||
<div class="ph loading">Loading…</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if hasPrev}
|
|
||||||
<button class="nav prev" onclick={prev} aria-label="Previous">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
|
||||||
<path
|
|
||||||
d="M12 4L6 10L12 16"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{#if hasNext}
|
|
||||||
<button class="nav next" onclick={next} aria-label="Next">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
|
||||||
<path
|
|
||||||
d="M8 4L14 10L8 16"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="caption">
|
|
||||||
<span class="name" title={current?.original_name ?? ''}>{current?.original_name ?? '—'}</span>
|
|
||||||
<span class="pos">{index + 1} / {files.length}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.backdrop {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 100;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 16px;
|
|
||||||
background-color: rgba(0, 0, 0, 0.88);
|
|
||||||
}
|
|
||||||
|
|
||||||
.close {
|
|
||||||
position: absolute;
|
|
||||||
top: 12px;
|
|
||||||
right: 12px;
|
|
||||||
width: 38px;
|
|
||||||
height: 38px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: none;
|
|
||||||
background-color: rgba(0, 0, 0, 0.5);
|
|
||||||
color: #fff;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.close:hover {
|
|
||||||
background-color: rgba(0, 0, 0, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stage {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
max-width: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.img {
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
display: block;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ph {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-width: 220px;
|
|
||||||
min-height: 220px;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
.ph.failed {
|
|
||||||
color: var(--color-danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: none;
|
|
||||||
background-color: rgba(0, 0, 0, 0.55);
|
|
||||||
color: #fff;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.15s;
|
|
||||||
}
|
|
||||||
.nav:hover {
|
|
||||||
background-color: rgba(0, 0, 0, 0.85);
|
|
||||||
}
|
|
||||||
.nav.prev {
|
|
||||||
left: 8px;
|
|
||||||
}
|
|
||||||
.nav.next {
|
|
||||||
right: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.caption {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
max-width: 100%;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.caption .name {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.caption .pos {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,21 +1,32 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { selectionStore, selectionCount } from '$lib/stores/selection';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
count: number;
|
||||||
|
onClear: () => void;
|
||||||
onEditTags: () => void;
|
onEditTags: () => void;
|
||||||
onAddToPool: () => void;
|
onAddToPool: () => void;
|
||||||
onMarkReviewed: () => void;
|
onMarkReviewed: () => void;
|
||||||
onDelete: () => 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>
|
</script>
|
||||||
|
|
||||||
<div class="bar" role="toolbar" aria-label="Selection actions">
|
<div class="bar" role="toolbar" aria-label="Selection actions">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<!-- Count / deselect all -->
|
<!-- Count / deselect all -->
|
||||||
<button class="count" onclick={() => selectionStore.exit()} title="Clear selection">
|
<button class="count" onclick={onClear} title="Clear selection">
|
||||||
<span class="num">{$selectionCount}</span>
|
<span class="num">{count}</span>
|
||||||
<span class="label">selected</span>
|
<span class="label">selected</span>
|
||||||
<svg
|
<svg
|
||||||
class="close-icon"
|
class="close-icon"
|
||||||
@@ -39,6 +50,9 @@
|
|||||||
<button class="action edit-tags" onclick={onEditTags}>Edit tags</button>
|
<button class="action edit-tags" onclick={onEditTags}>Edit tags</button>
|
||||||
<button class="action add-pool" onclick={onAddToPool}>Add to pool</button>
|
<button class="action add-pool" onclick={onAddToPool}>Add to pool</button>
|
||||||
<button class="action mark-reviewed" onclick={onMarkReviewed}>Mark reviewed</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>
|
<button class="action delete" onclick={onDelete}>Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,6 +86,7 @@
|
|||||||
.row {
|
.row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +139,7 @@
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-tags {
|
.edit-tags {
|
||||||
@@ -150,6 +166,15 @@
|
|||||||
background-color: color-mix(in srgb, var(--color-success) 15%, transparent);
|
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 {
|
.delete {
|
||||||
color: var(--color-danger);
|
color: var(--color-danger);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,8 @@
|
|||||||
|
|
||||||
let { tag, onclick, size = 'md', focused = false, index }: Props = $props();
|
let { tag, onclick, size = 'md', focused = false, index }: Props = $props();
|
||||||
|
|
||||||
const color = tag.color ?? tag.category_color;
|
let color = $derived(tag.color ?? tag.category_color);
|
||||||
const style = color ? `background-color: #${color}` : '';
|
let style = $derived(color ? `background-color: #${color}` : '');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if onclick}
|
{#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 {
|
function isFormTarget(t: EventTarget | null): boolean {
|
||||||
return (
|
return (
|
||||||
t instanceof HTMLElement &&
|
t instanceof HTMLElement &&
|
||||||
(t.isContentEditable ||
|
(t.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON', 'A'].includes(t.tagName))
|
||||||
['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON', 'A'].includes(t.tagName))
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -107,6 +107,7 @@
|
|||||||
class:on={isAdmin}
|
class:on={isAdmin}
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={isAdmin}
|
aria-checked={isAdmin}
|
||||||
|
aria-label="Admin"
|
||||||
onclick={() => (isAdmin = !isAdmin)}><span class="thumb"></span></button
|
onclick={() => (isAdmin = !isAdmin)}><span class="thumb"></span></button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,6 +122,7 @@
|
|||||||
class:on={canCreate}
|
class:on={canCreate}
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={canCreate}
|
aria-checked={canCreate}
|
||||||
|
aria-label="Can create"
|
||||||
onclick={() => (canCreate = !canCreate)}><span class="thumb"></span></button
|
onclick={() => (canCreate = !canCreate)}><span class="thumb"></span></button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -140,6 +142,7 @@
|
|||||||
class:danger={isBlocked}
|
class:danger={isBlocked}
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={isBlocked}
|
aria-checked={isBlocked}
|
||||||
|
aria-label="Blocked"
|
||||||
onclick={() => (isBlocked = !isBlocked)}><span class="thumb"></span></button
|
onclick={() => (isBlocked = !isBlocked)}><span class="thumb"></span></button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
import SelectionBar from '$lib/components/layout/SelectionBar.svelte';
|
import SelectionBar from '$lib/components/layout/SelectionBar.svelte';
|
||||||
import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte';
|
import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte';
|
||||||
import { fileSorting, type FileSortField } from '$lib/stores/sorting';
|
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 ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||||
import BulkTagEditor from '$lib/components/file/BulkTagEditor.svelte';
|
import BulkTagEditor from '$lib/components/file/BulkTagEditor.svelte';
|
||||||
import PoolPicker from '$lib/components/file/PoolPicker.svelte';
|
import PoolPicker from '$lib/components/file/PoolPicker.svelte';
|
||||||
@@ -129,9 +129,7 @@
|
|||||||
selectionStore.exit();
|
selectionStore.exit();
|
||||||
try {
|
try {
|
||||||
await api.post('/files/bulk/review', { file_ids: ids, needs_review: false });
|
await api.post('/files/bulk/review', { file_ids: ids, needs_review: false });
|
||||||
files = files.map((f) =>
|
files = files.map((f) => (ids.includes(f.id ?? '') ? { ...f, needs_review: false } : f));
|
||||||
ids.includes(f.id ?? '') ? { ...f, needs_review: false } : f
|
|
||||||
);
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore — list already reflects the intended state optimistically
|
// ignore — list already reflects the intended state optimistically
|
||||||
}
|
}
|
||||||
@@ -148,7 +146,8 @@
|
|||||||
function handleKey(e: KeyboardEvent) {
|
function handleKey(e: KeyboardEvent) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
if (tagEditorOpen) tagEditorOpen = false;
|
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 (confirmDeleteFiles) confirmDeleteFiles = false;
|
||||||
else if (activeFileId) return;
|
else if (activeFileId) return;
|
||||||
else if ($selectionActive) selectionStore.exit();
|
else if ($selectionActive) selectionStore.exit();
|
||||||
@@ -784,6 +783,8 @@
|
|||||||
|
|
||||||
{#if $selectionActive}
|
{#if $selectionActive}
|
||||||
<SelectionBar
|
<SelectionBar
|
||||||
|
count={$selectionCount}
|
||||||
|
onClear={() => selectionStore.exit()}
|
||||||
onEditTags={openTagEditor}
|
onEditTags={openTagEditor}
|
||||||
onAddToPool={openPoolPicker}
|
onAddToPool={openPoolPicker}
|
||||||
onMarkReviewed={markSelectionReviewed}
|
onMarkReviewed={markSelectionReviewed}
|
||||||
|
|||||||
@@ -1,41 +1,61 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { api } from '$lib/api/client';
|
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 Thumb from '$lib/components/file/Thumb.svelte';
|
||||||
import DuplicateMergeDialog from '$lib/components/file/DuplicateMergeDialog.svelte';
|
import DuplicateMergeDialog from '$lib/components/file/DuplicateMergeDialog.svelte';
|
||||||
import PreviewLightbox from '$lib/components/file/PreviewLightbox.svelte';
|
import FileViewer from '$lib/components/file/FileViewer.svelte';
|
||||||
import type { File } from '$lib/api/types';
|
import type { File } from '$lib/api/types';
|
||||||
|
|
||||||
const LIMIT = 20;
|
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);
|
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 loading = $state(false);
|
||||||
let initialLoaded = $state(false);
|
let initialLoaded = $state(false);
|
||||||
let error = $state('');
|
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).
|
// Which file is the survivor for a given cluster (keyed by its stable key).
|
||||||
let keepers = $state<Record<string, string>>({});
|
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 mergeKeep = $state<File | null>(null);
|
||||||
let mergeDiscard = $state<File | null>(null);
|
let mergeDiscard = $state<File | null>(null);
|
||||||
|
|
||||||
// Enlarged-preview lightbox: thumbnails are too small to tell near-duplicates
|
// Full viewer (same as the files page): thumbnails are too small to compare,
|
||||||
// apart, so a zoom opens the full preview and pages across the cluster.
|
// and dedup decisions need date / tags / EXIF, so the zoom opens the real
|
||||||
let lightbox = $state<{ files: File[]; startId: string } | null>(null);
|
// viewer and pages across the cluster's files.
|
||||||
|
let viewer = $state<{ key: number; files: File[]; id: string } | null>(null);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!initialLoaded && !loading) void load();
|
if (!initialLoaded && !loading) void load();
|
||||||
});
|
});
|
||||||
|
|
||||||
function clusterKey(c: DuplicateCluster): string {
|
function keeperId(c: Cluster): string {
|
||||||
return c.files.map((f) => f.id).join(',');
|
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() {
|
async function load() {
|
||||||
@@ -43,9 +63,17 @@
|
|||||||
loading = true;
|
loading = true;
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
const res = await getDuplicates(LIMIT, clusters.length);
|
const res = await getDuplicates(LIMIT, offset);
|
||||||
clusters = [...clusters, ...(res.items ?? [])];
|
const incoming = (res.items ?? []).map((c) => ({
|
||||||
total = res.total ?? clusters.length;
|
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 {
|
} catch {
|
||||||
error = 'Failed to load duplicates';
|
error = 'Failed to load duplicates';
|
||||||
} finally {
|
} finally {
|
||||||
@@ -58,58 +86,114 @@
|
|||||||
clusters = [];
|
clusters = [];
|
||||||
keepers = {};
|
keepers = {};
|
||||||
total = 0;
|
total = 0;
|
||||||
|
offset = 0;
|
||||||
initialLoaded = false;
|
initialLoaded = false;
|
||||||
await load();
|
await load();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setKeeper(c: DuplicateCluster, id: string) {
|
function setKeeper(c: Cluster, id: string) {
|
||||||
keepers = { ...keepers, [clusterKey(c)]: id };
|
keepers = { ...keepers, [c.key]: id };
|
||||||
}
|
}
|
||||||
|
|
||||||
function openLightbox(c: DuplicateCluster, startId: string) {
|
// Drop one file from a cluster after it's resolved, in place. With fewer than
|
||||||
lightbox = { files: c.files, startId };
|
// two files there's nothing left to compare, so the cluster — and its slot in
|
||||||
|
// the total — goes away. The server's view is live, so a later page reconciles.
|
||||||
|
function removeFile(key: number, fileId: string) {
|
||||||
|
const target = clusters.find((c) => c.key === key);
|
||||||
|
if (!target) return;
|
||||||
|
const remaining = target.files.filter((f) => f.id !== fileId);
|
||||||
|
const dropCluster = remaining.length < 2;
|
||||||
|
|
||||||
|
if (dropCluster) {
|
||||||
|
clusters = clusters.filter((c) => c.key !== key);
|
||||||
|
total = Math.max(0, total - 1);
|
||||||
|
} else {
|
||||||
|
clusters = clusters.map((c) => (c.key === key ? { ...c, files: remaining } : c));
|
||||||
|
}
|
||||||
|
// Forget a stale survivor pick when its cluster is gone or the pick was removed.
|
||||||
|
if (dropCluster || keepers[key] === fileId) {
|
||||||
|
const next = { ...keepers };
|
||||||
|
delete next[key];
|
||||||
|
keepers = next;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openMerge(c: DuplicateCluster, other: File) {
|
function openViewer(c: Cluster, id: string) {
|
||||||
|
viewer = { key: c.key, files: c.files, id };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prev/next within the cluster currently open in the viewer.
|
||||||
|
let viewerPrevId = $derived.by(() => {
|
||||||
|
const v = viewer;
|
||||||
|
if (!v) return null;
|
||||||
|
const i = v.files.findIndex((f) => f.id === v.id);
|
||||||
|
return i > 0 ? (v.files[i - 1]?.id ?? null) : null;
|
||||||
|
});
|
||||||
|
let viewerNextId = $derived.by(() => {
|
||||||
|
const v = viewer;
|
||||||
|
if (!v) return null;
|
||||||
|
const i = v.files.findIndex((f) => f.id === v.id);
|
||||||
|
return i >= 0 && i < v.files.length - 1 ? (v.files[i + 1]?.id ?? null) : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
function viewerNavigate(id: string) {
|
||||||
|
if (viewer) viewer = { ...viewer, id };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirror a review toggle made inside the viewer back into the cluster list and
|
||||||
|
// the viewer's own navigation snapshot so both stay consistent.
|
||||||
|
function onViewerReviewChange(id: string, needsReview: boolean) {
|
||||||
|
const apply = (f: File) => (f.id === id ? { ...f, needs_review: needsReview } : f);
|
||||||
|
clusters = clusters.map((c) =>
|
||||||
|
c.key === viewer?.key ? { ...c, files: c.files.map(apply) } : c
|
||||||
|
);
|
||||||
|
if (viewer) viewer = { ...viewer, files: viewer.files.map(apply) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMerge(c: Cluster, other: File) {
|
||||||
const keep = c.files.find((f) => f.id === keeperId(c));
|
const keep = c.files.find((f) => f.id === keeperId(c));
|
||||||
if (!keep) return;
|
if (!keep) return;
|
||||||
|
mergeId = c.key;
|
||||||
mergeKeep = keep;
|
mergeKeep = keep;
|
||||||
mergeDiscard = other;
|
mergeDiscard = other;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteFile(c: DuplicateCluster, id: string) {
|
async function deleteFile(c: Cluster, id: string) {
|
||||||
if (busyKey) return;
|
if (busyId !== null) return;
|
||||||
busyKey = clusterKey(c);
|
busyId = c.key;
|
||||||
try {
|
try {
|
||||||
await api.post('/files/bulk/delete', { file_ids: [id] });
|
await api.post('/files/bulk/delete', { file_ids: [id] });
|
||||||
await reload();
|
removeFile(c.key, id);
|
||||||
} catch {
|
} catch {
|
||||||
error = 'Failed to delete file';
|
error = 'Failed to delete file';
|
||||||
} finally {
|
} finally {
|
||||||
busyKey = '';
|
busyId = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function notDuplicate(c: DuplicateCluster, other: File) {
|
async function notDuplicate(c: Cluster, other: File) {
|
||||||
if (busyKey) return;
|
if (busyId !== null) return;
|
||||||
busyKey = clusterKey(c);
|
busyId = c.key;
|
||||||
try {
|
try {
|
||||||
await dismissDuplicate(keeperId(c), other.id);
|
await dismissDuplicate(keeperId(c), other.id);
|
||||||
await reload();
|
removeFile(c.key, other.id);
|
||||||
} catch {
|
} catch {
|
||||||
error = 'Failed to dismiss pair';
|
error = 'Failed to dismiss pair';
|
||||||
} finally {
|
} finally {
|
||||||
busyKey = '';
|
busyId = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onMerged() {
|
function onMerged() {
|
||||||
|
const key = mergeId;
|
||||||
|
const discardId = mergeDiscard?.id;
|
||||||
|
mergeId = null;
|
||||||
mergeKeep = null;
|
mergeKeep = null;
|
||||||
mergeDiscard = null;
|
mergeDiscard = null;
|
||||||
void reload();
|
if (key !== null && discardId) removeFile(key, discardId);
|
||||||
}
|
}
|
||||||
|
|
||||||
let hasMore = $derived(clusters.length < total);
|
let hasMore = $derived(offset < total);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -155,9 +239,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#each clusters as c (clusterKey(c))}
|
{#each clusters as c (c.key)}
|
||||||
{@const keep = keeperId(c)}
|
{@const keep = keeperId(c)}
|
||||||
<section class="cluster" class:busy={busyKey === clusterKey(c)}>
|
<section class="cluster" class:busy={busyId === c.key}>
|
||||||
<div class="files">
|
<div class="files">
|
||||||
{#each c.files as f (f.id)}
|
{#each c.files as f (f.id)}
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||||
@@ -175,10 +259,10 @@
|
|||||||
class="zoom"
|
class="zoom"
|
||||||
onclick={(e) => {
|
onclick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
openLightbox(c, f.id);
|
openViewer(c, f.id);
|
||||||
}}
|
}}
|
||||||
aria-label="Enlarge preview"
|
aria-label="Open in viewer"
|
||||||
title="Enlarge preview"
|
title="Open in viewer"
|
||||||
>
|
>
|
||||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
|
<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" />
|
<circle cx="6.5" cy="6.5" r="4.5" stroke="currentColor" stroke-width="1.5" />
|
||||||
@@ -206,8 +290,20 @@
|
|||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
{#each c.files.filter((f) => f.id !== keep) as other (other.id)}
|
{#each c.files.filter((f) => f.id !== keep) as other (other.id)}
|
||||||
|
{@const dist = distanceFromKeep(c, keep, other.id)}
|
||||||
<div class="actrow">
|
<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={() => openMerge(c, other)}>Merge</button>
|
||||||
<button class="abtn" onclick={() => deleteFile(c, other.id)}>Delete</button>
|
<button class="abtn" onclick={() => deleteFile(c, other.id)}>Delete</button>
|
||||||
<button class="abtn ghost" onclick={() => notDuplicate(c, other)}>Not a dup</button>
|
<button class="abtn ghost" onclick={() => notDuplicate(c, other)}>Not a dup</button>
|
||||||
@@ -227,12 +323,17 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if lightbox}
|
{#if viewer}
|
||||||
<PreviewLightbox
|
<div class="viewer-overlay">
|
||||||
files={lightbox.files}
|
<FileViewer
|
||||||
startId={lightbox.startId}
|
fileId={viewer.id}
|
||||||
onClose={() => (lightbox = null)}
|
prevId={viewerPrevId}
|
||||||
|
nextId={viewerNextId}
|
||||||
|
onNavigate={viewerNavigate}
|
||||||
|
onClose={() => (viewer = null)}
|
||||||
|
onReviewChange={onViewerReviewChange}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if mergeKeep && mergeDiscard}
|
{#if mergeKeep && mergeDiscard}
|
||||||
@@ -418,6 +519,20 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
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 {
|
.abtn {
|
||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
@@ -452,4 +567,14 @@
|
|||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-size: 0.9rem;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
import FileViewer from '$lib/components/file/FileViewer.svelte';
|
import FileViewer from '$lib/components/file/FileViewer.svelte';
|
||||||
import FilterBar from '$lib/components/file/FilterBar.svelte';
|
import FilterBar from '$lib/components/file/FilterBar.svelte';
|
||||||
import PoolPicker from '$lib/components/file/PoolPicker.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 InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte';
|
||||||
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||||
import { parseDslFilter } from '$lib/utils/dsl';
|
import { parseDslFilter } from '$lib/utils/dsl';
|
||||||
@@ -44,11 +46,13 @@
|
|||||||
let filterOpen = $state(false);
|
let filterOpen = $state(false);
|
||||||
let activeTokens = $derived(parseDslFilter(filterParam));
|
let activeTokens = $derived(parseDslFilter(filterParam));
|
||||||
|
|
||||||
// ---- Selection (for removal) ----
|
// ---- Selection + bulk actions (mirrors the files page) ----
|
||||||
let selectedIds = $state(new Set<string>());
|
let selectedIds = $state(new Set<string>());
|
||||||
let selectionMode = $derived(selectedIds.size > 0);
|
let selectionMode = $derived(selectedIds.size > 0);
|
||||||
let lastSelectedIdx = $state<number | null>(null);
|
let lastSelectedIdx = $state<number | null>(null);
|
||||||
let confirmRemove = $state(false);
|
let confirmRemove = $state(false);
|
||||||
|
let confirmDeleteFiles = $state(false);
|
||||||
|
let tagEditorOpen = $state(false);
|
||||||
let poolPickerOpen = $state(false);
|
let poolPickerOpen = $state(false);
|
||||||
|
|
||||||
// ---- Add files mode ----
|
// ---- Add files mode ----
|
||||||
@@ -61,8 +65,15 @@
|
|||||||
let addSelected = $state(new Set<string>());
|
let addSelected = $state(new Set<string>());
|
||||||
let addSearchPrev = $state('');
|
let addSearchPrev = $state('');
|
||||||
|
|
||||||
// ---- Drag-to-reorder (disabled when filter active) ----
|
// ---- Sorting ----
|
||||||
let canReorder = $derived(!filterParam);
|
// 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 dragSrcIdx = $state<number | null>(null);
|
||||||
let dragOverIdx = $state<number | null>(null);
|
let dragOverIdx = $state<number | null>(null);
|
||||||
let reorderPending = $state(false);
|
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 ----
|
// ---- Save pool ----
|
||||||
async function save() {
|
async function save() {
|
||||||
if (!name.trim() || saving) return;
|
if (!name.trim() || saving) return;
|
||||||
@@ -210,10 +242,20 @@
|
|||||||
lastSelectedIdx = idx;
|
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() {
|
async function removeSelected() {
|
||||||
confirmRemove = false;
|
confirmRemove = false;
|
||||||
const ids = [...selectedIds];
|
const ids = [...selectedIds];
|
||||||
selectedIds = new Set();
|
clearSelection();
|
||||||
try {
|
try {
|
||||||
await api.post(`/pools/${poolId}/files/remove`, { file_ids: ids });
|
await api.post(`/pools/${poolId}/files/remove`, { file_ids: ids });
|
||||||
files = files.filter((f) => !ids.includes(f.id ?? ''));
|
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) ----
|
// ---- File viewer overlay (shallow routing) ----
|
||||||
// Open the viewer on top of the still-mounted pool grid so the back button (and
|
// 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 —
|
// 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}
|
{#if pool?.file_count != null}<span class="count">({pool.file_count})</span>{/if}
|
||||||
</span>
|
</span>
|
||||||
<div class="files-header-actions">
|
<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}
|
{#if canReorder && files.length > 1}
|
||||||
<span class="reorder-hint" title="Drag thumbnails to reorder">
|
<span class="reorder-hint" title="Drag thumbnails to reorder">
|
||||||
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true">
|
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true">
|
||||||
@@ -685,35 +781,53 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Selection bar (remove mode) -->
|
<!-- Selection actions — same set as the files page, plus Remove from pool. -->
|
||||||
{#if selectionMode && !addMode}
|
{#if selectionMode && !addMode}
|
||||||
<div class="selection-bar" role="toolbar">
|
<SelectionBar
|
||||||
<button
|
count={selectedIds.size}
|
||||||
class="sel-cancel"
|
onClear={clearSelection}
|
||||||
onclick={() => {
|
onEditTags={openTagEditor}
|
||||||
selectedIds = new Set();
|
onAddToPool={() => (poolPickerOpen = true)}
|
||||||
lastSelectedIdx = null;
|
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>
|
<button class="picker-close" onclick={() => (tagEditorOpen = false)} aria-label="Close">
|
||||||
<span class="sel-label">selected</span>
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
|
||||||
<path
|
<path
|
||||||
d="M2 2l10 10M12 2L2 12"
|
d="M3 3l10 10M13 3L3 13"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
stroke-width="1.8"
|
stroke-width="1.8"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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>
|
||||||
|
<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}
|
{/if}
|
||||||
|
|
||||||
<!-- Add the selected files to another pool (reuses the file picker; the order
|
<!-- Add the selected files to another pool (reuses the file picker; the order
|
||||||
@@ -721,10 +835,7 @@
|
|||||||
{#if poolPickerOpen}
|
{#if poolPickerOpen}
|
||||||
<PoolPicker
|
<PoolPicker
|
||||||
fileIds={[...selectedIds]}
|
fileIds={[...selectedIds]}
|
||||||
onAdded={() => {
|
onAdded={clearSelection}
|
||||||
selectedIds = new Set();
|
|
||||||
lastSelectedIdx = null;
|
|
||||||
}}
|
|
||||||
onClose={() => (poolPickerOpen = false)}
|
onClose={() => (poolPickerOpen = false)}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1043,6 +1154,57 @@
|
|||||||
border-color: var(--color-accent);
|
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 ---- */
|
/* ---- File grid ---- */
|
||||||
main {
|
main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -1117,21 +1279,64 @@
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Selection bar ---- */
|
/* ---- Bulk tag editor sheet (shared shell with the files page) ---- */
|
||||||
.selection-bar {
|
.picker-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 10px;
|
inset: 0;
|
||||||
right: 10px;
|
z-index: 110;
|
||||||
bottom: 65px;
|
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);
|
background-color: var(--color-bg-secondary);
|
||||||
border-radius: 10px;
|
border-radius: 14px 14px 0 0;
|
||||||
box-shadow: 0 0 12px rgba(0, 0, 0, 0.5);
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
padding: 10px 14px;
|
max-height: 70dvh;
|
||||||
z-index: 100;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
animation: slide-up 0.18s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-sheet {
|
||||||
|
max-height: 80dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
padding: 14px 16px 10px;
|
||||||
animation: slide-up 0.18s ease-out;
|
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 {
|
@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 files overlay ---- */
|
||||||
.add-overlay {
|
.add-overlay {
|
||||||
position: absolute;
|
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",
|
"theme_color": "#312F45",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/images/android-icon-36x36.png",
|
"src": "/images/icon-tile-192x192.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",
|
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/images/pwa-512x512.png",
|
"src": "/images/icon-tile-512x512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/images/pwa-maskable-192x192.png",
|
"src": "/images/icon-maskable-192x192.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable"
|
"purpose": "maskable"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/images/pwa-maskable-512x512.png",
|
"src": "/images/icon-maskable-512x512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable"
|
"purpose": "maskable"
|
||||||
|
|||||||
@@ -4,9 +4,15 @@ info:
|
|||||||
description: |
|
description: |
|
||||||
REST API for Tanabata File Manager — a multi-user, tag-based web file manager.
|
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
|
## Authentication
|
||||||
All endpoints except `POST /auth/login` require a Bearer JWT token
|
All endpoints require a Bearer JWT token in the `Authorization` header,
|
||||||
in the `Authorization` header.
|
except `POST /auth/login`, `POST /auth/refresh` (which carry their own
|
||||||
|
credentials) and `GET /health`.
|
||||||
|
|
||||||
## Pagination
|
## Pagination
|
||||||
- **Files**: cursor-based (`cursor` parameter, returned in `next_cursor`).
|
- **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: `{t=uuid1,&,!,t=uuid2}` → has tag1 AND NOT tag2.
|
||||||
Example: `{r=1,&,m~image%}` → needs review AND is an image.
|
Example: `{r=1,&,m~image%}` → needs review AND is an image.
|
||||||
version: 1.0.0
|
version: 3.0.0
|
||||||
license:
|
license:
|
||||||
name: Proprietary
|
name: AGPL-3.0-or-later
|
||||||
|
url: https://www.gnu.org/licenses/agpl-3.0.html
|
||||||
|
|
||||||
servers:
|
servers:
|
||||||
- url: /api/v1
|
- url: /api/v1
|
||||||
@@ -59,12 +66,42 @@ tags:
|
|||||||
description: User management (admin)
|
description: User management (admin)
|
||||||
- name: Audit
|
- name: Audit
|
||||||
description: Audit log (admin)
|
description: Audit log (admin)
|
||||||
|
- name: System
|
||||||
|
description: Service health and liveness
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
# Paths
|
# Paths
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
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
|
# Auth
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -1956,6 +1993,28 @@ components:
|
|||||||
description: Two or more mutually similar files
|
description: Two or more mutually similar files
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/File'
|
$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:
|
DuplicateClusterPage:
|
||||||
type: object
|
type: object
|
||||||
@@ -2228,6 +2287,16 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
is_public:
|
is_public:
|
||||||
type: boolean
|
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:
|
file_count:
|
||||||
type: integer
|
type: integer
|
||||||
created_at:
|
created_at:
|
||||||
@@ -2259,6 +2328,12 @@ components:
|
|||||||
type: object
|
type: object
|
||||||
is_public:
|
is_public:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
sort_key:
|
||||||
|
type: string
|
||||||
|
enum: [manual, content_datetime, created, original_name]
|
||||||
|
sort_order:
|
||||||
|
type: string
|
||||||
|
enum: [asc, desc]
|
||||||
|
|
||||||
PoolOffsetPage:
|
PoolOffsetPage:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||