Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ca84f4fea | ||
|
|
5f237aa2e3 | ||
|
|
90ba2ecff7 | ||
|
|
73bf4f9e38 | ||
|
|
7c2569522f | ||
|
|
a62195ffce | ||
|
|
c73a81a5f4 |
@@ -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);
|
||||||
|
|||||||
@@ -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'@'%';
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Generated
+2
-2
@@ -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,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
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user