Author SHA1 Message Date
JakeBreath 7ca84f4fea Point desktop updates at the Gitea release feed
CI / Frontend build & lint (push) Successful in 25s
CI / Backend tests (push) Successful in 2m41s
The updater now resolves the newest non-draft desktop-v* release through the
Gitea API at check time (J621_UPDATE_REPO, lowercase because the API path is
case-sensitive), picks the platform's latest*.yml asset and uses that release
as a generic electron-updater feed; J621_UPDATE_URL still overrides
everything. Verified against the live API: release picked, yml fetched,
artifact HEAD 200.

electron-builder's publish.url is now metadata only (still needed so the
build emits latest*.yml). Docs updated: CD release assets are the feed, the
website /desktop/ feed only matters for installs before 0.1.2.
2026-09-23 22:00:49 -05:00
JakeBreath 5f237aa2e3 Widen the header and collapse blacklist listings by default
- the header row is no longer capped at 1600px, so the nav hugs the left
  edge and the status/account controls the right; main content and footer
  keep their width
- OnlinePage's blacklist chips and the Followed page's blacklisted-tag
  cloud start collapsed behind a count toggle (N tags / N entries)
2026-09-23 21:57:48 -05:00
JakeBreath 90ba2ecff7 Bump the desktop app to 0.1.2 2026-09-23 21:40:43 -05:00
JakeBreath 73bf4f9e38 Rework the metadata modal: fullscreen, inline sections, bigger previews
- near-fullscreen panel (up to 1400px / 92vh) with two independently
  scrolling columns; the left preview uses self-start so its border hugs the
  image instead of stretching to the modal height
- J-ID matches render as a larger tile grid (was 48px rows) and IQDB
  candidates get bigger tiles too
- Link to e621 post and Custom metadata are shown inline instead of behind
  tabs, each with its own heading
- new ThumbImage component: spinner while loading and a broken-image icon on
  error, with alt text removed so a pending tile never reads as 'J-7786'
2026-09-23 21:39:48 -05:00
JakeBreath 7c2569522f Make thumbnail generation atomic and warm it on import
- write thumbnails to a .part file and os.replace() them, so concurrent
  requests never read a half-written JPEG
- a stale thumbnail plus a vanished source no longer raises through the
  request (getmtime on a missing file returned 500); it falls back cleanly
- ensure_thumbnail(item) warms the preview when a file is indexed, keeping
  image decoding out of the request path
2026-09-23 21:35:09 -05:00
JakeBreath a62195ffce Re-sign visual-match thumbnails on every detail fetch
Match rows stored a signed URL minted when the scan ran, so it aged out (or
used the pre-stable signing scheme) and the modal showed broken tiles even
after legacy signatures were fixed. Rows now carry item_id/j_id and the
detail serializer mints a fresh thumbnail URL per request; matches whose
item no longer exists are dropped.
2026-09-23 21:33:20 -05:00
JakeBreath c73a81a5f4 Fix 500 on legacy signed URLs
A TimestampSigner value is an HMAC over 'payload:timestamp', so a plain
Signer's HMAC check accepts it and the embedded timestamp then reached the
JSON decoder, raising JSONDecodeError (not BadSignature) and surfacing as a
500. That broke every stored visual-match thumbnail URL minted before the
stable scheme, so the J-ID match tiles never loaded on prod.

