11 Commits
Author SHA1 Message Date
JakeBreath bf1d75aca8 Update
CI / Backend tests (push) Successful in 2m15s
CI / Frontend build & lint (push) Successful in 22s
2026-10-02 00:34:05 -05:00
JakeBreath 4889c5c355 extras/powershell_greeting: fix WezTerm logo stretch (iterm + preserveAspectRatio)
CI / Frontend build & lint (push) Successful in 22s
CI / Backend tests (push) Successful in 2m12s
kitty-direct sends c= but no rows through ConPTY (the terminal guesses the height and stretches); the plain kitty type needs tty pixel queries that ConPTY drops. The iterm type with preserveAspectRatio=1 letterboxes any aspect ratio.
2026-09-30 09:44:36 -05:00
JakeBreath d2f857d3ee extras: add PowerShell greeting port, Rust gm-switch and Windows installers
- powershell_greeting: PowerShell port of fish_greeting (fastfetch random logo, sixel/kitty-direct per terminal, logs, modes)

- win-gm-switch: Rust port of gm-switch.cpp (cross-platform mode file, --status, Start Menu shortcuts installer)

- install-windows.ps1 umbrella installer; README pointers
2026-09-30 09:34:01 -05:00
JakeBreath 9b431eb982 Stop the metadata preview from being clipped
CI / Backend tests (push) Successful in 3m58s
CI / Frontend build & lint (push) Successful in 24s
The preview box is a flex item in the fixed-height modal column, so default
flex-shrink squeezed it and overflow-hidden cropped tall images to a slice.
shrink-0 keeps its natural size (capped at 60vh) and lets the column scroll.
2026-09-23 22:56:48 -05:00
JakeBreath 7ca84f4fea Point desktop updates at the Gitea release feed
CI / Backend tests (push) Successful in 2m41s
CI / Frontend build & lint (push) Successful in 25s
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
31 changed files with 1800 additions and 440 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'@'%';
+6 -3
View File
@@ -8,7 +8,7 @@ Self-hosted media library and e621 archive manager, rebuilt as a **React SPA + D
backend/ Django 6 + DRF API (MariaDB + Redis via docker compose) backend/ Django 6 + DRF API (MariaDB + Redis via docker compose)
frontend/ Vite + React + TypeScript SPA frontend/ Vite + React + TypeScript SPA
deploy/ Docker images, compose variants and Tailscale serve configs deploy/ Docker images, compose variants and Tailscale serve configs
extras/ shell integrations (fish_greeting with fastfetch) extras/ shell integrations (fish + PowerShell greetings with fastfetch, gm-switch)
``` ```
## Development ## Development
@@ -75,8 +75,11 @@ curl -H "Authorization: Token <token>" \
the bundled nginx those aliases negotiate on `Accept`: browsers get the SPA the bundled nginx those aliases negotiate on `Accept`: browsers get the SPA
page, requesters like curl/wget/fastfetch get the JSON. `/api/random/` is page, requesters like curl/wget/fastfetch get the JSON. `/api/random/` is
the unambiguous path for scripts; 404 when nothing matches the filters. the unambiguous path for scripts; 404 when nothing matches the filters.
- A ready-made shell greeting that uses this endpoint lives in - Ready-made shell greetings that use this endpoint live in
[`extras/fish_greeting/`](extras/fish_greeting/README.md). [`extras/fish_greeting/`](extras/fish_greeting/README.md) (Linux/fish) and
[`extras/powershell_greeting/`](extras/powershell_greeting/README.md)
(Windows/PowerShell); [`extras/win-gm-switch/`](extras/win-gm-switch/)
(Rust) switches the rating mode on both.
- **Scoped tokens for scripts**: the Account page's *Shell tokens* section - **Scoped tokens for scripts**: the Account page's *Shell tokens* section
(also `/tokens`) issues `j621r_…` tokens that only authenticate (also `/tokens`) issues `j621r_…` tokens that only authenticate
`/api/random/` — the rest of the API rejects them. They are stored as `/api/random/` — the rest of the API rejects them. They are stored as
+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,17 +36,24 @@ 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
try:
data = signing.Signer(salt=salt).unsign_object(signature)
except (signing.BadSignature, ValueError):
return None return None
if not isinstance(data, dict): if not isinstance(data, dict):
return None return None
+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);
+59
View File
@@ -0,0 +1,59 @@
# Install the J621 Windows extras (PowerShell greeting + Rust gm-switch).
#
# Usage:
# .\install-windows.ps1
# .\install-windows.ps1 -Url https://j621.example.ts.net -Token <token>
# .\install-windows.ps1 -NoPrompt
# .\install-windows.ps1 -Uninstall
#
# Options are forwarded to the component installers (see
# powershell_greeting\install.ps1 and win-gm-switch\install.ps1).
[CmdletBinding()]
param(
[string]$Url,
[string]$Token,
[switch]$NoPrompt,
[switch]$Force,
[switch]$NoProfilePatch,
[switch]$SkipGreeting,
[switch]$SkipGmSwitch,
[switch]$Uninstall
)
$ErrorActionPreference = 'Stop'
$here = $PSScriptRoot
if (-not $here) { $here = Split-Path -Parent $MyInvocation.MyCommand.Path }
$failures = New-Object System.Collections.Generic.List[string]
if (-not $SkipGmSwitch) {
Write-Host ''
Write-Host '=== gm-switch ==='
$params = @{}
if ($Uninstall) { $params.Uninstall = $true }
& (Join-Path $here 'win-gm-switch\install.ps1') @params
if ($LASTEXITCODE -ne 0) { $failures.Add('gm-switch installer failed') }
}
if (-not $SkipGreeting) {
Write-Host ''
Write-Host '=== PowerShell greeting ==='
$params = @{}
if ($Uninstall) { $params.Uninstall = $true }
if ($Url) { $params.Url = $Url }
if ($Token) { $params.Token = $Token }
if ($NoPrompt) { $params.NoPrompt = $true }
if ($Force) { $params.Force = $true }
if ($NoProfilePatch) { $params.NoProfilePatch = $true }
& (Join-Path $here 'powershell_greeting\install.ps1') @params
if ($LASTEXITCODE -ne 0) { $failures.Add('greeting installer failed') }
}
Write-Host ''
if ($failures.Count -eq 0) {
Write-Host 'All done.'
exit 0
}
foreach ($failure in $failures) { Write-Host $failure }
exit 1
+200
View File
@@ -0,0 +1,200 @@
# J621Greeting.ps1 - show a random J621 library image as the shell greeting.
#
# PowerShell port of extras/fish_greeting/fish_greeting.fish (the J621 REST
# rewrite): asks GET /api/random/?fastfetch=1&rating=X for a random
# png/jpg/gif, downloads the signed url and runs fastfetch with the image as
# its logo. Based on the original script from
# J621-Django/extras/fish_greeting system/fish_greeting.fish.
#
# Requires: fastfetch and curl.exe (ships with Windows 10 1803+).
#
# Rating modes (same file name as the fish version):
# %APPDATA%\j621Greeting\greeting_mode 0 = NSFW (explicit)
# 1 = SFW (safe)
# 2 = Questionable (default)
#
# Configuration: %APPDATA%\j621Greeting\config.ps1 (sets environment
# variables; see config.example.ps1):
# J621_BASE API origin (default https://j621.rainbow-herring.ts.net)
# J621_TOKEN API / scoped greeting token, sent as "Authorization: Token ..."
# J621_WEB web origin used in the printed link (defaults to J621_BASE)
# J621_FASTFETCH_CONFIG fastfetch config name (default "jake")
# J621_MODE_FILE mode file override
# J621_LOGO_TYPE force a fastfetch logo type (sixel, kitty-direct, ...)
# J621_ALWAYS_FETCH=1 fetch even in terminals without image support
# J621_LOGO_WIDTH logo width in columns (default 35)
# J621_LOGO_HEIGHT iterm box height in rows (default 25)
#Requires -Version 5.1
function Get-J621FileKind {
param([string]$Path)
try {
$stream = [IO.File]::OpenRead($Path)
try {
$buffer = New-Object byte[] 8
$read = $stream.Read($buffer, 0, 8)
if ($read -ge 4 -and $buffer[0] -eq 0x89 -and $buffer[1] -eq 0x50 -and $buffer[2] -eq 0x4E -and $buffer[3] -eq 0x47) { return 'png' }
if ($read -ge 2 -and $buffer[0] -eq 0xFF -and $buffer[1] -eq 0xD8) { return 'jpeg' }
if ($read -ge 3 -and $buffer[0] -eq 0x47 -and $buffer[1] -eq 0x49 -and $buffer[2] -eq 0x46) { return 'gif' }
return 'unknown'
} finally { $stream.Dispose() }
} catch { return 'unknown' }
}
function Format-J621Accent {
param([string]$Text)
return "$([char]27)[4;2;38;2;138;0;222;49m$Text$([char]27)[0m"
}
function Format-J621Error {
param([string]$Text)
return "$([char]27)[31m$Text$([char]27)[0m"
}
function Get-J621FastfetchArgs {
$configName = if ($env:J621_FASTFETCH_CONFIG) { $env:J621_FASTFETCH_CONFIG } else { 'jake' }
$candidates = @(
(Join-Path $env:APPDATA "fastfetch\$configName.jsonc"),
(Join-Path $env:APPDATA "fastfetch\$configName\config.jsonc")
)
foreach ($candidate in $candidates) {
if (Test-Path -LiteralPath $candidate) { return @('--config', $configName) }
}
return @()
}
function Show-J621Greeting {
param([int]$Depth = 0)
# --- Configuration ---
$configFile = Join-Path $env:APPDATA 'j621Greeting\config.ps1'
if (Test-Path -LiteralPath $configFile) { . $configFile }
$base = if ($env:J621_BASE) { $env:J621_BASE.TrimEnd('/') } else { 'https://j621.rainbow-herring.ts.net' }
$webBase = if ($env:J621_WEB) { $env:J621_WEB.TrimEnd('/') } else { $base }
$token = $env:J621_TOKEN
$logoWidth = if ($env:J621_LOGO_WIDTH) { $env:J621_LOGO_WIDTH } else { '35' }
$modeFile = if ($env:J621_MODE_FILE) { $env:J621_MODE_FILE } else { Join-Path $env:APPDATA 'j621Greeting\greeting_mode' }
# --- Setup logging ---
$logDir = Join-Path $env:APPDATA 'j621Logos'
$logFile = Join-Path $logDir 'logs.log'
if (-not (Test-Path -LiteralPath $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null }
$stamp = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss')
# --- Mode selection ---
$mode = '2'
if (Test-Path -LiteralPath $modeFile) {
$raw = Get-Content -LiteralPath $modeFile -Raw -ErrorAction SilentlyContinue
if ($null -ne $raw) { $mode = $raw.Trim() }
}
switch ($mode) {
'0' { $rating = 'e'; $modeName = 'NSFW' }
'1' { $rating = 's'; $modeName = 'SFW' }
'2' { $rating = 'q'; $modeName = 'Questionable' }
default { Write-Output 'Unknown mode, using default NSFW'; $rating = 'e'; $modeName = 'NSFW' }
}
# --- Pick the image protocol for this terminal ---
$logoType = $env:J621_LOGO_TYPE
if (-not $logoType) {
if ($env:WT_SESSION) { $logoType = 'sixel' }
elseif ($env:WEZTERM_PANE) { $logoType = 'iterm' }
}
$fetchImage = [bool]$logoType -or ($env:J621_ALWAYS_FETCH -eq '1')
$ffArgs = @(Get-J621FastfetchArgs)
if (-not $fetchImage) {
# Terminal without image support (e.g. VS Code): plain fastfetch.
& fastfetch @ffArgs
return
}
# --- Ask the API for a random image (JSON with a signed URL) ---
$apiUrl = "$base/api/random/?fastfetch=1&rating=$rating"
$curlArgs = @('-s', '-L', '--max-time', '20')
if ($token) { $curlArgs += @('-H', "Authorization: Token $token") }
$json = & curl.exe @curlArgs $apiUrl 2>$null | Out-String
$data = $null
try { $data = $json | ConvertFrom-Json } catch { $data = $null }
$imageUrl = $null; $jId = $null; $md5 = $null; $filename = $null; $detail = $null; $ext = 'img'
if ($data) {
if ($data.url) { $imageUrl = [string]$data.url }
if ($data.j_id) { $jId = [string]$data.j_id }
if ($data.md5) { $md5 = [string]$data.md5 }
if ($data.filename) { $filename = [string]$data.filename }
if ($data.detail) { $detail = [string]$data.detail }
if ($data.extension) { $ext = [string]$data.extension }
}
if ($ext -notmatch '^[A-Za-z0-9]+$') { $ext = 'img' }
if (-not $imageUrl) {
& fastfetch @ffArgs
if ($detail) { Write-Host (Format-J621Accent "No logo ($detail)") } else { Write-Host (Format-J621Accent 'No logo (API error)') }
Add-Content -LiteralPath $logFile -Value "$stamp No logo for rating=${rating}: $detail"
return
}
# --- Download the signed image ---
$tempFile = Join-Path ([IO.Path]::GetTempPath()) ('j621_logo_' + [guid]::NewGuid().ToString('N') + '.' + $ext)
$downloadSuccess = $false
& curl.exe -s -L --max-time 60 -o $tempFile $imageUrl 2>$null
if ($LASTEXITCODE -eq 0 -and (Test-Path -LiteralPath $tempFile) -and (Get-Item -LiteralPath $tempFile).Length -gt 0) { $downloadSuccess = $true }
if (-not $downloadSuccess) {
& fastfetch @ffArgs
Write-Host (Format-J621Accent 'No logo (download failed)')
Add-Content -LiteralPath $logFile -Value "$stamp Download failed for $jId"
Remove-Item -LiteralPath $tempFile -ErrorAction SilentlyContinue
return
}
# --- Accept only PNG, JPEG, GIF; anything else causes one more roll ---
$kind = Get-J621FileKind -Path $tempFile
if ($kind -eq 'unknown') {
if ($Depth -lt 3) {
Show-J621Greeting -Depth ($Depth + 1)
} else {
Write-Host (Format-J621Error 'Max recursion depth reached, skipping unsupported file type: unknown')
}
if ($Depth -eq 0) {
Write-Host (Format-J621Error ("Forked (Got unsupported type: unknown - $filename)"))
if ($md5) { Write-Output "Link: $webBase/view/$md5" }
}
Remove-Item -LiteralPath $tempFile -ErrorAction SilentlyContinue
return
}
# --- Display result ---
if ($logoType) {
$logoArgs = @('--logo-type', $logoType, '--logo', $tempFile, '--logo-width', $logoWidth)
if ($logoType -eq 'iterm') {
# Box + preserveAspectRatio: never stretched, whatever the aspect ratio
# (kitty-direct cannot do this through ConPTY: its payload has no rows).
$logoHeight = if ($env:J621_LOGO_HEIGHT) { $env:J621_LOGO_HEIGHT } else { '25' }
$logoArgs += @('--logo-height', $logoHeight, '--logo-preserve-aspect-ratio', 'true')
} elseif ($env:J621_LOGO_HEIGHT) {
$logoArgs += @('--logo-height', $env:J621_LOGO_HEIGHT)
}
& fastfetch @ffArgs @logoArgs
} else {
& fastfetch @ffArgs
}
Add-Content -LiteralPath $logFile -Value "$stamp Downloaded: $jId $filename [$modeName]"
if ($filename) {
Write-Host (Format-J621Accent "Logo from API: $filename ($modeName, $jId)")
Write-Output "Link: $webBase/detail/$jId"
} else {
Write-Host (Format-J621Accent "Logo from API ($modeName)")
}
# --- Keep only the last 1000 lines of the log ---
$lines = @(Get-Content -LiteralPath $logFile -Tail 1000)
Set-Content -LiteralPath $logFile -Value $lines
# --- Cleanup ---
Remove-Item -LiteralPath $tempFile -ErrorAction SilentlyContinue
}
+127
View File
@@ -0,0 +1,127 @@
# PowerShell greeting (J621 Windows port)
Shows a random J621 library image as your shell greeting, using fastfetch.
This is the Windows/PowerShell port of [`../fish_greeting/`](../fish_greeting/) -
same API, same modes, same logs, adapted to PowerShell 5.1+ and Windows
terminals.
## What changed from the fish version
| | fish | PowerShell (this port) |
| --- | --- | --- |
| Entry point | `fish_greeting` function | `Show-J621Greeting` (loaded from your profile) |
| Endpoint | `GET /api/random/?fastfetch=1&rating=X` | same |
| Config | `~/.config/j621Greeting/config.fish` | `%APPDATA%\j621Greeting\config.ps1` |
| Mode file | `~/.config/fish/greeting_mode` | `%APPDATA%\j621Greeting\greeting_mode` (the file `gm-switch.exe` writes) |
| JSON parsing | `jq` / `python3` / grep | `ConvertFrom-Json` (built-in) |
| MIME detection | `file --mime-type` | PNG/JPEG/GIF magic bytes |
| GIFs | `gifsicle` + `kitten icat` | none: static first frame in Windows Terminal, animated in WezTerm (its `kitty-direct` plays the GIF) |
| fastfetch logo | `--logo-type kitty --logo ... --logo-width 35` | `sixel` in Windows Terminal, `iterm` (aspect-preserving box) in WezTerm, no image elsewhere |
| Logs | `~/.config/j621Logos/logs.log` | `%APPDATA%\j621Logos\logs.log` |
## Install
```powershell
cd extras\powershell_greeting
.\install.ps1
```
The installer copies `J621Greeting.ps1` into `%APPDATA%\j621Greeting\`, asks
for your API origin (and an optional scoped token), writes
`%APPDATA%\j621Greeting\config.ps1`, wires a marker block into your
PowerShell profile (it replaces the previous plain-`fastfetch` hook), checks
the tools it needs and probes the backend (`/health` plus a random roll). It
exits non-zero when the backend cannot be reached.
Non-interactive / re-install:
```powershell
.\install.ps1 -Url https://j621.example.ts.net -Token <api-token>
.\install.ps1 -NoPrompt # defaults, never asks
.\install.ps1 -Force # rewrite an existing config
.\install.ps1 -NoProfilePatch # do not touch your PowerShell profile
.\install.ps1 -Uninstall # remove the profile block + installed script
```
Test it manually any time with:
```powershell
greeting
```
The profile block also installs a `greeting` helper that (re)sources
`J621Greeting.ps1` and runs `Show-J621Greeting`, so it works even when the
automatic interactive greeting was skipped.
Requirements: `fastfetch` and `curl.exe` (ships with Windows 10 1803+).
Image logos need Windows Terminal 1.22+ (sixel) or WezTerm with kitty
graphics enabled; other terminals fall back to a plain fastfetch.
## No token? That is fine
The greeting is meant to run **without** a token: it then behaves like a
guest of your instance - unsigned image links and only items that are
visible to guests. Adding a token to the config unlocks signed links and
items that are hidden from guests. The recommended value is a **scoped
greeting token**: open the app, go to *Account -> Shell tokens* (or `/tokens`),
create one and copy the `j621r_...` key - it only works with `/api/random/`,
so it is safe to keep in the config file.
## Configuration
`%APPDATA%\j621Greeting\config.ps1` is read on every run (environment
variables already set in your session win over it):
```powershell
if (-not $env:J621_BASE) { $env:J621_BASE = 'https://j621.rainbow-herring.ts.net' } # API origin
if (-not $env:J621_WEB) { $env:J621_WEB = 'https://j621.example.ts.net' } # link origin (split deploys)
if (-not $env:J621_TOKEN) { $env:J621_TOKEN = '<api token>' } # signed URLs + hidden items
if (-not $env:J621_FASTFETCH_CONFIG) { $env:J621_FASTFETCH_CONFIG = 'jake' } # fastfetch config name
```
Extra escapes (all optional):
- `J621_LOGO_TYPE` - force a fastfetch logo type (`sixel`, `kitty-direct`, ...)
- `J621_ALWAYS_FETCH=1` - fetch even in terminals without image support (prints the link, ASCII logo)
- `J621_LOGO_WIDTH` - logo width in columns (default 35)
- `J621_MODE_FILE` - mode file override (shared with `gm-switch`)
## Rating filters
| `greeting_mode` | Rating requested | Label |
| --- | --- | --- |
| `0` | `e` | NSFW |
| `1` | `s` | SFW |
| `2` (default) | `q` | Questionable |
The mode file is `%APPDATA%\j621Greeting\greeting_mode`. Use
[`../win-gm-switch/`](../win-gm-switch/) (or its Start Menu shortcuts) to
switch, or just edit the file.
## Troubleshooting
- **No image logo in Windows Terminal**: WT 1.22+ is required for sixel;
update with `winget upgrade Microsoft.WindowsTerminal`.
- **WezTerm**: kitty graphics must be on - `config.enable_kitty_graphics = true`
in `~/.wezterm.lua`. The default uses the `iterm` logo type with
`preserveAspectRatio`, which never stretches. If something looks off, try
`J621_LOGO_TYPE=kitty-direct`, `kitty`, or `sixel` (note: through ConPTY,
plain `kitty` falls back to ASCII and `kitty-direct` stretches tall images).
- **VS Code / other terminals**: no image protocol, so the greeting prints a
plain fastfetch on purpose (no fetch delay). `J621_ALWAYS_FETCH=1` still
fetches and prints the link.
- `No logo (No image matches those filters.)` - the library has no images
for that rating; try another mode or add files.
- `No logo (API error)` - check `J621_BASE` and that the deployment is up
(`https://<host>/health`).
- `No logo (download failed)` - the signed URL expired (they last 24 h) or
the item was deleted between the two requests; just run it again.
- **The greeting is slow** - the API and image fetch have `--max-time`
guards; a slow tailnet link is usually the cause.
- Log file: `%APPDATA%\j621Logos\logs.log` (only the last 1000 lines are kept).
## gm-switch
The Rust [`../win-gm-switch/`](../win-gm-switch/) tool writes the same mode
file, and on Linux it can replace the old C++ `gm-switch` (it writes
`~/.config/fish/greeting_mode` there, the same file the fish greeting reads).
@@ -0,0 +1,26 @@
# Copy to %APPDATA%\j621Greeting\config.ps1 and adjust.
# This file runs every time the greeting starts; an environment variable that
# is already set in your session wins over the values here.
# API origin (no trailing slash needed).
if (-not $env:J621_BASE) { $env:J621_BASE = 'https://j621.rainbow-herring.ts.net' }
# Web origin used in the printed link. Only needed when the SPA is served
# from a different host than the API (split deployment).
# if (-not $env:J621_WEB) { $env:J621_WEB = 'https://j621-frontend.rainbow-herring.ts.net' }
# API token. Optional: gives you signed image URLs and access to items that
# are hidden from guests. The recommended value is a scoped greeting token
# (Account -> Shell tokens, or /tokens in the app): those only work with
# /api/random/, so they are safe to keep in this file. The full API token
# works too, but grants everything.
# if (-not $env:J621_TOKEN) { $env:J621_TOKEN = 'j621r_paste-your-token-here' }
# fastfetch config name used for the greeting logo.
# if (-not $env:J621_FASTFETCH_CONFIG) { $env:J621_FASTFETCH_CONFIG = 'jake' }
# Force a fastfetch logo type instead of the per-terminal default
# (Windows Terminal -> sixel, WezTerm -> iterm with preserveAspectRatio,
# others -> no image).
# if (-not $env:J621_LOGO_TYPE) { $env:J621_LOGO_TYPE = 'iterm' }
# if (-not $env:J621_LOGO_HEIGHT) { $env:J621_LOGO_HEIGHT = '25' }
+219
View File
@@ -0,0 +1,219 @@
# Install the J621 PowerShell greeting:
# * copies J621Greeting.ps1 into %APPDATA%\j621Greeting\
# * writes %APPDATA%\j621Greeting\config.ps1 (asks for the API origin and an
# optional token, or takes them as parameters)
# * wires the greeting into your PowerShell profile (marker block, once)
# * checks the tools it needs and probes the configured backend
#
# Usage:
# .\install.ps1
# .\install.ps1 -Url https://j621.example.ts.net -Token <api-token>
# .\install.ps1 -NoPrompt # never ask, keep/derive defaults
# .\install.ps1 -Force # rewrite an existing config
# .\install.ps1 -NoProfilePatch # do not touch your PowerShell profile
# .\install.ps1 -Uninstall # remove the profile block + installed script
[CmdletBinding()]
param(
[string]$Url,
[string]$Token,
[switch]$NoPrompt,
[switch]$Force,
[switch]$NoProfilePatch,
[switch]$Uninstall
)
$ErrorActionPreference = 'Stop'
$here = $PSScriptRoot
if (-not $here) { $here = Split-Path -Parent $MyInvocation.MyCommand.Path }
$configDir = Join-Path $env:APPDATA 'j621Greeting'
$configFile = Join-Path $configDir 'config.ps1'
$installedScript = Join-Path $configDir 'J621Greeting.ps1'
$defaultUrl = 'https://j621.rainbow-herring.ts.net'
$markerBegin = '# >>> J621 greeting >>>'
$markerEnd = '# <<< J621 greeting <<<'
$profilePath = $PROFILE
function Get-InstalledConfigValue {
param([string]$Name)
if (-not (Test-Path -LiteralPath $configFile)) { return $null }
$content = Get-Content -LiteralPath $configFile -Raw
# Ignore commented-out lines: the config ships an example token line, and
# matching it would make us probe with the placeholder as a real token.
$content = (@($content -split "`r?`n") | Where-Object { $_ -notmatch '^\s*#' }) -join "`n"
$rx = '\$env:' + $Name + ' = ''([^'']*)'''
if ($content -match $rx) { return $Matches[1] }
return $null
}
function Remove-ProfileBlock {
if (-not (Test-Path -LiteralPath $profilePath)) { return }
$lines = @(Get-Content -LiteralPath $profilePath)
if (-not ($lines -contains $markerBegin)) { return }
$out = New-Object System.Collections.Generic.List[string]
$inside = $false
foreach ($line in $lines) {
if ($line.Trim() -eq $markerBegin) { $inside = $true; continue }
if ($line.Trim() -eq $markerEnd) { $inside = $false; continue }
if (-not $inside) { $out.Add($line) }
}
while ($out.Count -gt 0 -and [string]::IsNullOrWhiteSpace($out[$out.Count - 1])) { $out.RemoveAt($out.Count - 1) }
Set-Content -LiteralPath $profilePath -Value ($out -join "`r`n") -Encoding ASCII
}
# --- Uninstall -----------------------------------------------------------------
if ($Uninstall) {
Remove-ProfileBlock
if (Test-Path -LiteralPath $installedScript) { Remove-Item -LiteralPath $installedScript -Force }
Write-Host "Removed the J621 greeting from the profile and $installedScript"
Write-Host "Your config and logs were left in place: $configDir"
exit 0
}
# --- Install the function ------------------------------------------------------
New-Item -ItemType Directory -Path $configDir -Force | Out-Null
$source = Join-Path $here 'J621Greeting.ps1'
if (-not (Test-Path -LiteralPath $source)) { throw "J621Greeting.ps1 not found next to this installer ($source)" }
Copy-Item -LiteralPath $source -Destination $installedScript -Force
Write-Host "Installed $installedScript"
# --- Configuration -------------------------------------------------------------
$writeConfig = $false
if (-not (Test-Path -LiteralPath $configFile)) {
$writeConfig = $true
} elseif ($Force -or $PSBoundParameters.ContainsKey('Url')) {
$writeConfig = $true
} else {
Write-Host "Keeping existing $configFile (use -Force to rewrite it)"
$current = Get-InstalledConfigValue -Name 'J621_BASE'
if ($current) { $Url = $current }
if (-not $Token) { $Token = Get-InstalledConfigValue -Name 'J621_TOKEN' }
}
if ($writeConfig) {
if (-not $NoPrompt) {
if (-not $Url) {
$answer = Read-Host "API origin [$defaultUrl]"
if ($answer) { $Url = $answer }
}
if (-not $Token) {
$answer = Read-Host 'API token (optional, Enter to run as a guest)'
if ($answer) { $Token = $answer }
}
}
if (-not $Url) { $Url = $defaultUrl }
$Url = $Url.Trim().TrimEnd('/').Replace("'", "")
if ($Token) { $Token = $Token.Trim().Replace("'", "") }
$lines = New-Object System.Collections.Generic.List[string]
$lines.Add('# J621 greeting configuration - written by install.ps1 on ' + (Get-Date -Format 'yyyy-MM-dd'))
$lines.Add('# Values here are defaults; an environment variable already set in your')
$lines.Add('# session wins over them.')
$lines.Add('if (-not $env:J621_BASE) { $env:J621_BASE = ''' + $Url + ''' }')
if ($Token) {
$lines.Add('if (-not $env:J621_TOKEN) { $env:J621_TOKEN = ''' + $Token + ''' }')
} else {
$lines.Add('# if (-not $env:J621_TOKEN) { $env:J621_TOKEN = ''paste-a-token-here'' }')
}
$lines.Add('# if (-not $env:J621_WEB) { $env:J621_WEB = ''' + $Url + ''' }')
$lines.Add('# if (-not $env:J621_FASTFETCH_CONFIG) { $env:J621_FASTFETCH_CONFIG = ''jake'' }')
Set-Content -LiteralPath $configFile -Value $lines -Encoding ASCII
Write-Host "Wrote $configFile"
}
# --- Profile -------------------------------------------------------------------
if (-not $NoProfilePatch) {
$existing = ''
if (Test-Path -LiteralPath $profilePath) { $existing = Get-Content -LiteralPath $profilePath -Raw }
if ($existing -match [regex]::Escape($markerBegin)) {
Write-Host 'Profile already sources the greeting (marker present)'
} else {
$normalized = $existing -replace "`r`n", "`n"
$legacy = @(
'# Fastfetch greeting on interactive PowerShell start',
'if ([Environment]::UserInteractive -and -not [Console]::IsOutputRedirected) {',
' fastfetch',
'}'
) -join "`n"
if ($normalized.Contains($legacy)) {
$normalized = $normalized.Replace($legacy, '').TrimEnd() + "`n"
Write-Host 'Replaced the plain fastfetch block in your profile'
}
$block = @(
$markerBegin,
'# Random J621 library image as the fastfetch logo (extras/powershell_greeting).',
'$j621GreetingScript = "$env:APPDATA\j621Greeting\J621Greeting.ps1"',
'if (Test-Path -LiteralPath $j621GreetingScript) { . $j621GreetingScript }',
'# Run it again any time with: greeting',
'function greeting {',
' if (-not (Get-Command Show-J621Greeting -ErrorAction Ignore) -and (Test-Path -LiteralPath $j621GreetingScript)) { . $j621GreetingScript }',
' Show-J621Greeting',
'}',
'if ((Test-Path -LiteralPath $j621GreetingScript) -and [Environment]::UserInteractive -and -not [Console]::IsOutputRedirected) {',
' Show-J621Greeting',
'}',
$markerEnd
) -join "`r`n"
$profileDir = Split-Path -Parent $profilePath
if (-not (Test-Path -LiteralPath $profileDir)) { New-Item -ItemType Directory -Path $profileDir -Force | Out-Null }
Set-Content -LiteralPath $profilePath -Value ($normalized.TrimEnd() + "`r`n`r`n" + $block + "`r`n") -Encoding ASCII
Write-Host "Added the J621 greeting to $profilePath"
}
}
# --- Dependencies --------------------------------------------------------------
$problems = 0
foreach ($tool in 'fastfetch', 'curl.exe') {
if (-not (Get-Command $tool -ErrorAction SilentlyContinue)) {
$problems = 1
Write-Host "Missing required tool: $tool"
}
}
if (-not ($env:WT_SESSION -or $env:WEZTERM_PANE)) {
Write-Host 'Note: image logos need Windows Terminal 1.22+ (sixel) or WezTerm (kitty graphics);'
Write-Host 'other terminals fall back to a plain fastfetch.'
}
# --- Probe the configured backend -----------------------------------------------
Write-Host ''
$probeUrl = $Url
$probeToken = $Token
if (-not $probeUrl) { $probeUrl = $defaultUrl }
Write-Host "Checking $probeUrl ..."
$health = & curl.exe -s -m 10 ($probeUrl + '/health') 2>$null | Out-String
if ($health -match '"status"\s*:\s*"ok"') {
Write-Host ' backend: ok'
$probeArgs = @('-s', '-m', '10')
if ($probeToken) { $probeArgs += @('-H', "Authorization: Token $probeToken") }
$probe = & curl.exe @probeArgs ($probeUrl + '/api/random/?fastfetch=1') 2>$null | Out-String
$detail = $null
try {
$parsed = $probe | ConvertFrom-Json
if ($parsed.detail) { $detail = [string]$parsed.detail }
} catch { }
if ($detail) {
Write-Host " random: $detail"
if (-not $probeToken) { Write-Host ' (a token would also let you see items that are hidden from guests)' }
} else {
Write-Host ' random: ok'
}
} else {
$problems = 1
Write-Host " backend did not answer at $probeUrl/health"
Write-Host " got: $health"
Write-Host " Fix J621_BASE in $configFile (or pass -Url) and run again."
}
# --- Wrap up --------------------------------------------------------------------
Write-Host ''
if ($problems -eq 0) {
Write-Host 'Done. Test it now with: Show-J621Greeting'
} else {
Write-Host 'Finished with problems above; the greeting will report them too.'
}
Write-Host ('Rating mode lives in ' + (Join-Path $configDir 'greeting_mode') + ' (0=NSFW, 1=SFW, 2=Questionable).')
Write-Host 'gm-switch.exe and its Start Menu shortcuts write that same file.'
exit $problems
+1
View File
@@ -0,0 +1 @@
/target
+7
View File
@@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "gm-switch"
version = "0.1.0"
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "gm-switch"
version = "0.1.0"
edition = "2021"
description = "Set the J621 shell greeting rating mode (NSFW / SFW / Questionable)"
publish = false
[profile.release]
strip = true
lto = true
+54
View File
@@ -0,0 +1,54 @@
# win-gm-switch (Rust)
Rust port of the old `gm-switch.cpp` from
`J621-Django/extras/fish_greeting system/`: a tiny CLI that sets the J621
shell greeting rating mode (0 = NSFW, 1 = SFW, 2 = Questionable).
## Usage
```
gm-switch --mode 0 # NSFW (explicit)
gm-switch --mode 1 # SFW (safe)
gm-switch --mode 2 # Questionable
gm-switch --status # print the current mode
```
`--path <file>` overrides the mode file (also via the `J621_MODE_FILE`
environment variable). Invalid input prints the same usage/errors as the C++
tool and exits 1.
Mode file locations:
- **Windows**: `%APPDATA%\j621Greeting\greeting_mode` - read by
[`../powershell_greeting/`](../powershell_greeting/).
- **Linux/macOS**: `~/.config/fish/greeting_mode` - read by
[`../fish_greeting/`](../fish_greeting/), so this binary can stand in for
the C++ tool there.
## Install (Windows)
```powershell
cd extras\win-gm-switch
.\install.ps1
```
The installer builds with `cargo build --release`, installs `gm-switch.exe`
to `%LOCALAPPDATA%\J621\bin`, adds that folder to your user PATH (new
terminals pick it up) and creates Start Menu shortcuts under **J621
Greeting** (NSFW / SFW / Questionable). Each shortcut opens a small console
showing the confirmation; close it with `exit` or the window button.
Uninstall:
```powershell
.\install.ps1 -Uninstall
```
## Build and test without installing
```powershell
cargo build --release # -> target\release\gm-switch.exe
cargo test
```
Zero runtime dependencies (std only). The `target\` folder is gitignored.
+105
View File
@@ -0,0 +1,105 @@
# Build and install gm-switch:
# * cargo build --release (needs the Rust toolchain on PATH)
# * copies gm-switch.exe to %LOCALAPPDATA%\J621\bin
# * adds that folder to your user PATH (if missing)
# * creates Start Menu shortcuts for each mode under "J621 Greeting"
#
# Usage:
# .\install.ps1
# .\install.ps1 -Uninstall
[CmdletBinding()]
param(
[switch]$Uninstall
)
$ErrorActionPreference = 'Stop'
$here = $PSScriptRoot
if (-not $here) { $here = Split-Path -Parent $MyInvocation.MyCommand.Path }
$binDir = Join-Path $env:LOCALAPPDATA 'J621\bin'
$exe = Join-Path $binDir 'gm-switch.exe'
$startMenuDir = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs\J621 Greeting'
if ($Uninstall) {
if (Test-Path -LiteralPath $startMenuDir) {
Remove-Item -LiteralPath $startMenuDir -Recurse -Force
Write-Host "Removed $startMenuDir"
}
if (Test-Path -LiteralPath $exe) {
Remove-Item -LiteralPath $exe -Force
Write-Host "Removed $exe"
}
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
if ($userPath) {
$parts = @($userPath -split ';' | Where-Object { $_ -and $_ -ne $binDir })
[Environment]::SetEnvironmentVariable('Path', ($parts -join ';'), 'User')
Write-Host "Removed $binDir from your user PATH"
}
exit 0
}
# --- Build ---------------------------------------------------------------------
if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) {
Write-Host 'cargo was not found on PATH - install the Rust toolchain (https://rustup.rs) first.'
exit 1
}
Push-Location $here
try {
Write-Host 'Building gm-switch (cargo build --release) ...'
# cargo writes progress to stderr; keep that from tripping $ErrorActionPreference.
$previousPreference = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
cargo build --release 2>&1 | Write-Host
$buildExit = $LASTEXITCODE
$ErrorActionPreference = $previousPreference
if ($buildExit -ne 0) {
Write-Host 'cargo build failed.'
exit 1
}
} finally {
Pop-Location
}
$built = Join-Path $here 'target\release\gm-switch.exe'
if (-not (Test-Path -LiteralPath $built)) {
Write-Host "Build output not found: $built"
exit 1
}
# --- Install -------------------------------------------------------------------
New-Item -ItemType Directory -Path $binDir -Force | Out-Null
Copy-Item -LiteralPath $built -Destination $exe -Force
Write-Host "Installed $exe"
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
if (-not $userPath) { $userPath = '' }
if (-not (($userPath -split ';') -contains $binDir)) {
$newPath = ($userPath.TrimEnd(';') + ';' + $binDir).TrimStart(';')
[Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
Write-Host "Added $binDir to your user PATH (open a new terminal to pick it up)"
}
# --- Shortcuts ------------------------------------------------------------------
New-Item -ItemType Directory -Path $startMenuDir -Force | Out-Null
$modes = @(
@{ Value = '0'; Name = 'NSFW' },
@{ Value = '1'; Name = 'SFW' },
@{ Value = '2'; Name = 'Questionable' }
)
$shell = New-Object -ComObject WScript.Shell
foreach ($m in $modes) {
$lnkPath = Join-Path $startMenuDir ('J621 Greeting - ' + $m.Name + '.lnk')
$shortcut = $shell.CreateShortcut($lnkPath)
$shortcut.TargetPath = "$env:SystemRoot\System32\cmd.exe"
$shortcut.Arguments = '/k ""' + $exe + '" --mode ' + $m.Value + '"'
$shortcut.WorkingDirectory = $env:USERPROFILE
$shortcut.Description = 'Set the J621 shell greeting to ' + $m.Name + ' mode'
$shortcut.Save()
Write-Host ('Created shortcut: ' + $lnkPath)
}
Write-Host ''
Write-Host 'Done. Try: gm-switch --status'
Write-Host 'The Start Menu shortcuts open a small console showing the confirmation.'
exit 0
+188
View File
@@ -0,0 +1,188 @@
//! gm-switch - set the J621 shell greeting rating mode.
//!
//! Rust port of the old gm-switch.cpp (J621-Django/extras/fish_greeting
//! system). On Windows it writes the mode file used by the PowerShell
//! greeting (%APPDATA%\j621Greeting\greeting_mode); on Linux/macOS it writes
//! ~/.config/fish/greeting_mode, so it can stand in for the C++ tool there.
//!
//! Usage:
//! gm-switch --mode <0|1|2> [--path <file>]
//! gm-switch --status [--path <file>]
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::exit;
const USAGE: &str = "\
Usage: gm-switch --mode <0|1|2>
--mode 0 Set to NSFW mode (explicit)
--mode 1 Set to SFW mode (safe)
--mode 2 Set to Questionable mode
--status Print the current greeting mode
--path <file> Override the mode file location
";
fn mode_name(mode: &str) -> Option<&'static str> {
match mode {
"0" => Some("NSFW"),
"1" => Some("SFW"),
"2" => Some("Questionable"),
_ => None,
}
}
fn default_mode_file() -> PathBuf {
if let Ok(path) = env::var("J621_MODE_FILE") {
if !path.is_empty() {
return PathBuf::from(path);
}
}
#[cfg(windows)]
{
if let Ok(appdata) = env::var("APPDATA") {
return Path::new(&appdata).join("j621Greeting").join("greeting_mode");
}
}
#[cfg(not(windows))]
{
if let Ok(home) = env::var("HOME") {
return Path::new(&home)
.join(".config")
.join("fish")
.join("greeting_mode");
}
}
PathBuf::from("greeting_mode")
}
fn main() {
let args: Vec<String> = env::args().skip(1).collect();
let mut mode: Option<String> = None;
let mut status = false;
let mut path_override: Option<String> = None;
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--mode" => {
if i + 1 < args.len() {
mode = Some(args[i + 1].clone());
i += 2;
} else {
eprintln!("Error: --mode requires a value");
print!("{USAGE}");
exit(1);
}
}
"--status" => {
status = true;
i += 1;
}
"--path" => {
if i + 1 < args.len() {
path_override = Some(args[i + 1].clone());
i += 2;
} else {
eprintln!("Error: --path requires a value");
print!("{USAGE}");
exit(1);
}
}
"-h" | "--help" => {
print!("{USAGE}");
exit(0);
}
other => {
eprintln!("Error: unknown argument: {other}");
print!("{USAGE}");
exit(1);
}
}
}
let mode_file = match &path_override {
Some(p) => PathBuf::from(p),
None => default_mode_file(),
};
if status {
match fs::read_to_string(&mode_file) {
Ok(content) => {
let value = content.trim().to_string();
match mode_name(&value) {
Some(name) => println!("Current greeting mode: {name} ({value})"),
None => println!(
"Current greeting mode: {value} (unrecognized - the greeting falls back to NSFW)"
),
}
}
Err(_) => {
println!("Current greeting mode: 2 (Questionable, default - no mode file yet)")
}
}
return;
}
let value = match mode {
Some(v) => v,
None => {
print!("{USAGE}");
exit(1);
}
};
let name = match mode_name(&value) {
Some(name) => name,
None => {
eprintln!("Error: Mode must be 0, 1, or 2");
print!("{USAGE}");
exit(1);
}
};
if let Some(parent) = mode_file.parent() {
if !parent.as_os_str().is_empty() {
if let Err(err) = fs::create_dir_all(parent) {
eprintln!("Error: {err}");
exit(1);
}
}
}
if let Err(err) = fs::write(&mode_file, &value) {
eprintln!(
"Error: Could not open file for writing: {}",
mode_file.display()
);
eprintln!("Error: {err}");
exit(1);
}
println!("Successfully set greeting mode to: {name}");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maps_modes_to_names() {
assert_eq!(mode_name("0"), Some("NSFW"));
assert_eq!(mode_name("1"), Some("SFW"));
assert_eq!(mode_name("2"), Some("Questionable"));
assert_eq!(mode_name("x"), None);
}
#[test]
fn writes_and_reads_mode_file() {
let dir = env::temp_dir().join("gm-switch-test");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let file = dir.join("greeting_mode");
fs::write(&file, "1").unwrap();
assert_eq!(fs::read_to_string(&file).unwrap().trim(), "1");
let _ = fs::remove_dir_all(&dir);
}
}
+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
+29 -12
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,28 +148,43 @@ 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 ? `${cloud.blacklist_count} entries` : "…"}
{cloud?.status === "building" ? (
<Spinner className="h-3 w-3" />
) : null}
{expanded ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</span>
</button>
{expanded ? (
<>
<p className="mt-2 text-xs text-ctp-overlay0">
{cloud?.source === "user" {cloud?.source === "user"
? "From your e621 blacklist" ? "From your e621 blacklist"
: "From e621's anonymous default blacklist"} : "From e621's anonymous default blacklist"}
{cloud ? ` · ${cloud.blacklist_count} entries` : ""}
{cloud ? ` · ${cloud.posts} feed post(s) scanned` : ""} {cloud ? ` · ${cloud.posts} feed post(s) scanned` : ""}
{cloud?.computed_at ? ` · computed ${formatDate(cloud.computed_at)}` : ""} {cloud?.computed_at
{cloud?.status === "building" ? ( ? ` · computed ${formatDate(cloud.computed_at)}`
<span className="ml-2 inline-flex items-center gap-1.5"> : ""}
<Spinner className="h-3 w-3" /> building…
</span>
) : null}
</p> </p>
</div>
{query.isPending ? ( {query.isPending ? (
<div className="mt-3 flex justify-center py-4"> <div className="mt-3 flex justify-center py-4">
<Spinner className="h-4 w-4" /> <Spinner className="h-4 w-4" />
@@ -190,6 +205,8 @@ function BlacklistCloudPanel() {
No blacklisted tags in your feeds. No blacklisted tags in your feeds.
</p> </p>
)} )}
</>
) : null}
</section> </section>
); );
} }
+22 -3
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,10 +272,27 @@ export default function OnlinePage() {
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<button
type="button"
onClick={() => setShowBlacklist((value) => !value)}
title={showBlacklist ? "Hide the blacklist" : "Show the blacklist"}
className="flex items-center justify-between gap-2 text-left"
>
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1"> <span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
Your blacklist Your blacklist
</span> </span>
{credentials?.configured ? ( <span className="flex items-center gap-1 font-mono text-[11px] text-ctp-overlay0">
{blacklistEntries.length} tag
{blacklistEntries.length === 1 ? "" : "s"}
{showBlacklist ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</span>
</button>
{showBlacklist ? (
credentials?.configured ? (
<> <>
{blacklistEntries.length > 0 ? ( {blacklistEntries.length > 0 ? (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
@@ -337,7 +355,8 @@ export default function OnlinePage() {
</Link>{" "} </Link>{" "}
to manage your blacklist. to manage your blacklist.
</p> </p>
)} )
) : null}
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
+108 -69
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,6 +543,12 @@ function MetadataForm({
{temp.original_filename} {temp.original_filename}
</p> </p>
</div> </div>
<div className="flex items-center gap-2">
{temp.processing ? (
<span className="flex items-center gap-1.5 font-mono text-[10px] text-ctp-overlay0">
<Spinner className="h-3 w-3" /> working
</span>
) : null}
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
@@ -504,22 +558,24 @@ function MetadataForm({
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </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">
<div className="w-fit max-w-full shrink-0 self-start overflow-hidden rounded-md border border-ctp-surface0 bg-ctp-mantle">
{mediaUrl ? ( {mediaUrl ? (
isVideo ? ( isVideo ? (
<video <video
src={mediaUrl} src={mediaUrl}
controls controls
muted muted
className="block h-auto max-h-[55vh] w-auto max-w-full" className="block h-auto max-h-[60vh] w-auto max-w-full"
/> />
) : ( ) : (
<img <img
src={mediaUrl} src={mediaUrl}
alt={temp.original_filename} alt={temp.original_filename}
className="block h-auto max-h-[55vh] w-auto max-w-full" className="block h-auto max-h-[60vh] w-auto max-w-full"
/> />
) )
) : ( ) : (
@@ -529,57 +585,32 @@ function MetadataForm({
)} )}
</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>
{tab === "link" ? (
<div className="flex flex-col gap-3">
{visualMatches.length > 0 ? ( {visualMatches.length > 0 ? (
<div> <div>
<p className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1"> <p className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
Already in your library Already in your library
</p> </p>
<div className="mt-2 flex flex-col gap-2"> <div className="mt-2 grid grid-cols-2 gap-3 sm:grid-cols-3 2xl:grid-cols-4">
{visualMatches.map((match) => ( {visualMatches.map((match) => (
<div <div
key={match.j_id} key={match.j_id}
className="flex items-center gap-3 rounded-md border border-ctp-surface0 bg-ctp-surface0/40 p-2" className="overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-surface0/30"
> >
<img <Link
to={`/detail/${match.j_id}`}
className="block"
title={match.filename}
>
<ThumbImage
src={apiUrl( src={apiUrl(
match.thumbnail_url ?? match.thumbnail_url ??
`/api/files/${match.j_id}/thumbnail/`, `/api/files/${match.j_id}/thumbnail/`,
)} )}
alt={match.j_id} className="aspect-square w-full"
loading="lazy"
className="h-12 w-12 shrink-0 rounded object-cover"
/> />
<span className="min-w-0 flex-1"> </Link>
<div className="flex items-center justify-between gap-2 px-2 py-1.5">
<span className="min-w-0">
<Link <Link
to={`/detail/${match.j_id}`} to={`/detail/${match.j_id}`}
className="font-mono text-[11px] text-ctp-blue hover:underline" className="font-mono text-[11px] text-ctp-blue hover:underline"
@@ -587,7 +618,7 @@ function MetadataForm({
{match.j_id} {match.j_id}
</Link> </Link>
<span <span
className="block truncate font-mono text-[11px] text-ctp-subtext0" className="block truncate font-mono text-[10px] text-ctp-overlay0"
title={match.filename} title={match.filename}
> >
{match.filename} {match.filename}
@@ -597,11 +628,12 @@ function MetadataForm({
{match.similarity}% {match.similarity}%
</span> </span>
</div> </div>
</div>
))} ))}
</div> </div>
<p className="mt-2 text-[11px] text-ctp-overlay0"> <p className="mt-2 text-[11px] text-ctp-overlay0">
This upload looks like it is already in the library — you This upload looks like it is already in the library — you can
can discard it below. discard it below.
</p> </p>
<button <button
type="button" type="button"
@@ -612,11 +644,13 @@ function MetadataForm({
</button> </button>
</div> </div>
) : null} ) : 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>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<p className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1"> <p className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
IQDB candidates e621 IQDB candidates
</p> </p>
{isVideo ? null : ( {isVideo ? null : (
<button <button
@@ -645,7 +679,7 @@ function MetadataForm({
</p> </p>
) : iqdbData && iqdbData.length > 0 ? ( ) : iqdbData && iqdbData.length > 0 ? (
<> <>
<div className="mt-2 grid grid-cols-3 gap-2"> <div className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-3">
{iqdbData.map((candidate) => ( {iqdbData.map((candidate) => (
<button <button
key={`${candidate.post_id}-${candidate.md5}`} key={`${candidate.post_id}-${candidate.md5}`}
@@ -661,11 +695,9 @@ function MetadataForm({
> >
<span className="relative block aspect-square overflow-hidden"> <span className="relative block aspect-square overflow-hidden">
{candidate.preview_url ? ( {candidate.preview_url ? (
<img <ThumbImage
src={candidate.preview_url} src={candidate.preview_url}
alt={`Post #${candidate.post_id}`} className="h-full w-full"
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"> <span className="flex h-full w-full items-center justify-center text-[10px] text-ctp-overlay0">
@@ -695,14 +727,13 @@ function MetadataForm({
</div> </div>
{selected ? ( {selected ? (
<div className="mt-2 rounded-md border border-ctp-surface0 bg-ctp-surface0/40 p-3"> <div className="mt-3 rounded-md border border-ctp-surface0 bg-ctp-surface0/40 p-3">
<p className="font-mono text-xs text-ctp-subtext1"> <p className="font-mono text-xs text-ctp-subtext1">
Post #{selected.post_id} Post #{selected.post_id}
</p> </p>
<p className="mt-1 text-xs text-ctp-overlay0"> <p className="mt-1 text-xs text-ctp-overlay0">
{selected.rating {selected.rating
? (RATING_LABELS[selected.rating] ?? ? (RATING_LABELS[selected.rating] ?? selected.rating)
selected.rating)
: "Unknown rating"} : "Unknown rating"}
{selected.score_total !== null && {selected.score_total !== null &&
selected.score_total !== undefined selected.score_total !== undefined
@@ -744,10 +775,6 @@ function MetadataForm({
View post View post
</Link> </Link>
</div> </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}
</> </>
@@ -763,7 +790,11 @@ function MetadataForm({
)} )}
</div> </div>
<div className="flex gap-2"> <div>
<p className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
Link to an e621 post
</p>
<div className="mt-2 flex gap-2">
<input <input
className={cn(inputClass, "font-mono")} className={cn(inputClass, "font-mono")}
placeholder="e621 post ID" placeholder="e621 post ID"
@@ -774,9 +805,17 @@ function MetadataForm({
Link Link
</Button> </Button>
</div> </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>
) : (
<div className="flex flex-col gap-3"> <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>
)} )}