Detect the legacy shape by its extra separator and verify it with
TimestampSigner; malformed input returns None instead of raising.
2026-09-23 21:31:34 -05:00
19 changed files with 798 additions and 437 deletions
+6 -4
View File
@@ -7,10 +7,12 @@
# * builds the desktop packages and attaches them (plus the update # * builds the desktop packages and attaches them (plus the update
# metadata) to the Gitea release tagged `desktop-v<package.json version>`. # metadata) to the Gitea release tagged `desktop-v<package.json version>`.
# #
# The live update feed (deploy/data/desktop, served by the frontend nginx at # The desktop build also attaches the update metadata (latest*.yml) to the
# /desktop/) is not touched here: it is runtime state on the deploy host and # release; that is the desktop updater's feed, resolved through the Gitea API
# is still published with `deploy/push_desktop.sh --no-build` from a machine # at check time (see desktop/README.md). The older website feed
# that can reach it. # (deploy/data/desktop, served at /desktop/) is runtime state on the deploy
# host and only needed for installs before 0.1.2; it is refreshed with
# `deploy/push_desktop.sh` from a machine that can reach the deploy host.
# #
# Registry login uses a repo PAT with the minimal write:package scope (the # Registry login uses a repo PAT with the minimal write:package scope (the
# Gitea registry rejects the automatic job token, go-gitea/gitea#23642); # Gitea registry rejects the automatic job token, go-gitea/gitea#23642);
+4 -3
View File
@@ -56,9 +56,10 @@ Project constraints (do not regress):
`desktop-v<version>`). Jobs run on the user-scoped runners: `ubuntu-latest` `desktop-v<version>`). Jobs run on the user-scoped runners: `ubuntu-latest`
on nitro-ci, `desktop` on msi-mortar-ci. Do not add actions/cache on nitro-ci, `desktop` on msi-mortar-ci. Do not add actions/cache
(`cache: pip`/`npm`) to these workflows: Gitea's cache service hangs the job (`cache: pip`/`npm`) to these workflows: Gitea's cache service hangs the job
on restore/save. The live desktop update feed (deploy/data/desktop) is still on restore/save. Desktop updates read the release assets (latest*.yml) from
published with `deploy/push_desktop.sh --no-build` from a machine with SSH the Gitea API at check time; the older website feed (deploy/data/desktop)
to the deploy host — CI has no key for that. only matters for installs before 0.1.2 and is refreshed with
`deploy/push_desktop.sh` from a machine with SSH to the deploy host.
- Security/permission tests live in backend/apps/core/tests and need a - Security/permission tests live in backend/apps/core/tests and need a
one-time grant: GRANT ALL ON `test_j621`.* TO 'j621'@'%'; one-time grant: GRANT ALL ON `test_j621`.* TO 'j621'@'%';
+52
View File
@@ -150,6 +150,7 @@ class TempUploadSerializer(serializers.ModelSerializer):
iqdb_checked = serializers.SerializerMethodField() iqdb_checked = serializers.SerializerMethodField()
processing = serializers.SerializerMethodField() processing = serializers.SerializerMethodField()
similar_count = serializers.SerializerMethodField() similar_count = serializers.SerializerMethodField()
visual_matches = serializers.SerializerMethodField()
class Meta: class Meta:
model = TempUpload model = TempUpload
@@ -242,6 +243,57 @@ class TempUploadSerializer(serializers.ModelSerializer):
def get_similar_count(self, obj): def get_similar_count(self, obj):
return len(obj.iqdb_data or []) + len(obj.visual_matches or []) return len(obj.iqdb_data or []) + len(obj.visual_matches or [])
@staticmethod
def _visual_item_id(entry):
if not isinstance(entry, dict):
return None
item_id = entry.get("item_id")
if item_id is None:
j_id = str(entry.get("j_id") or "")
if j_id.upper().startswith("J-"):
j_id = j_id[2:]
item_id = j_id if j_id.isdigit() else None
try:
return int(item_id)
except (TypeError, ValueError):
return None
def get_visual_matches(self, obj):
"""Rebuild match rows with fresh signed thumbnail URLs.
Storing the signed URL meant it aged out (or came from an older
signing scheme) and the "Already in your library" grid showed broken
tiles. The stored rows only carry the item reference now.
"""
entries = obj.visual_matches or []
if not entries:
return entries
wanted = {}
for entry in entries:
item_id = self._visual_item_id(entry)
if item_id is not None:
wanted[item_id] = None
items = MediaItem.objects.in_bulk(list(wanted))
user = self._request_user()
request = self.context.get("request")
matches = []
for entry in entries:
item_id = self._visual_item_id(entry)
item = items.get(item_id) if item_id is not None else None
if item is None:
continue
matches.append(
{
"j_id": f"J-{item.id}",
"filename": entry.get("filename") or item.md5,
"similarity": entry.get("similarity"),
"thumbnail_url": signed_media_url(
item, user, "thumbnail", request=request
),
}
)
return matches
class TempUploadListSerializer(TempUploadSerializer): class TempUploadListSerializer(TempUploadSerializer):
"""Compact staged-upload row for the board and the status polling. """Compact staged-upload row for the board and the status polling.
+60 -11
View File
@@ -6,6 +6,7 @@ import os
import re import re
import shutil import shutil
import subprocess import subprocess
import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from urllib.parse import urlencode from urllib.parse import urlencode
@@ -481,15 +482,38 @@ def sanitize_iqdb_results(results):
return cleaned return cleaned
def _thumbnail_is_fresh(target, path):
"""True when the cached thumbnail exists and is at least as new as source."""
try:
stat = target.stat()
if stat.st_size <= 0:
return False
return stat.st_mtime >= os.path.getmtime(path)
except OSError:
return False
def _thumbs_dir():
thumbs_dir = Path(settings.MEDIA_ROOT) / "thumbs"
thumbs_dir.mkdir(parents=True, exist_ok=True)
return thumbs_dir
def generate_video_thumbnail(md5, path): def generate_video_thumbnail(md5, path):
"""Extract a JPEG thumbnail from a video, cached under MEDIA_ROOT/thumbs.""" """Extract a JPEG thumbnail from a video, cached under MEDIA_ROOT/thumbs."""
if not shutil.which("ffmpeg"): if not shutil.which("ffmpeg"):
return None return None
thumbs_dir = Path(settings.MEDIA_ROOT) / "thumbs" try:
thumbs_dir.mkdir(parents=True, exist_ok=True) thumbs_dir = _thumbs_dir()
except OSError:
logger.exception("Could not create the thumbnail folder")
return None
target = thumbs_dir / f"{md5}.jpg" target = thumbs_dir / f"{md5}.jpg"
if target.exists() and target.stat().st_mtime >= os.path.getmtime(path): if _thumbnail_is_fresh(target, path):
return target return target
# Write beside the target and move it into place, so a concurrent request
# can never read a half-written JPEG.
temp = thumbs_dir / f".{md5}.{uuid.uuid4().hex}.part.jpg"
command = [ command = [
"ffmpeg", "ffmpeg",
"-y", "-y",
@@ -503,11 +527,14 @@ def generate_video_thumbnail(md5, path):
"scale=480:-2", "scale=480:-2",
"-loglevel", "-loglevel",
"error", "error",
str(target), str(temp),
] ]
try: try:
subprocess.run(command, check=True, capture_output=True, timeout=60) subprocess.run(command, check=True, capture_output=True, timeout=60)
os.replace(temp, target)
except (subprocess.SubprocessError, OSError): except (subprocess.SubprocessError, OSError):
logger.exception("Could not build a video thumbnail for %s", path)
temp.unlink(missing_ok=True)
return None return None
return target if target.exists() else None return target if target.exists() else None
@@ -517,14 +544,18 @@ def generate_image_thumbnail(md5, path):
The thumbnail action used to serve full-size originals for images; a The thumbnail action used to serve full-size originals for images; a
cached 480px JPEG keeps the library grid light without touching the cached 480px JPEG keeps the library grid light without touching the
original file. Returns ``None`` when Pillow cannot decode the format, so original file. Returns ``None`` when the source is missing or Pillow
callers can fall back to the original. cannot decode it, so callers can fall back to the original.
""" """
thumbs_dir = Path(settings.MEDIA_ROOT) / "thumbs" try:
thumbs_dir.mkdir(parents=True, exist_ok=True) thumbs_dir = _thumbs_dir()
except OSError:
logger.exception("Could not create the thumbnail folder")
return None
target = thumbs_dir / f"{md5}.jpg" target = thumbs_dir / f"{md5}.jpg"
if target.exists() and target.stat().st_mtime >= os.path.getmtime(path): if _thumbnail_is_fresh(target, path):
return target return target
temp = thumbs_dir / f".{md5}.{uuid.uuid4().hex}.part.jpg"
try: try:
with Image.open(path) as image: with Image.open(path) as image:
# Animated formats: the first frame is the preview. # Animated formats: the first frame is the preview.
@@ -532,9 +563,27 @@ def generate_image_thumbnail(md5, path):
frame = ImageOps.exif_transpose(image) or image frame = ImageOps.exif_transpose(image) or image
frame = frame.convert("RGB") frame = frame.convert("RGB")
frame.thumbnail((480, 480)) frame.thumbnail((480, 480))
frame.save(target, "JPEG", quality=82, optimize=True) frame.save(temp, "JPEG", quality=82, optimize=True)
os.replace(temp, target)
except Exception: # noqa: BLE001 - previews must never break serving except Exception: # noqa: BLE001 - previews must never break serving
logger.exception("Could not build an image thumbnail for %s", path) logger.exception("Could not build an image thumbnail for %s", path)
target.unlink(missing_ok=True) temp.unlink(missing_ok=True)
return None return None
return target if target.exists() else None return target if target.exists() else None
def ensure_thumbnail(item):
"""Generate an item's cached thumbnail if it is missing or stale.
Warming thumbnails when a file is indexed keeps image decoding out of the
request path, where the upload pipeline's hashing used to starve it.
"""
location = item.locations.first()
if location is None:
return None
path = Path(location.path)
if not path.is_file():
return None
if path.suffix.lower() in VIDEO_EXTENSIONS:
return generate_video_thumbnail(item.md5, path)
return generate_image_thumbnail(item.md5, path)
+13 -6
View File
@@ -36,18 +36,25 @@ def sign_payload(payload, salt, now=None):
def load_payload(signature, salt, legacy_max_age=86400): def load_payload(signature, salt, legacy_max_age=86400):
"""Verify a signed payload; ``None`` when missing, tampered with or expired. """Verify a signed payload; ``None`` when missing, tampered with or expired.
Signatures minted before the stable scheme (``TimestampSigner``) are still Legacy ``TimestampSigner`` values are still accepted for one release.
accepted for one release so pages open across the deploy keep working. Detect them by their extra separator (``payload:timestamp:signature``):
a plain ``Signer`` accepts the HMAC a ``TimestampSigner`` computed over
``payload:timestamp`` and then chokes on the embedded timestamp while
decoding the JSON payload, which used to surface as a 500.
""" """
try: if not signature:
data = signing.Signer(salt=salt).unsign_object(signature) return None
except signing.BadSignature: if signature.count(":") >= 2:
try: try:
return signing.TimestampSigner(salt=salt).unsign_object( return signing.TimestampSigner(salt=salt).unsign_object(
signature, max_age=legacy_max_age signature, max_age=legacy_max_age
) )
except signing.BadSignature: except (signing.BadSignature, ValueError):
return None return None
try:
data = signing.Signer(salt=salt).unsign_object(signature)
except (signing.BadSignature, ValueError):
return None
if not isinstance(data, dict): if not isinstance(data, dict):
return None return None
try: try:
+50 -1
View File
@@ -12,8 +12,10 @@ import shutil
import tempfile import tempfile
import time import time
from pathlib import Path from pathlib import Path
from unittest import mock
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core import signing
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client, TestCase, override_settings from django.test import Client, TestCase, override_settings
@@ -23,7 +25,7 @@ from rest_framework.authtoken.models import Token
from apps.library import services from apps.library import services
from apps.library.models import MediaItem, MediaLocation, TempUpload from apps.library.models import MediaItem, MediaLocation, TempUpload
from apps.library.signing_urls import sign_payload from apps.library.signing_urls import load_payload, sign_payload
User = get_user_model() User = get_user_model()
@@ -118,6 +120,27 @@ class MediaCacheTests(TestCase):
self.client.get(url, self.signed("thumbnail")) self.client.get(url, self.signed("thumbnail"))
self.assertEqual(thumb.stat().st_mtime_ns, before) self.assertEqual(thumb.stat().st_mtime_ns, before)
def test_ensure_thumbnail_reuses_the_cache(self):
first = services.ensure_thumbnail(self.item)
self.assertIsNotNone(first)
self.assertTrue(first.exists())
mtime = first.stat().st_mtime_ns
second = services.ensure_thumbnail(self.item)
self.assertEqual(second, first)
self.assertEqual(second.stat().st_mtime_ns, mtime)
def test_thumbnail_of_a_missing_source_does_not_error(self):
"""Regression: getmtime() on a vanished source used to raise a 500."""
thumbs = self._media / "thumbs"
thumbs.mkdir(parents=True, exist_ok=True)
(thumbs / f"{self.item.md5}.jpg").write_bytes(b"stale")
Path(self.item.locations.first().path).unlink()
self.assertIsNone(services.ensure_thumbnail(self.item))
response = self.client.get(
f"/api/files/J-{self.item.id}/thumbnail/", self.signed("thumbnail")
)
self.assertEqual(response.status_code, 404)
def test_staged_files_cache_briefly(self): def test_staged_files_cache_briefly(self):
temp = TempUpload.objects.create( temp = TempUpload.objects.create(
user=self.user, user=self.user,
@@ -137,3 +160,29 @@ class MediaCacheTests(TestCase):
f"max-age={services.TEMP_CACHE_SECONDS}", response["Cache-Control"] f"max-age={services.TEMP_CACHE_SECONDS}", response["Cache-Control"]
) )
self.assertNotIn("immutable", response["Cache-Control"]) self.assertNotIn("immutable", response["Cache-Control"])
def test_legacy_timestamp_signatures_are_accepted(self):
"""URLs minted before the stable scheme must not 500.
A TimestampSigner HMAC also passes a plain Signer's check, so the
embedded timestamp used to reach the JSON decoder and blow up.
"""
payload = {"item": self.item.id, "user": self.user.id, "action": "raw"}
legacy = signing.dumps(payload, salt=services.MEDIA_FILE_SALT)
self.assertEqual(
load_payload(legacy, services.MEDIA_FILE_SALT)["item"], self.item.id
)
response = self.client.get(
f"/api/files/J-{self.item.id}/raw/", {"sig": legacy}
)
self.assertEqual(response.status_code, 200)
def test_expired_and_malformed_signatures_return_none(self):
payload = {"item": self.item.id, "user": self.user.id, "action": "raw"}
with mock.patch.object(signing, "time") as clock:
clock.time.return_value = time.time() - 3 * 86400
expired = signing.dumps(payload, salt=services.MEDIA_FILE_SALT)
self.assertIsNone(load_payload(expired, services.MEDIA_FILE_SALT))
self.assertIsNone(load_payload("bogus", services.MEDIA_FILE_SALT))
self.assertIsNone(load_payload("a:b", services.MEDIA_FILE_SALT))
self.assertIsNone(load_payload("", services.MEDIA_FILE_SALT))
@@ -287,6 +287,37 @@ class StagedUploadWorkflowTests(TestCase):
self.assertEqual(body["visual_matches"], []) self.assertEqual(body["visual_matches"], [])
self.assertEqual(body["status"], TempUpload.STATUS_PENDING) self.assertEqual(body["status"], TempUpload.STATUS_PENDING)
def test_detail_resigns_stored_visual_match_urls(self):
"""Stored matches carry only the item reference; URLs are re-minted.
Embedding the signed URL meant it expired (or used an older signing
scheme) and the modal showed alt text instead of thumbnails.
"""
client = self.api_client(self.uploader)
self.seed_library_item(client, "seed-resign")
item = MediaItem.objects.get()
temp = self.make_temp(self.uploader, "resign")
TempUpload.objects.filter(pk=temp.pk).update(
visual_matches=[
{
"item_id": item.id,
"j_id": f"J-{item.id}",
"filename": "seed-resign.png",
"similarity": 96.5,
"thumbnail_url": "/api/files/J-x/thumbnail/?sig=stale",
},
{"item_id": 999999, "j_id": "J-999999", "filename": "gone.png"},
]
)
body = client.get(f"/api/uploads/{temp.id}/").json()
self.assertEqual(len(body["visual_matches"]), 1)
match = body["visual_matches"][0]
self.assertEqual(match["j_id"], f"J-{item.id}")
self.assertEqual(match["similarity"], 96.5)
self.assertNotIn("stale", match["thumbnail_url"])
self.assertIn("/thumbnail/", match["thumbnail_url"])
self.assertIn(f"v={item.md5}", match["thumbnail_url"])
def test_visual_match_phase_rejects_completed_uploads(self): def test_visual_match_phase_rejects_completed_uploads(self):
client = self.api_client(self.uploader) client = self.api_client(self.uploader)
temp = self.make_temp( temp = self.make_temp(
+7
View File
@@ -60,6 +60,7 @@ def match_hashes(hashes, index, limit=10, user=None, request=None):
location = item.locations.first() location = item.locations.first()
matches.append( matches.append(
{ {
"item_id": item.id,
"j_id": f"J-{item.id}", "j_id": f"J-{item.id}",
"filename": Path(location.rel_path).name if location else item.md5, "filename": Path(location.rel_path).name if location else item.md5,
"similarity": round(similarity * 100, 1), "similarity": round(similarity * 100, 1),
@@ -127,6 +128,12 @@ def complete_temp_upload(temp, download_url=None):
services.ensure_visual_hashes(item) services.ensure_visual_hashes(item)
temp.file.delete(save=False) temp.file.delete(save=False)
# Warm the preview while the import is still off the request path.
try:
services.ensure_thumbnail(item)
except Exception: # noqa: BLE001 - a preview must not fail the import
logger.exception("Could not warm the thumbnail for J-%s", item.id)
temp.library_item = item temp.library_item = item
temp.status = TempUpload.STATUS_COMPLETED temp.status = TempUpload.STATUS_COMPLETED
+9 -12
View File
@@ -145,9 +145,12 @@ Build the desktop installers without publishing anything:
``` ```
Hand the files out or attach them to a Gitea release manually — the script Hand the files out or attach them to a Gitea release manually — the script
prints sizes and SHA-256 sums for the release notes. prints sizes and SHA-256 sums for the release notes. The release assets are
also the desktop update feed; the app resolves the newest `desktop-v*`
release on Gitea at check time (see `desktop/README.md`).
Push the desktop builds and their update metadata to the frontend's feed: The frontend's `/desktop/` feed is optional now — kept for manual downloads
and for installs older than 0.1.2. To publish it:
```bash ```bash
./push_desktop.sh # build Linux packages + copy the feed to jakerasp ./push_desktop.sh # build Linux packages + copy the feed to jakerasp
@@ -160,19 +163,13 @@ The remote copy defaults to `jakerasp:/home/jake/servers/J621`, or
`$J621_DESKTOP_FEED_HOST` when set. Artifacts land in `deploy/data/desktop/`, `$J621_DESKTOP_FEED_HOST` when set. Artifacts land in `deploy/data/desktop/`,
which the frontend nginx mounts read-only and serves at `/desktop/`. The which the frontend nginx mounts read-only and serves at `/desktop/`. The
remote copy uses rsync when both ends have it, tar over ssh when the server remote copy uses rsync when both ends have it, tar over ssh when the server
does not. The desktop app's "Check for updates…" menu item reads does not. Backend-only composes have no frontend, so no website feed.
`latest-linux.yml` / `latest.yml` from there (see `desktop/README.md`).
Backend-only composes have no frontend, so no feed.
The manual **CD** workflow (Actions tab) builds the desktop packages on the The manual **CD** workflow (Actions tab) builds the desktop packages on the
runner and attaches them plus the update metadata to the Gitea release runner and attaches them plus the update metadata to the Gitea release
`desktop-v<version>`; it does not touch the live feed, because that is `desktop-v<version>`; that is what the desktop updater reads. The website feed
runtime state on the deploy host and CI has no SSH key for it. After a CD run, is not touched by CI (runtime state on the deploy host, no SSH key there); use
publish the feed from a machine that can reach the deploy checkout: `push_desktop.sh` when it needs refreshing for old installs.
```bash
./push_desktop.sh --no-build --local # or without --local to also copy it
```
## Scheduled jobs ## Scheduled jobs
+7 -6
View File
@@ -1,11 +1,12 @@
#!/bin/bash #!/bin/bash
# Build the J621 desktop packages and publish them to the update feed. # Build the J621 desktop packages, optionally publish them to the website
# feed.
# #
# Locally the feed is deploy/data/desktop, which the frontend nginx mounts # Desktop updates no longer depend on this: the app resolves the newest
# read-only and serves at /desktop/. With --host the same directory is also # `desktop-v*` release on Gitea at check time (see desktop/README.md). This
# copied to a remote deploy checkout (rsync, or tar over ssh when the server # script builds the packages and can copy them to deploy/data/desktop, which
# has no rsync). electron-updater reads latest-linux.yml / latest.yml from # the frontend nginx mounts read-only and serves at /desktop/ for manual
# there; the feed URL comes from desktop/electron-builder.yml. # downloads and for pre-0.1.2 installs.
# #
# Usage: ./push_desktop.sh [--win] [--no-build] [--local] [--host user@server:/path] # Usage: ./push_desktop.sh [--win] [--no-build] [--local] [--host user@server:/path]
# --win also cross-build the Windows NSIS installer (needs wine) # --win also cross-build the Windows NSIS installer (needs wine)
+19 -8
View File
@@ -49,7 +49,8 @@ npm run dist:all
`deploy/build_desktop.sh` wraps the same commands, installs dependencies on `deploy/build_desktop.sh` wraps the same commands, installs dependencies on
first run and prints sizes plus SHA-256 sums for release notes. Nothing is first run and prints sizes plus SHA-256 sums for release notes. Nothing is
published by it; `deploy/push_desktop.sh` is the one that feeds auto-updates. published by it; the CD workflow attaches the artifacts to the Gitea release,
which is also the update feed.
The Arch package can be installed and removed with pacman: The Arch package can be installed and removed with pacman:
@@ -68,19 +69,29 @@ will warn, and it has not been smoke-tested on real Windows.
The app checks only when asked (**J621 → Check for updates…** in the menu): The app checks only when asked (**J621 → Check for updates…** in the menu):
Linux packages install through pacman/dpkg, which needs administrator rights, Linux packages install through pacman/dpkg, which needs administrator rights,
and the Windows build is unsigned, so nothing installs silently. The check and the Windows build is unsigned, so nothing installs silently.
reads `latest-linux.yml` / `latest.yml` from the feed configured in
`electron-builder.yml` (`publish.url`, baked into `app-update.yml`); set The check resolves the feed itself: it asks the Gitea API for the newest
`J621_UPDATE_URL` to point a build at another feed (the smoke test uses this). non-draft `desktop-v*` release (`J621_UPDATE_REPO`, default
`https://gitea.rainbow-herring.ts.net/jakebreath/j621` — lowercase on purpose,
the API path is case-sensitive), picks the `latest-linux.yml` / `latest.yml`
asset for the platform and uses that release as an electron-updater generic
feed. The baked `publish.url` in `electron-builder.yml` is metadata only.
`J621_UPDATE_URL` overrides the whole lookup (the smoke test uses this).
Publishing a release: Publishing a release:
1. Bump `version` in `desktop/package.json` — that is what the updater compares. 1. Bump `version` in `desktop/package.json` — that is what the updater compares.
2. `./deploy/push_desktop.sh --win` builds deb/pacman/NSIS and copies the 2. Run the manual CD workflow, which builds the packages and attaches them
artifacts plus both channel files into `deploy/data/desktop/`, which the plus both channel files to the Gitea release `desktop-v<version>`.
frontend nginx serves read-only at `/desktop/`. `./deploy/push_desktop.sh --win` does the same build locally (and can also
copy the files to the website feed, which is optional now).
3. Existing installs find the new version on their next manual check. 3. Existing installs find the new version on their next manual check.
Note for the 0.1.1 → 0.1.2 step: 0.1.1 only knows the old `/desktop/` feed, so
publish 0.1.2 there once (`./deploy/push_desktop.sh --no-build`, or install it
manually). From 0.1.2 on, updates come from Gitea.
`package-type` in the app resources tells electron-updater whether to run `package-type` in the app resources tells electron-updater whether to run
`pacman -U` or `dpkg -i` (both via pkexec/sudo); the per-user NSIS install `pacman -U` or `dpkg -i` (both via pkexec/sudo); the per-user NSIS install
updates without elevation. updates without elevation.
+5 -4
View File
@@ -2,12 +2,13 @@ appId: io.j621.desktop
productName: J621 productName: J621
copyright: Copyright (c) 2026 JakeBreath — Jake Labs Non-Commercial Software Licence copyright: Copyright (c) 2026 JakeBreath — Jake Labs Non-Commercial Software Licence
# Update feed served by the frontend nginx (deploy/data/desktop, published # Update feed: desktop/src/main.ts resolves the newest desktop-v* release on
# with deploy/push_desktop.sh). Baked into resources/app-update.yml; override # Gitea at check time (J621_UPDATE_REPO). This block only tells electron-builder
# at runtime with J621_UPDATE_URL for a fork or a test feed. # to emit latest.yml/latest-linux.yml next to the installers; J621_UPDATE_URL
# overrides the feed for a fork or a test.
publish: publish:
provider: generic provider: generic
url: https://j621.rainbow-herring.ts.net/desktop url: https://gitea.rainbow-herring.ts.net/JakeBreath/J621/releases
directories: directories:
output: release output: release
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "j621-desktop", "name": "j621-desktop",
"version": "0.1.1", "version": "0.1.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "j621-desktop", "name": "j621-desktop",
"version": "0.1.1", "version": "0.1.2",
"license": "LicenseRef-Jake-Labs-Non-Commercial", "license": "LicenseRef-Jake-Labs-Non-Commercial",
"dependencies": { "dependencies": {
"electron-updater": "^6.8.9" "electron-updater": "^6.8.9"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "j621-desktop", "name": "j621-desktop",
"productName": "J621", "productName": "J621",
"version": "0.1.1", "version": "0.1.2",
"private": true, "private": true,
"description": "Desktop shell for the J621 self-hosted media archive", "description": "Desktop shell for the J621 self-hosted media archive",
"author": { "author": {
+82 -4
View File
@@ -238,8 +238,9 @@ function isDownloadNavigation(url: URL): boolean {
/** /**
* Updates are manual by design: Linux packages install through pacman/dpkg * Updates are manual by design: Linux packages install through pacman/dpkg
* (pkexec/sudo) and the Windows build is unsigned, so the app asks before * (pkexec/sudo) and the Windows build is unsigned, so the app asks before
* downloading and again before installing. The feed comes from `publish` in * downloading and again before installing. The feed is resolved at check time
* electron-builder.yml and can be overridden with J621_UPDATE_URL. * from the newest `desktop-v*` release on Gitea (`J621_UPDATE_REPO`);
* `J621_UPDATE_URL` overrides it for forks and the smoke test.
*/ */
type UpdateEvent = type UpdateEvent =
| { state: "available"; version: string } | { state: "available"; version: string }
@@ -250,9 +251,84 @@ type UpdateEvent =
let updateReporter: ((event: UpdateEvent) => void) | null = null; let updateReporter: ((event: UpdateEvent) => void) | null = null;
let updateCheckRunning = false; let updateCheckRunning = false;
function setUpUpdates(win: BrowserWindow): void { const UPDATE_REPO =
process.env.J621_UPDATE_REPO?.trim() ||
"https://gitea.rainbow-herring.ts.net/jakebreath/j621";
interface ReleaseAsset {
name: string;
browser_download_url: string;
}
interface Release {
tag_name: string;
draft: boolean;
prerelease: boolean;
assets?: ReleaseAsset[];
}
function releaseVersion(tag: string): [number, number, number] | null {
const match = /^desktop-v(\d+)\.(\d+)\.(\d+)$/.exec(tag);
if (!match) return null;
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
function compareVersions(
a: [number, number, number],
b: [number, number, number],
): number {
for (let index = 0; index < 3; index += 1) {
if (a[index] !== b[index]) return a[index] - b[index];
}
return 0;
}
/**
* Resolve the generic feed base electron-updater should use.
*
* Gitea's API path is case-sensitive (owner/repo must match the login), while
* the asset URLs it returns are canonical, so the base is derived from the
* platform's metadata asset (`latest-linux.yml` / `latest.yml`).
*/
async function resolveReleaseFeed(): Promise<string> {
const override = process.env.J621_UPDATE_URL?.trim(); const override = process.env.J621_UPDATE_URL?.trim();
if (override) autoUpdater.setFeedURL({ provider: "generic", url: override }); if (override) return override;
const match = /^(https?:\/\/[^/]+)\/([^/]+)\/([^/]+?)\/?$/.exec(UPDATE_REPO);
if (!match) {
throw new Error(
`J621_UPDATE_REPO must be <origin>/<owner>/<repo> (got ${UPDATE_REPO}).`,
);
}
const [, origin, owner, repo] = match;
const response = await fetch(
`${origin}/api/v1/repos/${owner}/${repo}/releases?limit=50`,
{ headers: { Accept: "application/json" } },
);
if (!response.ok) {
throw new Error(`Release lookup on Gitea failed (HTTP ${response.status}).`);
}
const releases = (await response.json()) as Release[];
const assetName =
process.platform === "win32" ? "latest.yml" : "latest-linux.yml";
let best: { version: [number, number, number]; asset: ReleaseAsset } | null =
null;
for (const release of releases) {
if (release.draft || release.prerelease) continue;
const version = releaseVersion(release.tag_name);
if (!version) continue;
const asset = release.assets?.find((entry) => entry.name === assetName);
if (!asset) continue;
if (!best || compareVersions(version, best.version) > 0) {
best = { version, asset };
}
}
if (!best) {
throw new Error(`No ${assetName} asset found in ${UPDATE_REPO} releases.`);
}
return best.asset.browser_download_url.replace(/\/[^/]*$/, "");
}
function setUpUpdates(win: BrowserWindow): void {
if (!app.isPackaged) autoUpdater.forceDevUpdateConfig = true; if (!app.isPackaged) autoUpdater.forceDevUpdateConfig = true;
autoUpdater.autoDownload = false; autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = false; autoUpdater.autoInstallOnAppQuit = false;
@@ -336,6 +412,8 @@ async function checkForUpdates(win: BrowserWindow): Promise<void> {
if (updateCheckRunning) return; if (updateCheckRunning) return;
updateCheckRunning = true; updateCheckRunning = true;
try { try {
const feed = await resolveReleaseFeed();
autoUpdater.setFeedURL({ provider: "generic", url: feed });
await autoUpdater.checkForUpdates(); await autoUpdater.checkForUpdates();
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
+1 -1
View File
@@ -113,7 +113,7 @@ export function AppShell() {
return ( return (
<div className="flex min-h-screen flex-col"> <div className="flex min-h-screen flex-col">
<header className="sticky top-0 z-40 border-b border-ctp-surface0 bg-ctp-crust/95 backdrop-blur"> <header className="sticky top-0 z-40 border-b border-ctp-surface0 bg-ctp-crust/95 backdrop-blur">
<div className="mx-auto flex h-14 w-full max-w-[1600px] items-center gap-4 px-4"> <div className="flex h-14 w-full items-center gap-4 px-3 sm:px-4">
<Link to="/" className="flex shrink-0 items-center"> <Link to="/" className="flex shrink-0 items-center">
<span className="rounded bg-ctp-mauve px-2 py-1 font-mono text-xs font-bold tracking-wide text-ctp-crust"> <span className="rounded bg-ctp-mauve px-2 py-1 font-mono text-xs font-bold tracking-wide text-ctp-crust">
J621 J621
+51 -34
View File
@@ -1,5 +1,5 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Eye, StarOff } from "lucide-react"; import { ChevronDown, ChevronRight, Eye, StarOff } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
@@ -148,48 +148,65 @@ function BlacklistCloudPanel() {
q.state.data?.status === "building" ? 2000 : false, q.state.data?.status === "building" ? 2000 : false,
}); });
const cloud = query.data; const cloud = query.data;
const [expanded, setExpanded] = useState(false);
return ( return (
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4"> <section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
<div className="flex flex-wrap items-center justify-between gap-2"> <button
type="button"
onClick={() => setExpanded((value) => !value)}
title={expanded ? "Hide the tags" : "Show the tags"}
className="flex w-full items-center justify-between gap-2 text-left"
>
<h2 className="text-sm font-semibold text-ctp-subtext1"> <h2 className="text-sm font-semibold text-ctp-subtext1">
Blacklisted tags Blacklisted tags
</h2> </h2>
<p className="text-xs text-ctp-overlay0"> <span className="flex items-center gap-1.5 font-mono text-[11px] text-ctp-overlay0">
{cloud?.source === "user" {cloud ? `${cloud.blacklist_count} entries` : "…"}
? "From your e621 blacklist"
: "From e621's anonymous default blacklist"}
{cloud ? ` · ${cloud.blacklist_count} entries` : ""}
{cloud ? ` · ${cloud.posts} feed post(s) scanned` : ""}
{cloud?.computed_at ? ` · computed ${formatDate(cloud.computed_at)}` : ""}
{cloud?.status === "building" ? ( {cloud?.status === "building" ? (
<span className="ml-2 inline-flex items-center gap-1.5"> <Spinner className="h-3 w-3" />
<Spinner className="h-3 w-3" /> building…
</span>
) : null} ) : null}
</p> {expanded ? (
</div> <ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</span>
</button>
{query.isPending ? ( {expanded ? (
<div className="mt-3 flex justify-center py-4"> <>
<Spinner className="h-4 w-4" /> <p className="mt-2 text-xs text-ctp-overlay0">
</div> {cloud?.source === "user"
) : cloud && cloud.tags.length > 0 ? ( ? "From your e621 blacklist"
<div className="mt-3 flex flex-wrap gap-1.5"> : "From e621's anonymous default blacklist"}
{cloud.tags.map(([tag, count]) => ( {cloud ? ` · ${cloud.posts} feed post(s) scanned` : ""}
<span {cloud?.computed_at
key={tag} ? ` · computed ${formatDate(cloud.computed_at)}`
className="rounded border border-ctp-red/30 bg-ctp-red/10 px-1.5 py-0.5 font-mono text-[11px] text-ctp-red" : ""}
> </p>
{tag} ({count}) {query.isPending ? (
</span> <div className="mt-3 flex justify-center py-4">
))} <Spinner className="h-4 w-4" />
</div> </div>
) : ( ) : cloud && cloud.tags.length > 0 ? (
<p className="mt-3 text-xs text-ctp-overlay0"> <div className="mt-3 flex flex-wrap gap-1.5">
No blacklisted tags in your feeds. {cloud.tags.map(([tag, count]) => (
</p> <span
)} key={tag}
className="rounded border border-ctp-red/30 bg-ctp-red/10 px-1.5 py-0.5 font-mono text-[11px] text-ctp-red"
>
{tag} ({count})
</span>
))}
</div>
) : (
<p className="mt-3 text-xs text-ctp-overlay0">
No blacklisted tags in your feeds.
</p>
)}
</>
) : null}
</section> </section>
); );
} }
+83 -64
View File
@@ -1,5 +1,5 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { X } from "lucide-react"; import { ChevronDown, ChevronRight, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Link, useLocation, useSearchParams } from "react-router-dom"; import { Link, useLocation, useSearchParams } from "react-router-dom";
@@ -43,6 +43,7 @@ export default function OnlinePage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [draft, setDraft] = useState<string | null>(null); const [draft, setDraft] = useState<string | null>(null);
const [blacklistDraft, setBlacklistDraft] = useState(""); const [blacklistDraft, setBlacklistDraft] = useState("");
const [showBlacklist, setShowBlacklist] = useState(false);
const tagInput = draft ?? tags; const tagInput = draft ?? tags;
const zoom = useUi((state) => state.zoom); const zoom = useUi((state) => state.zoom);
const perPage = useUi((state) => state.e621PerPage); const perPage = useUi((state) => state.e621PerPage);
@@ -271,73 +272,91 @@ export default function OnlinePage() {
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1"> <button
Your blacklist type="button"
</span> onClick={() => setShowBlacklist((value) => !value)}
{credentials?.configured ? ( title={showBlacklist ? "Hide the blacklist" : "Show the blacklist"}
<> className="flex items-center justify-between gap-2 text-left"
{blacklistEntries.length > 0 ? ( >
<div className="flex flex-wrap gap-1.5"> <span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
{blacklistEntries.map(({ line, index }) => ( Your blacklist
<span </span>
key={`${line}-${index}`} <span className="flex items-center gap-1 font-mono text-[11px] text-ctp-overlay0">
className="inline-flex items-center gap-1 rounded border border-ctp-red/30 bg-ctp-red/10 px-1.5 py-0.5 font-mono text-[11px] text-ctp-red" {blacklistEntries.length} tag
> {blacklistEntries.length === 1 ? "" : "s"}
{line} {showBlacklist ? (
<button <ChevronDown className="h-3.5 w-3.5" />
type="button"
title={`Remove ${line}`}
disabled={blacklistMutation.isPending}
onClick={() => removeBlacklistEntry(index)}
className="text-ctp-red/70 transition hover:text-ctp-red disabled:opacity-40"
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
) : ( ) : (
<p className="text-xs text-ctp-overlay0"> <ChevronRight className="h-3.5 w-3.5" />
No blacklisted tags.
</p>
)} )}
<form </span>
onSubmit={(event) => { </button>
event.preventDefault(); {showBlacklist ? (
addBlacklistEntry(); credentials?.configured ? (
}} <>
className="flex gap-2" {blacklistEntries.length > 0 ? (
> <div className="flex flex-wrap gap-1.5">
<input {blacklistEntries.map(({ line, index }) => (
className={cn(inputClass, "font-mono")} <span
placeholder="Add a tag…" key={`${line}-${index}`}
value={blacklistDraft} className="inline-flex items-center gap-1 rounded border border-ctp-red/30 bg-ctp-red/10 px-1.5 py-0.5 font-mono text-[11px] text-ctp-red"
onChange={(event) => setBlacklistDraft(event.target.value)} >
/> {line}
<Button <button
type="submit" type="button"
variant="secondary" title={`Remove ${line}`}
disabled={!blacklistDraft.trim() || blacklistMutation.isPending} disabled={blacklistMutation.isPending}
onClick={() => removeBlacklistEntry(index)}
className="text-ctp-red/70 transition hover:text-ctp-red disabled:opacity-40"
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
) : (
<p className="text-xs text-ctp-overlay0">
No blacklisted tags.
</p>
)}
<form
onSubmit={(event) => {
event.preventDefault();
addBlacklistEntry();
}}
className="flex gap-2"
> >
{blacklistMutation.isPending ? ( <input
<Spinner className="h-3.5 w-3.5" /> className={cn(inputClass, "font-mono")}
) : ( placeholder="Add a tag…"
"Add" value={blacklistDraft}
)} onChange={(event) => setBlacklistDraft(event.target.value)}
</Button> />
</form> <Button
<p className="text-[11px] text-ctp-overlay0"> type="submit"
Saved to your e621 account. variant="secondary"
disabled={!blacklistDraft.trim() || blacklistMutation.isPending}
>
{blacklistMutation.isPending ? (
<Spinner className="h-3.5 w-3.5" />
) : (
"Add"
)}
</Button>
</form>
<p className="text-[11px] text-ctp-overlay0">
Saved to your e621 account.
</p>
</>
) : (
<p className="text-xs text-ctp-overlay0">
<Link to="/account" className="text-ctp-blue hover:underline">
Add e621 credentials
</Link>{" "}
to manage your blacklist.
</p> </p>
</> )
) : ( ) : null}
<p className="text-xs text-ctp-overlay0">
<Link to="/account" className="text-ctp-blue hover:underline">
Add e621 credentials
</Link>{" "}
to manage your blacklist.
</p>
)}
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
+315 -276
View File
@@ -1,6 +1,7 @@
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
CheckCircle2, CheckCircle2,
ImageOff,
ListChecks, ListChecks,
Trash2, Trash2,
UploadCloud, UploadCloud,
@@ -310,6 +311,54 @@ function TempCard({
); );
} }
/** Image with a spinner/broken-image placeholder instead of alt-text fallback. */
function ThumbImage({
src,
className,
}: {
src: string;
className?: string;
}) {
const [loadedSrc, setLoadedSrc] = useState<string | null>(null);
const [failedSrc, setFailedSrc] = useState<string | null>(null);
// Derived from the current src: no effect needed when it changes.
const ready = Boolean(src) && loadedSrc === src;
const failed = !src || failedSrc === src;
return (
<span
className={cn(
"relative block overflow-hidden bg-ctp-surface0/60",
className,
)}
>
{src ? (
<img
src={src}
alt=""
loading="lazy"
onLoad={() => setLoadedSrc(src)}
onError={() => setFailedSrc(src)}
className={cn(
"h-full w-full object-cover transition-opacity duration-200",
ready ? "opacity-100" : "opacity-0",
)}
/>
) : null}
{!ready && !failed ? (
<span className="absolute inset-0 flex items-center justify-center">
<Spinner className="h-3.5 w-3.5 text-ctp-overlay0" />
</span>
) : null}
{failed ? (
<span className="absolute inset-0 flex items-center justify-center text-ctp-overlay0">
<ImageOff className="h-4 w-4" />
</span>
) : null}
</span>
);
}
/** /**
* A "J-x uploaded" notification. * A "J-x uploaded" notification.
* *
@@ -378,7 +427,6 @@ function MetadataForm({
}) { }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const credentials = useE621((state) => state.credentials); const credentials = useE621((state) => state.credentials);
const [tab, setTab] = useState<"link" | "custom">("link");
const [postId, setPostId] = useState(""); const [postId, setPostId] = useState("");
const [selected, setSelected] = useState<E621IqdbCandidate | null>(null); const [selected, setSelected] = useState<E621IqdbCandidate | null>(null);
const [rating, setRating] = useState<Rating>(temp.custom_rating); const [rating, setRating] = useState<Rating>(temp.custom_rating);
@@ -485,8 +533,8 @@ function MetadataForm({
} }
return ( return (
<> <div className="flex min-h-0 flex-1 flex-col">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3 border-b border-ctp-surface0 px-5 py-3">
<div className="min-w-0"> <div className="min-w-0">
<h2 className="text-sm font-semibold text-ctp-subtext1"> <h2 className="text-sm font-semibold text-ctp-subtext1">
Set metadata Set metadata
@@ -495,288 +543,279 @@ function MetadataForm({
{temp.original_filename} {temp.original_filename}
</p> </p>
</div> </div>
<button <div className="flex items-center gap-2">
type="button" {temp.processing ? (
onClick={onClose} <span className="flex items-center gap-1.5 font-mono text-[10px] text-ctp-overlay0">
className="rounded-md p-1 text-ctp-overlay0 transition hover:bg-ctp-surface0 hover:text-ctp-text" <Spinner className="h-3 w-3" /> working
title="Close" </span>
> ) : null}
<X className="h-4 w-4" /> <button
</button> type="button"
onClick={onClose}
className="rounded-md p-1 text-ctp-overlay0 transition hover:bg-ctp-surface0 hover:text-ctp-text"
title="Close"
>
<X className="h-4 w-4" />
</button>
</div>
</div> </div>
<div className="mt-4 grid gap-4 sm:grid-cols-[minmax(0,320px)_minmax(0,1fr)]"> <div className="grid min-h-0 flex-1 overflow-y-auto lg:grid-cols-[minmax(0,1fr)_minmax(0,34rem)] lg:overflow-hidden">
<div className="w-fit max-w-full overflow-hidden rounded-md border border-ctp-surface0 bg-ctp-mantle"> <div className="flex min-w-0 flex-col gap-5 p-5 lg:overflow-y-auto">
{mediaUrl ? ( <div className="w-fit max-w-full self-start overflow-hidden rounded-md border border-ctp-surface0 bg-ctp-mantle">
isVideo ? ( {mediaUrl ? (
<video isVideo ? (
src={mediaUrl} <video
controls src={mediaUrl}
muted controls
className="block h-auto max-h-[55vh] w-auto max-w-full" muted
/> className="block h-auto max-h-[70vh] w-auto max-w-full"
/>
) : (
<img
src={mediaUrl}
alt={temp.original_filename}
className="block h-auto max-h-[70vh] w-auto max-w-full"
/>
)
) : ( ) : (
<img <div className="flex h-40 w-40 items-center justify-center text-xs text-ctp-overlay0">
src={mediaUrl} No preview
alt={temp.original_filename} </div>
className="block h-auto max-h-[55vh] w-auto max-w-full" )}
/>
)
) : (
<div className="flex h-40 w-40 items-center justify-center text-xs text-ctp-overlay0">
No preview
</div>
)}
</div>
<div className="flex min-w-0 flex-col gap-3">
<div className="flex gap-1.5">
<button
type="button"
onClick={() => setTab("link")}
className={cn(
"rounded-md px-3 py-1.5 text-sm font-medium transition",
tab === "link"
? "bg-ctp-surface0 text-ctp-text"
: "text-ctp-subtext0 hover:bg-ctp-surface0/60 hover:text-ctp-text",
)}
>
Link to e621 post
</button>
<button
type="button"
onClick={() => setTab("custom")}
className={cn(
"rounded-md px-3 py-1.5 text-sm font-medium transition",
tab === "custom"
? "bg-ctp-surface0 text-ctp-text"
: "text-ctp-subtext0 hover:bg-ctp-surface0/60 hover:text-ctp-text",
)}
>
Custom metadata
</button>
</div> </div>
{tab === "link" ? ( {visualMatches.length > 0 ? (
<div className="flex flex-col gap-3"> <div>
{visualMatches.length > 0 ? ( <p className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
<div> Already in your library
<p className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1"> </p>
Already in your library <div className="mt-2 grid grid-cols-2 gap-3 sm:grid-cols-3 2xl:grid-cols-4">
</p> {visualMatches.map((match) => (
<div className="mt-2 flex flex-col gap-2"> <div
{visualMatches.map((match) => ( key={match.j_id}
<div className="overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-surface0/30"
key={match.j_id}
className="flex items-center gap-3 rounded-md border border-ctp-surface0 bg-ctp-surface0/40 p-2"
>
<img
src={apiUrl(
match.thumbnail_url ??
`/api/files/${match.j_id}/thumbnail/`,
)}
alt={match.j_id}
loading="lazy"
className="h-12 w-12 shrink-0 rounded object-cover"
/>
<span className="min-w-0 flex-1">
<Link
to={`/detail/${match.j_id}`}
className="font-mono text-[11px] text-ctp-blue hover:underline"
>
{match.j_id}
</Link>
<span
className="block truncate font-mono text-[11px] text-ctp-subtext0"
title={match.filename}
>
{match.filename}
</span>
</span>
<span className="shrink-0 font-mono text-[11px] text-ctp-peach">
{match.similarity}%
</span>
</div>
))}
</div>
<p className="mt-2 text-[11px] text-ctp-overlay0">
This upload looks like it is already in the library — you
can discard it below.
</p>
<button
type="button"
onClick={() => void discardUpload()}
className="mt-2 rounded-md px-1.5 py-0.5 font-mono text-[10px] text-ctp-red transition hover:bg-ctp-red/15"
> >
discard this upload <Link
</button> to={`/detail/${match.j_id}`}
</div> className="block"
) : null} title={match.filename}
<div>
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
IQDB candidates
</p>
{isVideo ? null : (
<button
type="button"
onClick={() => void checkIqdb()}
disabled={checking || busy || temp.processing}
className="rounded-md px-1.5 py-0.5 font-mono text-[10px] text-ctp-overlay0 transition hover:bg-ctp-surface0 hover:text-ctp-text disabled:opacity-50"
> >
{checking || temp.processing <ThumbImage
? "checking…" src={apiUrl(
: iqdbData match.thumbnail_url ??
? "re-check" `/api/files/${match.j_id}/thumbnail/`,
: "check now"} )}
className="aspect-square w-full"
/>
</Link>
<div className="flex items-center justify-between gap-2 px-2 py-1.5">
<span className="min-w-0">
<Link
to={`/detail/${match.j_id}`}
className="font-mono text-[11px] text-ctp-blue hover:underline"
>
{match.j_id}
</Link>
<span
className="block truncate font-mono text-[10px] text-ctp-overlay0"
title={match.filename}
>
{match.filename}
</span>
</span>
<span className="shrink-0 font-mono text-[11px] text-ctp-peach">
{match.similarity}%
</span>
</div>
</div>
))}
</div>
<p className="mt-2 text-[11px] text-ctp-overlay0">
This upload looks like it is already in the library — you can
discard it below.
</p>
<button
type="button"
onClick={() => void discardUpload()}
className="mt-2 rounded-md px-1.5 py-0.5 font-mono text-[10px] text-ctp-red transition hover:bg-ctp-red/15"
>
discard this upload
</button>
</div>
) : null}
</div>
<div className="flex min-w-0 flex-col gap-5 border-t border-ctp-surface0 p-5 lg:overflow-y-auto lg:border-l lg:border-t-0">
<div>
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
e621 IQDB candidates
</p>
{isVideo ? null : (
<button
type="button"
onClick={() => void checkIqdb()}
disabled={checking || busy || temp.processing}
className="rounded-md px-1.5 py-0.5 font-mono text-[10px] text-ctp-overlay0 transition hover:bg-ctp-surface0 hover:text-ctp-text disabled:opacity-50"
>
{checking || temp.processing
? "checking…"
: iqdbData
? "re-check"
: "check now"}
</button>
)}
</div>
{isVideo ? (
<p className="mt-2 text-xs text-ctp-overlay0">
IQDB works on images only.
</p>
) : temp.processing && !(iqdbData?.length ?? 0) ? (
<p className="mt-2 flex items-center gap-1.5 text-xs text-ctp-overlay0">
<Spinner className="h-3.5 w-3.5" />
Querying e621 IQDB…
</p>
) : iqdbData && iqdbData.length > 0 ? (
<>
<div className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-3">
{iqdbData.map((candidate) => (
<button
key={`${candidate.post_id}-${candidate.md5}`}
type="button"
disabled={busy || !candidate.post_id}
onClick={() => setSelected(candidate)}
className={cn(
"overflow-hidden rounded-md border bg-ctp-mantle text-left transition disabled:opacity-50",
selected?.post_id === candidate.post_id
? "border-ctp-mauve ring-2 ring-ctp-mauve/40"
: "border-ctp-surface0 hover:border-ctp-surface2",
)}
>
<span className="relative block aspect-square overflow-hidden">
{candidate.preview_url ? (
<ThumbImage
src={candidate.preview_url}
className="h-full w-full"
/>
) : (
<span className="flex h-full w-full items-center justify-center text-[10px] text-ctp-overlay0">
No preview
</span>
)}
{candidate.rating ? (
<span
className={cn(
"absolute left-1 top-1 rounded-full px-1 py-0.5 font-mono text-[9px] font-semibold uppercase",
ratingPill[candidate.rating] ??
"bg-ctp-surface1 text-ctp-text",
)}
>
{candidate.rating}
</span>
) : null}
</span>
<span className="block px-1.5 py-1 font-mono text-[10px] text-ctp-overlay0">
#{candidate.post_id}
{candidate.score !== null
? ` · ${Math.round(candidate.score)}%`
: ""}
</span>
</button> </button>
)} ))}
</div> </div>
{isVideo ? ( {selected ? (
<p className="mt-2 text-xs text-ctp-overlay0"> <div className="mt-3 rounded-md border border-ctp-surface0 bg-ctp-surface0/40 p-3">
IQDB works on images only. <p className="font-mono text-xs text-ctp-subtext1">
</p> Post #{selected.post_id}
) : temp.processing && !(iqdbData?.length ?? 0) ? ( </p>
<p className="mt-2 flex items-center gap-1.5 text-xs text-ctp-overlay0"> <p className="mt-1 text-xs text-ctp-overlay0">
<Spinner className="h-3.5 w-3.5" /> {selected.rating
Querying e621 IQDB… ? (RATING_LABELS[selected.rating] ?? selected.rating)
</p> : "Unknown rating"}
) : iqdbData && iqdbData.length > 0 ? ( {selected.score_total !== null &&
<> selected.score_total !== undefined
<div className="mt-2 grid grid-cols-3 gap-2"> ? ` · ▲ ${selected.score_total}`
{iqdbData.map((candidate) => ( : ""}
<button {selected.fav_count !== null &&
key={`${candidate.post_id}-${candidate.md5}`} selected.fav_count !== undefined
type="button" ? ` · ${selected.fav_count} favs`
disabled={busy || !candidate.post_id} : ""}
onClick={() => setSelected(candidate)} {selected.width && selected.height
className={cn( ? ` · ${selected.width}×${selected.height}`
"overflow-hidden rounded-md border bg-ctp-mantle text-left transition disabled:opacity-50", : ""}
selected?.post_id === candidate.post_id </p>
? "border-ctp-mauve ring-2 ring-ctp-mauve/40" {selected.tags_preview &&
: "border-ctp-surface0 hover:border-ctp-surface2", selected.tags_preview.length > 0 ? (
)} <div className="mt-2 flex flex-wrap gap-1">
> {selected.tags_preview.map((tag) => (
<span className="relative block aspect-square overflow-hidden"> <span
{candidate.preview_url ? ( key={tag}
<img className="rounded border border-ctp-surface1 px-1 py-0.5 font-mono text-[10px] text-ctp-subtext0"
src={candidate.preview_url}
alt={`Post #${candidate.post_id}`}
loading="lazy"
className="h-full w-full object-cover"
/>
) : (
<span className="flex h-full w-full items-center justify-center text-[10px] text-ctp-overlay0">
No preview
</span>
)}
{candidate.rating ? (
<span
className={cn(
"absolute left-1 top-1 rounded-full px-1 py-0.5 font-mono text-[9px] font-semibold uppercase",
ratingPill[candidate.rating] ??
"bg-ctp-surface1 text-ctp-text",
)}
>
{candidate.rating}
</span>
) : null}
</span>
<span className="block px-1.5 py-1 font-mono text-[10px] text-ctp-overlay0">
#{candidate.post_id}
{candidate.score !== null
? ` · ${Math.round(candidate.score)}%`
: ""}
</span>
</button>
))}
</div>
{selected ? (
<div className="mt-2 rounded-md border border-ctp-surface0 bg-ctp-surface0/40 p-3">
<p className="font-mono text-xs text-ctp-subtext1">
Post #{selected.post_id}
</p>
<p className="mt-1 text-xs text-ctp-overlay0">
{selected.rating
? (RATING_LABELS[selected.rating] ??
selected.rating)
: "Unknown rating"}
{selected.score_total !== null &&
selected.score_total !== undefined
? ` · ▲ ${selected.score_total}`
: ""}
{selected.fav_count !== null &&
selected.fav_count !== undefined
? ` · ${selected.fav_count} favs`
: ""}
{selected.width && selected.height
? ` · ${selected.width}×${selected.height}`
: ""}
</p>
{selected.tags_preview &&
selected.tags_preview.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1">
{selected.tags_preview.map((tag) => (
<span
key={tag}
className="rounded border border-ctp-surface1 px-1 py-0.5 font-mono text-[10px] text-ctp-subtext0"
>
{tag}
</span>
))}
</div>
) : null}
<div className="mt-3 flex items-center gap-3">
<Button
onClick={() => void linkCandidate(selected)}
disabled={busy || !selected.post_id}
> >
Link selected post {tag}
</Button> </span>
<Link ))}
to={`/detail/${selected.post_id}`}
target="_blank"
className="text-xs text-ctp-blue hover:underline"
>
View post
</Link>
</div>
<p className="mt-2 text-[11px] text-ctp-overlay0">
Linking downloads the post's file into the library
and drops this staged upload.
</p>
</div> </div>
) : null} ) : null}
</> <div className="mt-3 flex items-center gap-3">
) : iqdbData ? ( <Button
<p className="mt-2 text-xs text-ctp-overlay0"> onClick={() => void linkCandidate(selected)}
Checked against e621 IQDB — no match found. disabled={busy || !selected.post_id}
</p> >
) : ( Link selected post
<p className="mt-2 text-xs text-ctp-overlay0"> </Button>
Not checked yet. The background pipeline checks uploads <Link
automatically. to={`/detail/${selected.post_id}`}
</p> target="_blank"
)} className="text-xs text-ctp-blue hover:underline"
</div> >
View post
</Link>
</div>
</div>
) : null}
</>
) : iqdbData ? (
<p className="mt-2 text-xs text-ctp-overlay0">
Checked against e621 IQDB — no match found.
</p>
) : (
<p className="mt-2 text-xs text-ctp-overlay0">
Not checked yet. The background pipeline checks uploads
automatically.
</p>
)}
</div>
<div className="flex gap-2"> <div>
<input <p className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
className={cn(inputClass, "font-mono")} Link to an e621 post
placeholder="e621 post ID" </p>
value={postId} <div className="mt-2 flex gap-2">
onChange={(event) => setPostId(event.target.value)} <input
/> className={cn(inputClass, "font-mono")}
<Button onClick={() => void linkById()} disabled={busy}> placeholder="e621 post ID"
Link value={postId}
</Button> onChange={(event) => setPostId(event.target.value)}
</div> />
<Button onClick={() => void linkById()} disabled={busy}>
Link
</Button>
</div> </div>
) : ( <p className="mt-2 text-[11px] text-ctp-overlay0">
<div className="flex flex-col gap-3"> Linking downloads the post's file into the library and drops this
staged upload.
</p>
</div>
<div>
<p className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
Custom metadata
</p>
<div className="mt-2 flex flex-col gap-3">
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{ratingChoices.map((choice) => ( {ratingChoices.map((choice) => (
<button <button
@@ -810,7 +849,7 @@ function MetadataForm({
Save custom metadata Save custom metadata
</Button> </Button>
</div> </div>
)} </div>
{error ? <p className="text-xs text-ctp-red">{error}</p> : null} {error ? <p className="text-xs text-ctp-red">{error}</p> : null}
{temp.pipeline_error ? ( {temp.pipeline_error ? (
@@ -825,7 +864,7 @@ function MetadataForm({
) : null} ) : null}
</div> </div>
</div> </div>
</> </div>
); );
} }
@@ -845,11 +884,11 @@ function MetadataModal({
return ( return (
<div <div
className="fixed inset-0 z-[70] flex items-start justify-center overflow-y-auto bg-ctp-crust/70 px-4 py-10 backdrop-blur-sm" className="fixed inset-0 z-[70] flex items-start justify-center overflow-y-auto bg-ctp-crust/70 px-3 py-6 backdrop-blur-sm"
onClick={onClose} onClick={onClose}
> >
<div <div
className="w-full max-w-2xl rounded-lg border border-ctp-surface1 bg-ctp-base p-5" className="flex max-h-[92vh] w-full max-w-[min(1400px,96vw)] flex-col overflow-hidden rounded-xl border border-ctp-surface1 bg-ctp-base"
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
> >
{detailQuery.data ? ( {detailQuery.data ? (
@@ -859,11 +898,11 @@ function MetadataModal({
onClose={onClose} onClose={onClose}
/> />
) : detailQuery.isError ? ( ) : detailQuery.isError ? (
<p className="text-sm text-ctp-red"> <p className="p-5 text-sm text-ctp-red">
Could not load this upload's details. Could not load this upload's details.
</p> </p>
) : ( ) : (
<p className="flex items-center gap-2 text-sm text-ctp-overlay0"> <p className="flex items-center gap-2 p-5 text-sm text-ctp-overlay0">
<Spinner className="h-4 w-4" /> Loading… <Spinner className="h-4 w-4" /> Loading…
</p> </p>
)} )}