Phase 3: J-IDs, ownership, roles, guest safety, adaptive detail, download
Backend: - User.role (user/uploader/staff) with can_upload; uploads and downloads gated to uploader+; owners and staff can edit their items - MediaItem.uploaded_by plus J-<id> identity (serializer, admin, scan_files --user, first superuser as default owner) - API resolves J-<id>, bare numeric ids and MD5s; neighbors and lookup return j_ids - Guest safety: mirror e621's anonymous default blacklist into Redis (parses comments, negations and wildcards), flag hidden_from_guests and filter lists, details and lookups for anonymous users - POST /api/online/downloads/ writes an e621 file into the watched folder and indexes it for the uploader - MariaDB + Redis via docker compose (host ports 3307/6380), PyMySQL driver shim, Redis cache replacing the file cache; SQLite data dumped and loaded into MariaDB Frontend: - Single /detail/:itemId route with an adaptive shell: J-<id> renders the library item, bare numbers render the e621 post - Legacy /view/<md5> and /online/view/<id> redirect to canonical URLs - Cards expose J-IDs; library custom-data editor is read-only for non-owners - Role gating: Upload hidden/blocked for regular users, account shows the role, guest hint on the library
This commit is contained in:
@@ -3,3 +3,18 @@ DEBUG=True
|
|||||||
ALLOWED_HOSTS=localhost,127.0.0.1
|
ALLOWED_HOSTS=localhost,127.0.0.1
|
||||||
# Absolute path to your media folder, or relative to the backend/ folder.
|
# Absolute path to your media folder, or relative to the backend/ folder.
|
||||||
WATCHED_FOLDER=media/library
|
WATCHED_FOLDER=media/library
|
||||||
|
|
||||||
|
# MariaDB (docker compose at the repo root)
|
||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_PORT=3307
|
||||||
|
DB_NAME=j621
|
||||||
|
DB_USER=j621
|
||||||
|
DB_PASSWORD=j621
|
||||||
|
|
||||||
|
# Redis (docker compose at the repo root)
|
||||||
|
REDIS_URL=redis://127.0.0.1:6380/1
|
||||||
|
|
||||||
|
# Guest visibility: e621's anonymous default blacklist is mirrored into
|
||||||
|
# Redis by `manage.py refresh_guest_blacklist`.
|
||||||
|
# GUEST_BLACKLIST_FALLBACK=young,cub,shota,loli,child,underage
|
||||||
|
# GUEST_BLACKLIST_TTL=3600
|
||||||
|
|||||||
@@ -6,4 +6,18 @@ from .models import User
|
|||||||
|
|
||||||
@admin.register(User)
|
@admin.register(User)
|
||||||
class CustomUserAdmin(UserAdmin):
|
class CustomUserAdmin(UserAdmin):
|
||||||
pass
|
list_display = ("username", "email", "role", "is_staff", "is_superuser")
|
||||||
|
list_filter = UserAdmin.list_filter + ("role",)
|
||||||
|
fieldsets = UserAdmin.fieldsets + (
|
||||||
|
(
|
||||||
|
"J621",
|
||||||
|
{
|
||||||
|
"fields": (
|
||||||
|
"role",
|
||||||
|
"e621_username",
|
||||||
|
"e621_api_key",
|
||||||
|
"e621_base_url",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.1.1 on 2026-09-17 15:01
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0002_user_e621_api_key_user_e621_base_url_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='user',
|
||||||
|
name='role',
|
||||||
|
field=models.CharField(choices=[('user', 'User'), ('uploader', 'Uploader'), ('staff', 'Staff')], default='user', max_length=20),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -3,8 +3,18 @@ from django.db import models
|
|||||||
|
|
||||||
|
|
||||||
class User(AbstractUser):
|
class User(AbstractUser):
|
||||||
"""Project user with optional e621 API credentials."""
|
"""Project user with optional e621 API credentials and an app role."""
|
||||||
|
|
||||||
|
ROLE_USER = "user"
|
||||||
|
ROLE_UPLOADER = "uploader"
|
||||||
|
ROLE_STAFF = "staff"
|
||||||
|
ROLE_CHOICES = [
|
||||||
|
(ROLE_USER, "User"),
|
||||||
|
(ROLE_UPLOADER, "Uploader"),
|
||||||
|
(ROLE_STAFF, "Staff"),
|
||||||
|
]
|
||||||
|
|
||||||
|
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default=ROLE_USER)
|
||||||
e621_username = models.CharField(max_length=100, blank=True, default="")
|
e621_username = models.CharField(max_length=100, blank=True, default="")
|
||||||
e621_api_key = models.CharField(max_length=100, blank=True, default="")
|
e621_api_key = models.CharField(max_length=100, blank=True, default="")
|
||||||
e621_base_url = models.CharField(max_length=200, default="https://e621.net")
|
e621_base_url = models.CharField(max_length=200, default="https://e621.net")
|
||||||
@@ -12,3 +22,7 @@ class User(AbstractUser):
|
|||||||
@property
|
@property
|
||||||
def e621_configured(self):
|
def e621_configured(self):
|
||||||
return bool(self.e621_username and self.e621_api_key)
|
return bool(self.e621_username and self.e621_api_key)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def can_upload(self):
|
||||||
|
return self.is_superuser or self.role in {self.ROLE_UPLOADER, self.ROLE_STAFF}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from .models import User
|
|||||||
|
|
||||||
class UserSerializer(serializers.ModelSerializer):
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
e621_configured = serializers.BooleanField(read_only=True)
|
e621_configured = serializers.BooleanField(read_only=True)
|
||||||
|
can_upload = serializers.BooleanField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
@@ -15,6 +16,8 @@ class UserSerializer(serializers.ModelSerializer):
|
|||||||
"email",
|
"email",
|
||||||
"is_staff",
|
"is_staff",
|
||||||
"date_joined",
|
"date_joined",
|
||||||
|
"role",
|
||||||
|
"can_upload",
|
||||||
"e621_username",
|
"e621_username",
|
||||||
"e621_configured",
|
"e621_configured",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -11,11 +11,16 @@ class MediaLocationInline(admin.TabularInline):
|
|||||||
|
|
||||||
@admin.register(MediaItem)
|
@admin.register(MediaItem)
|
||||||
class MediaItemAdmin(admin.ModelAdmin):
|
class MediaItemAdmin(admin.ModelAdmin):
|
||||||
list_display = ("md5", "size", "rating", "created_at")
|
list_display = ("__str__", "j_id", "size", "rating", "uploaded_by", "hidden_from_guests", "created_at")
|
||||||
list_filter = ("rating",)
|
list_filter = ("rating", "hidden_from_guests")
|
||||||
search_fields = ("md5",)
|
search_fields = ("md5",)
|
||||||
|
readonly_fields = ("hidden_from_guests",)
|
||||||
inlines = [MediaLocationInline]
|
inlines = [MediaLocationInline]
|
||||||
|
|
||||||
|
@admin.display(description="J-ID")
|
||||||
|
def j_id(self, obj):
|
||||||
|
return f"J-{obj.id}"
|
||||||
|
|
||||||
|
|
||||||
@admin.register(MediaLocation)
|
@admin.register(MediaLocation)
|
||||||
class MediaLocationAdmin(admin.ModelAdmin):
|
class MediaLocationAdmin(admin.ModelAdmin):
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""Guest visibility: mirror e621's anonymous default blacklist.
|
||||||
|
|
||||||
|
Items whose tags intersect the blacklist are hidden from anonymous visitors.
|
||||||
|
e621's blacklist entries can be comments (``# ...``), compound expressions
|
||||||
|
(``young -rating:s``) or wildcards (``*young*``); we extract the positive
|
||||||
|
plain patterns and match them case-insensitively (negations are ignored, so
|
||||||
|
matching stays conservative).
|
||||||
|
|
||||||
|
The raw list is cached in Redis; ``manage.py refresh_guest_blacklist``
|
||||||
|
refreshes it from e621 and recomputes the item flags.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from fnmatch import fnmatch
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CACHE_KEY = "j621.guest_blacklist"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_blacklist_patterns(lines):
|
||||||
|
"""Extract lowercase tag patterns from e621 blacklist lines."""
|
||||||
|
patterns = []
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped or stripped.startswith("#"):
|
||||||
|
continue
|
||||||
|
for token in stripped.split():
|
||||||
|
if token.startswith("-"):
|
||||||
|
continue
|
||||||
|
patterns.append(token.lower())
|
||||||
|
return patterns
|
||||||
|
|
||||||
|
|
||||||
|
def cached_guest_blacklist():
|
||||||
|
"""Raw mirrored blacklist lines. Never hits the network."""
|
||||||
|
stored = cache.get(CACHE_KEY)
|
||||||
|
if stored is not None:
|
||||||
|
return list(stored)
|
||||||
|
return list(settings.GUEST_BLACKLIST_FALLBACK)
|
||||||
|
|
||||||
|
|
||||||
|
def guest_blacklist_patterns():
|
||||||
|
return parse_blacklist_patterns(cached_guest_blacklist())
|
||||||
|
|
||||||
|
|
||||||
|
def item_hidden_from_guests(tags):
|
||||||
|
if not tags:
|
||||||
|
return False
|
||||||
|
patterns = guest_blacklist_patterns()
|
||||||
|
if not patterns:
|
||||||
|
return False
|
||||||
|
for tag in tags:
|
||||||
|
candidate = str(tag).strip().lower()
|
||||||
|
if not candidate:
|
||||||
|
continue
|
||||||
|
if any(fnmatch(candidate, pattern) for pattern in patterns):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_e621_default_blacklist():
|
||||||
|
"""e621's anonymous default blacklist, or None when unreachable."""
|
||||||
|
try:
|
||||||
|
response = requests.get(
|
||||||
|
f"{settings.E621_BASE_URL}/users/me.json",
|
||||||
|
headers={"User-Agent": settings.USER_AGENT},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
raw = response.json().get("blacklisted_tags", "")
|
||||||
|
return [line.strip() for line in raw.split("\n") if line.strip()]
|
||||||
|
except (requests.RequestException, ValueError) as exc:
|
||||||
|
logger.warning("Could not fetch e621 default blacklist: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_guest_blacklist():
|
||||||
|
"""Refresh the mirrored blacklist and recompute guest visibility flags."""
|
||||||
|
from .models import MediaItem
|
||||||
|
|
||||||
|
lines = fetch_e621_default_blacklist()
|
||||||
|
used_fallback = lines is None
|
||||||
|
if used_fallback:
|
||||||
|
lines = list(settings.GUEST_BLACKLIST_FALLBACK)
|
||||||
|
cache.set(CACHE_KEY, lines, settings.GUEST_BLACKLIST_TTL)
|
||||||
|
|
||||||
|
patterns = parse_blacklist_patterns(lines)
|
||||||
|
updates = []
|
||||||
|
for item in MediaItem.objects.only("id", "tags", "hidden_from_guests").iterator():
|
||||||
|
hidden = False
|
||||||
|
for tag in item.tags or []:
|
||||||
|
candidate = str(tag).strip().lower()
|
||||||
|
if candidate and any(
|
||||||
|
fnmatch(candidate, pattern) for pattern in patterns
|
||||||
|
):
|
||||||
|
hidden = True
|
||||||
|
break
|
||||||
|
if item.hidden_from_guests != hidden:
|
||||||
|
item.hidden_from_guests = hidden
|
||||||
|
updates.append(item)
|
||||||
|
if updates:
|
||||||
|
MediaItem.objects.bulk_update(updates, ["hidden_from_guests"])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"blacklist": lines,
|
||||||
|
"patterns": patterns,
|
||||||
|
"updated": len(updates),
|
||||||
|
"fallback": used_fallback,
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
from apps.library.guest_filter import refresh_guest_blacklist
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = (
|
||||||
|
"Mirror e621's anonymous default blacklist and recompute which "
|
||||||
|
"library items are hidden from guests."
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
result = refresh_guest_blacklist()
|
||||||
|
source = "fallback" if result["fallback"] else "e621"
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f"Guest blacklist from {source}: {len(result['blacklist'])} tag(s); "
|
||||||
|
f"{result['updated']} item(s) updated."
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -2,11 +2,14 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
from apps.library.models import MediaLocation
|
from apps.library.models import MediaLocation
|
||||||
from apps.library.services import ALLOWED_EXTENSIONS, index_file
|
from apps.library.services import ALLOWED_EXTENSIONS, index_file
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = "Scan the watched folder and index media files into the library."
|
help = "Scan the watched folder and index media files into the library."
|
||||||
@@ -17,6 +20,10 @@ class Command(BaseCommand):
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="Remove locations whose files no longer exist on disk.",
|
help="Remove locations whose files no longer exist on disk.",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--user",
|
||||||
|
help="Username that owns newly indexed files (default: first superuser).",
|
||||||
|
)
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
folder = Path(settings.WATCHED_FOLDER)
|
folder = Path(settings.WATCHED_FOLDER)
|
||||||
@@ -26,6 +33,26 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
owner = None
|
||||||
|
if options["user"]:
|
||||||
|
owner = User.objects.filter(username=options["user"]).first()
|
||||||
|
if owner is None:
|
||||||
|
self.stderr.write(
|
||||||
|
self.style.ERROR(f"User not found: {options['user']}")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
owner = User.objects.filter(is_superuser=True).order_by("id").first()
|
||||||
|
if owner is None:
|
||||||
|
self.stderr.write(
|
||||||
|
self.style.WARNING(
|
||||||
|
"No superuser found; scanned files will have no owner. "
|
||||||
|
"Use --user <name> to assign one."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if owner is not None:
|
||||||
|
self.stdout.write(f"New files will be owned by {owner.username}.")
|
||||||
|
|
||||||
self.stdout.write(f"Scanning {folder} ...")
|
self.stdout.write(f"Scanning {folder} ...")
|
||||||
seen = set()
|
seen = set()
|
||||||
new_items = 0
|
new_items = 0
|
||||||
@@ -40,11 +67,14 @@ class Command(BaseCommand):
|
|||||||
continue
|
continue
|
||||||
path = Path(root) / name
|
path = Path(root) / name
|
||||||
try:
|
try:
|
||||||
_, created_item, created_location = index_file(path, folder)
|
item, created_item, created_location = index_file(path, folder)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
skipped += 1
|
skipped += 1
|
||||||
self.stderr.write(f"Skipped {path}: {exc}")
|
self.stderr.write(f"Skipped {path}: {exc}")
|
||||||
continue
|
continue
|
||||||
|
if owner is not None and item.uploaded_by_id is None:
|
||||||
|
item.uploaded_by = owner
|
||||||
|
item.save(update_fields=["uploaded_by", "updated_at"])
|
||||||
seen.add(str(path))
|
seen.add(str(path))
|
||||||
new_items += int(created_item)
|
new_items += int(created_item)
|
||||||
if created_location:
|
if created_location:
|
||||||
|
|||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
# Generated by Django 6.1.1 on 2026-09-17 15:01
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('library', '0001_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='mediaitem',
|
||||||
|
name='hidden_from_guests',
|
||||||
|
field=models.BooleanField(db_index=True, default=False),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='mediaitem',
|
||||||
|
name='uploaded_by',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='uploads', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
@@ -10,6 +11,15 @@ class MediaItem(models.Model):
|
|||||||
rating = models.CharField(max_length=1, blank=True, default="")
|
rating = models.CharField(max_length=1, blank=True, default="")
|
||||||
tags = models.JSONField(default=list, blank=True)
|
tags = models.JSONField(default=list, blank=True)
|
||||||
notes = models.TextField(blank=True, default="")
|
notes = models.TextField(blank=True, default="")
|
||||||
|
uploaded_by = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="uploads",
|
||||||
|
)
|
||||||
|
# Guests never see items whose tags hit the mirrored default blacklist.
|
||||||
|
hidden_from_guests = models.BooleanField(default=False, db_index=True)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
@@ -17,7 +27,13 @@ class MediaItem(models.Model):
|
|||||||
ordering = ["-created_at"]
|
ordering = ["-created_at"]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.md5
|
return f"J-{self.pk} ({self.md5})"
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
from .guest_filter import item_hidden_from_guests
|
||||||
|
|
||||||
|
self.hidden_from_guests = item_hidden_from_guests(self.tags)
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class MediaLocation(models.Model):
|
class MediaLocation(models.Model):
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from rest_framework import permissions
|
||||||
|
|
||||||
|
|
||||||
|
class CanUpload(permissions.BasePermission):
|
||||||
|
"""Only uploader/staff/admin accounts may add items to the library."""
|
||||||
|
|
||||||
|
message = "Your account is not allowed to upload."
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
user = request.user
|
||||||
|
return bool(user and user.is_authenticated and user.can_upload)
|
||||||
|
|
||||||
|
|
||||||
|
class IsUploaderOrStaffOrReadOnly(permissions.BasePermission):
|
||||||
|
"""Owners and staff can change an item; everyone can read."""
|
||||||
|
|
||||||
|
message = "Only the uploader or staff can change this item."
|
||||||
|
|
||||||
|
def has_object_permission(self, request, view, obj):
|
||||||
|
if request.method in permissions.SAFE_METHODS:
|
||||||
|
return True
|
||||||
|
user = request.user
|
||||||
|
if not (user and user.is_authenticated):
|
||||||
|
return False
|
||||||
|
if user.is_superuser or user.role == user.ROLE_STAFF:
|
||||||
|
return True
|
||||||
|
return obj.uploaded_by_id == user.id
|
||||||
@@ -18,13 +18,18 @@ class MediaItemSerializer(serializers.ModelSerializer):
|
|||||||
)
|
)
|
||||||
rating = serializers.ChoiceField(choices=["", "s", "q", "e"], required=False)
|
rating = serializers.ChoiceField(choices=["", "s", "q", "e"], required=False)
|
||||||
locations = MediaLocationSerializer(many=True, read_only=True)
|
locations = MediaLocationSerializer(many=True, read_only=True)
|
||||||
|
j_id = serializers.SerializerMethodField()
|
||||||
filename = serializers.SerializerMethodField()
|
filename = serializers.SerializerMethodField()
|
||||||
extension = serializers.SerializerMethodField()
|
extension = serializers.SerializerMethodField()
|
||||||
kind = serializers.SerializerMethodField()
|
kind = serializers.SerializerMethodField()
|
||||||
|
uploaded_by = serializers.SerializerMethodField()
|
||||||
|
uploaded_by_id = serializers.IntegerField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = MediaItem
|
model = MediaItem
|
||||||
fields = [
|
fields = [
|
||||||
|
"id",
|
||||||
|
"j_id",
|
||||||
"md5",
|
"md5",
|
||||||
"size",
|
"size",
|
||||||
"rating",
|
"rating",
|
||||||
@@ -33,21 +38,33 @@ class MediaItemSerializer(serializers.ModelSerializer):
|
|||||||
"filename",
|
"filename",
|
||||||
"extension",
|
"extension",
|
||||||
"kind",
|
"kind",
|
||||||
|
"uploaded_by",
|
||||||
|
"uploaded_by_id",
|
||||||
"locations",
|
"locations",
|
||||||
"created_at",
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
]
|
]
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
|
"id",
|
||||||
|
"j_id",
|
||||||
"md5",
|
"md5",
|
||||||
"size",
|
"size",
|
||||||
"filename",
|
"filename",
|
||||||
"extension",
|
"extension",
|
||||||
"kind",
|
"kind",
|
||||||
|
"uploaded_by",
|
||||||
|
"uploaded_by_id",
|
||||||
"locations",
|
"locations",
|
||||||
"created_at",
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def get_j_id(self, obj):
|
||||||
|
return f"J-{obj.id}"
|
||||||
|
|
||||||
|
def get_uploaded_by(self, obj):
|
||||||
|
return obj.uploaded_by.username if obj.uploaded_by else None
|
||||||
|
|
||||||
def _first_location(self, obj):
|
def _first_location(self, obj):
|
||||||
locations = list(obj.locations.all())
|
locations = list(obj.locations.all())
|
||||||
return locations[0] if locations else None
|
return locations[0] if locations else None
|
||||||
|
|||||||
@@ -160,6 +160,19 @@ def serve_file(request, path, download=False):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def download_file(url, destination):
|
||||||
|
"""Stream a remote file into destination (used by Download to Library)."""
|
||||||
|
import requests
|
||||||
|
|
||||||
|
headers = {"User-Agent": settings.USER_AGENT}
|
||||||
|
with requests.get(url, headers=headers, stream=True, timeout=120) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
with open(destination, "wb") as handle:
|
||||||
|
for chunk in response.iter_content(chunk_size=CHUNK_SIZE):
|
||||||
|
if chunk:
|
||||||
|
handle.write(chunk)
|
||||||
|
|
||||||
|
|
||||||
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"):
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
from .views import MediaItemViewSet, UploadView
|
from .views import DownloadToLibraryView, MediaItemViewSet, UploadView
|
||||||
|
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
router.register("files", MediaItemViewSet, basename="file")
|
router.register("files", MediaItemViewSet, basename="file")
|
||||||
@@ -9,4 +9,9 @@ router.register("files", MediaItemViewSet, basename="file")
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", include(router.urls)),
|
path("", include(router.urls)),
|
||||||
path("uploads/", UploadView.as_view(), name="upload"),
|
path("uploads/", UploadView.as_view(), name="upload"),
|
||||||
|
path(
|
||||||
|
"online/downloads/",
|
||||||
|
DownloadToLibraryView.as_view(),
|
||||||
|
name="download_to_library",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db.models import Min
|
from django.db.models import Min
|
||||||
|
from django.http import Http404
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
from rest_framework import mixins, status, viewsets
|
from rest_framework import mixins, status, viewsets
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.parsers import FormParser, MultiPartParser
|
from rest_framework.parsers import FormParser, MultiPartParser
|
||||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
from rest_framework.permissions import AllowAny, IsAuthenticatedOrReadOnly
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
from . import services
|
from . import services
|
||||||
from .models import MediaItem
|
from .models import MediaItem
|
||||||
|
from .permissions import CanUpload, IsUploaderOrStaffOrReadOnly
|
||||||
from .serializers import MediaItemSerializer
|
from .serializers import MediaItemSerializer
|
||||||
|
|
||||||
LIST_ORDERINGS = {"name", "-name", "size", "-size", "created_at", "-created_at"}
|
LIST_ORDERINGS = {"name", "-name", "size", "-size", "created_at", "-created_at"}
|
||||||
|
MD5_RE = re.compile(r"[0-9a-fA-F]{32}")
|
||||||
|
|
||||||
|
|
||||||
class MediaItemViewSet(
|
class MediaItemViewSet(
|
||||||
@@ -23,13 +29,15 @@ class MediaItemViewSet(
|
|||||||
viewsets.GenericViewSet,
|
viewsets.GenericViewSet,
|
||||||
):
|
):
|
||||||
serializer_class = MediaItemSerializer
|
serializer_class = MediaItemSerializer
|
||||||
lookup_field = "md5"
|
permission_classes = [IsAuthenticatedOrReadOnly, IsUploaderOrStaffOrReadOnly]
|
||||||
http_method_names = ["get", "post", "patch", "head", "options"]
|
http_method_names = ["get", "post", "patch", "head", "options"]
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
queryset = MediaItem.objects.prefetch_related("locations").annotate(
|
queryset = MediaItem.objects.prefetch_related("locations").annotate(
|
||||||
name=Min("locations__rel_path")
|
name=Min("locations__rel_path")
|
||||||
)
|
)
|
||||||
|
if not self.request.user.is_authenticated:
|
||||||
|
queryset = queryset.filter(hidden_from_guests=False)
|
||||||
search = self.request.query_params.get("search", "").strip()
|
search = self.request.query_params.get("search", "").strip()
|
||||||
if search:
|
if search:
|
||||||
queryset = queryset.filter(locations__rel_path__icontains=search)
|
queryset = queryset.filter(locations__rel_path__icontains=search)
|
||||||
@@ -46,8 +54,22 @@ class MediaItemViewSet(
|
|||||||
)
|
)
|
||||||
return queryset.distinct()
|
return queryset.distinct()
|
||||||
|
|
||||||
|
def get_object(self):
|
||||||
|
"""Resolve J-<id>, a bare numeric id, or an MD5 fingerprint."""
|
||||||
|
value = str(self.kwargs.get("pk", "")).strip()
|
||||||
|
queryset = self.get_queryset()
|
||||||
|
if MD5_RE.fullmatch(value):
|
||||||
|
obj = get_object_or_404(queryset, md5=value.lower())
|
||||||
|
else:
|
||||||
|
numeric = value[2:] if value.upper().startswith("J-") else value
|
||||||
|
if not numeric.isdigit():
|
||||||
|
raise Http404
|
||||||
|
obj = get_object_or_404(queryset, pk=int(numeric))
|
||||||
|
self.check_object_permissions(self.request, obj)
|
||||||
|
return obj
|
||||||
|
|
||||||
@action(detail=True, methods=["get"])
|
@action(detail=True, methods=["get"])
|
||||||
def raw(self, request, md5=None):
|
def raw(self, request, pk=None):
|
||||||
item = self.get_object()
|
item = self.get_object()
|
||||||
location = item.locations.first()
|
location = item.locations.first()
|
||||||
if location is None:
|
if location is None:
|
||||||
@@ -60,7 +82,7 @@ class MediaItemViewSet(
|
|||||||
)
|
)
|
||||||
|
|
||||||
@action(detail=True, methods=["get"])
|
@action(detail=True, methods=["get"])
|
||||||
def thumbnail(self, request, md5=None):
|
def thumbnail(self, request, pk=None):
|
||||||
item = self.get_object()
|
item = self.get_object()
|
||||||
location = item.locations.first()
|
location = item.locations.first()
|
||||||
if location is None:
|
if location is None:
|
||||||
@@ -81,7 +103,7 @@ class MediaItemViewSet(
|
|||||||
|
|
||||||
@action(detail=False, methods=["post"], permission_classes=[AllowAny])
|
@action(detail=False, methods=["post"], permission_classes=[AllowAny])
|
||||||
def lookup(self, request):
|
def lookup(self, request):
|
||||||
"""Return which of the given MD5s already exist in the library."""
|
"""Report which of the given MD5s exist in the library."""
|
||||||
md5s = request.data.get("md5s")
|
md5s = request.data.get("md5s")
|
||||||
if not isinstance(md5s, list):
|
if not isinstance(md5s, list):
|
||||||
return Response(
|
return Response(
|
||||||
@@ -89,11 +111,19 @@ class MediaItemViewSet(
|
|||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
wanted = {str(value).strip().lower()[:32] for value in md5s if value}
|
wanted = {str(value).strip().lower()[:32] for value in md5s if value}
|
||||||
found = MediaItem.objects.filter(md5__in=wanted).values_list("md5", flat=True)
|
queryset = MediaItem.objects.filter(md5__in=wanted)
|
||||||
return Response({"found": sorted(found)})
|
if not request.user.is_authenticated:
|
||||||
|
queryset = queryset.filter(hidden_from_guests=False)
|
||||||
|
rows = list(queryset.values_list("md5", "id"))
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"found": sorted(md5 for md5, _ in rows),
|
||||||
|
"j_ids": {md5: f"J-{item_id}" for md5, item_id in rows},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
@action(detail=True, methods=["get"])
|
@action(detail=True, methods=["get"])
|
||||||
def neighbors(self, request, md5=None):
|
def neighbors(self, request, pk=None):
|
||||||
"""Previous/next items in the ordered list, for keyboard navigation."""
|
"""Previous/next items in the ordered list, for keyboard navigation."""
|
||||||
item = self.get_object()
|
item = self.get_object()
|
||||||
ordering = request.query_params.get("ordering", "-created_at").strip()
|
ordering = request.query_params.get("ordering", "-created_at").strip()
|
||||||
@@ -120,6 +150,7 @@ class MediaItemViewSet(
|
|||||||
def brief(obj):
|
def brief(obj):
|
||||||
location = obj.locations.first()
|
location = obj.locations.first()
|
||||||
return {
|
return {
|
||||||
|
"j_id": f"J-{obj.id}",
|
||||||
"md5": obj.md5,
|
"md5": obj.md5,
|
||||||
"filename": Path(location.rel_path).name if location else obj.md5,
|
"filename": Path(location.rel_path).name if location else obj.md5,
|
||||||
}
|
}
|
||||||
@@ -135,7 +166,7 @@ class MediaItemViewSet(
|
|||||||
|
|
||||||
|
|
||||||
class UploadView(APIView):
|
class UploadView(APIView):
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [CanUpload]
|
||||||
parser_classes = [MultiPartParser, FormParser]
|
parser_classes = [MultiPartParser, FormParser]
|
||||||
|
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
@@ -150,7 +181,7 @@ class UploadView(APIView):
|
|||||||
for chunk in upload.chunks():
|
for chunk in upload.chunks():
|
||||||
output.write(chunk)
|
output.write(chunk)
|
||||||
try:
|
try:
|
||||||
item, _, _ = services.index_file(destination, folder)
|
item, created_item, _ = services.index_file(destination, folder)
|
||||||
except Exception as exc: # noqa: BLE001 - report indexing failures
|
except Exception as exc: # noqa: BLE001 - report indexing failures
|
||||||
destination.unlink(missing_ok=True)
|
destination.unlink(missing_ok=True)
|
||||||
return Response(
|
return Response(
|
||||||
@@ -159,6 +190,9 @@ class UploadView(APIView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
update_fields = []
|
update_fields = []
|
||||||
|
if created_item and item.uploaded_by_id is None:
|
||||||
|
item.uploaded_by = request.user
|
||||||
|
update_fields.append("uploaded_by")
|
||||||
rating = request.data.get("rating") or ""
|
rating = request.data.get("rating") or ""
|
||||||
if rating in {"s", "q", "e"} and item.rating != rating:
|
if rating in {"s", "q", "e"} and item.rating != rating:
|
||||||
item.rating = rating
|
item.rating = rating
|
||||||
@@ -178,3 +212,42 @@ class UploadView(APIView):
|
|||||||
MediaItemSerializer(item, context={"request": request}).data,
|
MediaItemSerializer(item, context={"request": request}).data,
|
||||||
status=status.HTTP_201_CREATED,
|
status=status.HTTP_201_CREATED,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadToLibraryView(APIView):
|
||||||
|
"""Fetch an e621 file into the watched folder and index it."""
|
||||||
|
|
||||||
|
permission_classes = [CanUpload]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
url = str(request.data.get("url") or "").strip()
|
||||||
|
post_id = request.data.get("post_id")
|
||||||
|
filename = str(request.data.get("filename") or "").strip()
|
||||||
|
if not url.startswith(("http://", "https://")):
|
||||||
|
return Response(
|
||||||
|
{"detail": "A valid file URL is required."},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
folder = Path(settings.WATCHED_FOLDER)
|
||||||
|
name = (
|
||||||
|
filename
|
||||||
|
or Path(urlparse(url).path).name
|
||||||
|
or f"post-{post_id or 'download'}"
|
||||||
|
)
|
||||||
|
destination = services.unique_destination(folder, name)
|
||||||
|
try:
|
||||||
|
services.download_file(url, destination)
|
||||||
|
item, _, _ = services.index_file(destination, folder)
|
||||||
|
except Exception as exc: # noqa: BLE001 - report download failures
|
||||||
|
destination.unlink(missing_ok=True)
|
||||||
|
return Response(
|
||||||
|
{"detail": f"Download failed: {exc}"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
if item.uploaded_by_id is None:
|
||||||
|
item.uploaded_by = request.user
|
||||||
|
item.save(update_fields=["uploaded_by", "updated_at"])
|
||||||
|
return Response(
|
||||||
|
MediaItemSerializer(item, context={"request": request}).data,
|
||||||
|
status=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"""Django project package.
|
||||||
|
|
||||||
|
Use PyMySQL as the MySQL/MariaDB driver. Django checks the driver library
|
||||||
|
version, so report a compatible one before installing the shim (same trick
|
||||||
|
the original J621 used).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pymysql
|
||||||
|
|
||||||
|
pymysql.version_info = (2, 2, 1, "final", 0)
|
||||||
|
pymysql.__version__ = "2.2.1"
|
||||||
|
|
||||||
|
pymysql.install_as_MySQLdb()
|
||||||
|
|||||||
@@ -85,12 +85,17 @@ TEMPLATES = [
|
|||||||
|
|
||||||
WSGI_APPLICATION = "config.wsgi.application"
|
WSGI_APPLICATION = "config.wsgi.application"
|
||||||
|
|
||||||
# Database
|
# Database (MariaDB, run via docker compose at the repo root)
|
||||||
|
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
"default": {
|
"default": {
|
||||||
"ENGINE": "django.db.backends.sqlite3",
|
"ENGINE": "django.db.backends.mysql",
|
||||||
"NAME": BASE_DIR / "db.sqlite3",
|
"NAME": os.getenv("DB_NAME", "j621"),
|
||||||
|
"USER": os.getenv("DB_USER", "j621"),
|
||||||
|
"PASSWORD": os.getenv("DB_PASSWORD", "j621"),
|
||||||
|
"HOST": os.getenv("DB_HOST", "127.0.0.1"),
|
||||||
|
"PORT": os.getenv("DB_PORT", "3307"),
|
||||||
|
"OPTIONS": {"charset": "utf8mb4"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +140,32 @@ if not WATCHED_FOLDER.is_absolute():
|
|||||||
WATCHED_FOLDER = BASE_DIR / WATCHED_FOLDER
|
WATCHED_FOLDER = BASE_DIR / WATCHED_FOLDER
|
||||||
WATCHED_FOLDER = str(WATCHED_FOLDER)
|
WATCHED_FOLDER = str(WATCHED_FOLDER)
|
||||||
|
|
||||||
|
# e621 integration
|
||||||
|
|
||||||
|
E621_BASE_URL = os.getenv("E621_BASE_URL", "https://e621.net").rstrip("/")
|
||||||
|
USER_AGENT = os.getenv("USER_AGENT", "J621/0.1 (by J621 on e621)")
|
||||||
|
|
||||||
|
# Guest visibility: e621's anonymous default blacklist is mirrored into the
|
||||||
|
# cache by `manage.py refresh_guest_blacklist`. This fallback is used until
|
||||||
|
# that command runs or when e621 is unreachable.
|
||||||
|
GUEST_BLACKLIST_FALLBACK = [
|
||||||
|
tag.strip()
|
||||||
|
for tag in os.getenv(
|
||||||
|
"GUEST_BLACKLIST_FALLBACK", "young,cub,shota,loli,child,underage"
|
||||||
|
).split(",")
|
||||||
|
if tag.strip()
|
||||||
|
]
|
||||||
|
GUEST_BLACKLIST_TTL = int(os.getenv("GUEST_BLACKLIST_TTL", "3600"))
|
||||||
|
|
||||||
|
# Redis cache (run via docker compose at the repo root), shared by web
|
||||||
|
# workers and management commands (e.g. the mirrored guest blacklist).
|
||||||
|
CACHES = {
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "django.core.cache.backends.redis.RedisCache",
|
||||||
|
"LOCATION": os.getenv("REDIS_URL", "redis://127.0.0.1:6380/1"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# Django REST Framework
|
# Django REST Framework
|
||||||
|
|
||||||
REST_FRAMEWORK = {
|
REST_FRAMEWORK = {
|
||||||
|
|||||||
@@ -3,3 +3,6 @@ djangorestframework>=3.17
|
|||||||
django-filter>=25.1
|
django-filter>=25.1
|
||||||
Pillow>=11.0
|
Pillow>=11.0
|
||||||
python-dotenv>=1.0
|
python-dotenv>=1.0
|
||||||
|
requests>=2.32
|
||||||
|
PyMySQL>=1.1
|
||||||
|
redis>=5.0
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
name: j621
|
||||||
|
|
||||||
|
services:
|
||||||
|
mariadb:
|
||||||
|
image: mariadb:11.4
|
||||||
|
container_name: j621-mariadb
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-j621root}
|
||||||
|
MARIADB_DATABASE: ${DB_NAME:-j621}
|
||||||
|
MARIADB_USER: ${DB_USER:-j621}
|
||||||
|
MARIADB_PASSWORD: ${DB_PASSWORD:-j621}
|
||||||
|
ports:
|
||||||
|
# Host port 3307 is free; 3305/3406 belong to other local projects.
|
||||||
|
- "${DB_PORT:-3307}:3306"
|
||||||
|
volumes:
|
||||||
|
- mariadb-data:/var/lib/mysql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: j621-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ["redis-server", "--appendonly", "yes"]
|
||||||
|
ports:
|
||||||
|
# Host port 6379 is taken by another local project's Redis.
|
||||||
|
- "${REDIS_PORT:-6380}:6379"
|
||||||
|
volumes:
|
||||||
|
- redis-data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mariadb-data:
|
||||||
|
redis-data:
|
||||||
+17
-5
@@ -1,14 +1,20 @@
|
|||||||
import { useEffect, type ReactNode } from "react";
|
import { useEffect, type ReactNode } from "react";
|
||||||
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
|
import {
|
||||||
|
Navigate,
|
||||||
|
Route,
|
||||||
|
Routes,
|
||||||
|
useLocation,
|
||||||
|
useParams,
|
||||||
|
} from "react-router-dom";
|
||||||
|
|
||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { FullPageSpinner } from "@/components/ui";
|
import { FullPageSpinner } from "@/components/ui";
|
||||||
import AccountPage from "@/features/account/AccountPage";
|
import AccountPage from "@/features/account/AccountPage";
|
||||||
import LoginPage from "@/features/auth/LoginPage";
|
import LoginPage from "@/features/auth/LoginPage";
|
||||||
import RegisterPage from "@/features/auth/RegisterPage";
|
import RegisterPage from "@/features/auth/RegisterPage";
|
||||||
import FileDetailPage from "@/features/library/FileDetailPage";
|
import DetailPage from "@/features/detail/DetailPage";
|
||||||
import LibraryPage from "@/features/library/LibraryPage";
|
import LibraryPage from "@/features/library/LibraryPage";
|
||||||
import OnlineDetailPage from "@/features/online/OnlineDetailPage";
|
import Md5Redirect from "@/features/library/Md5Redirect";
|
||||||
import OnlinePage from "@/features/online/OnlinePage";
|
import OnlinePage from "@/features/online/OnlinePage";
|
||||||
import UploadPage from "@/features/upload/UploadPage";
|
import UploadPage from "@/features/upload/UploadPage";
|
||||||
import { useAuth } from "@/store/auth";
|
import { useAuth } from "@/store/auth";
|
||||||
@@ -22,6 +28,11 @@ function RequireAuth({ children }: { children: ReactNode }) {
|
|||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PostRedirect() {
|
||||||
|
const { postId = "" } = useParams();
|
||||||
|
return <Navigate to={`/detail/${postId}`} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const ready = useAuth((state) => state.ready);
|
const ready = useAuth((state) => state.ready);
|
||||||
const loadUser = useAuth((state) => state.loadUser);
|
const loadUser = useAuth((state) => state.loadUser);
|
||||||
@@ -38,9 +49,10 @@ export default function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<AppShell />}>
|
<Route element={<AppShell />}>
|
||||||
<Route path="/" element={<LibraryPage />} />
|
<Route path="/" element={<LibraryPage />} />
|
||||||
<Route path="/view/:md5" element={<FileDetailPage />} />
|
<Route path="/detail/:itemId" element={<DetailPage />} />
|
||||||
|
<Route path="/view/:md5" element={<Md5Redirect />} />
|
||||||
<Route path="/online" element={<OnlinePage />} />
|
<Route path="/online" element={<OnlinePage />} />
|
||||||
<Route path="/online/view/:id" element={<OnlineDetailPage />} />
|
<Route path="/online/view/:postId" element={<PostRedirect />} />
|
||||||
<Route
|
<Route
|
||||||
path="/upload"
|
path="/upload"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -13,12 +13,6 @@ import { useAuth } from "@/store/auth";
|
|||||||
import { useE621 } from "@/store/e621";
|
import { useE621 } from "@/store/e621";
|
||||||
import { useUi } from "@/store/ui";
|
import { useUi } from "@/store/ui";
|
||||||
|
|
||||||
const navItems = [
|
|
||||||
{ to: "/", label: "Library" },
|
|
||||||
{ to: "/online", label: "Online" },
|
|
||||||
{ to: "/upload", label: "Upload" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
const user = useAuth((state) => state.user);
|
const user = useAuth((state) => state.user);
|
||||||
const logout = useAuth((state) => state.logout);
|
const logout = useAuth((state) => state.logout);
|
||||||
@@ -63,6 +57,12 @@ export function AppShell() {
|
|||||||
const showSidebarToggle =
|
const showSidebarToggle =
|
||||||
location.pathname === "/" || location.pathname === "/online";
|
location.pathname === "/" || location.pathname === "/online";
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ to: "/", label: "Library" },
|
||||||
|
{ to: "/online", label: "Online" },
|
||||||
|
...(user?.can_upload ? [{ to: "/upload", label: "Upload" }] : []),
|
||||||
|
];
|
||||||
|
|
||||||
const navigationType = useNavigationType();
|
const navigationType = useNavigationType();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// New navigations start at the top; history back/forward keeps position.
|
// New navigations start at the top; history back/forward keeps position.
|
||||||
|
|||||||
@@ -51,12 +51,16 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
|||||||
icon: FolderOpen,
|
icon: FolderOpen,
|
||||||
run: () => navigate("/"),
|
run: () => navigate("/"),
|
||||||
},
|
},
|
||||||
|
...(user?.can_upload
|
||||||
|
? [
|
||||||
{
|
{
|
||||||
id: "upload",
|
id: "upload",
|
||||||
label: "Go to Upload",
|
label: "Go to Upload",
|
||||||
icon: Upload,
|
icon: Upload,
|
||||||
run: () => navigate("/upload"),
|
run: () => navigate("/upload"),
|
||||||
},
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
id: "online",
|
id: "online",
|
||||||
label: "Go to Online browser",
|
label: "Go to Online browser",
|
||||||
|
|||||||
@@ -19,12 +19,12 @@ const ratingLabels: Record<string, string> = {
|
|||||||
export function MediaCard({ item }: { item: MediaItem }) {
|
export function MediaCard({ item }: { item: MediaItem }) {
|
||||||
const preview =
|
const preview =
|
||||||
item.kind === "video"
|
item.kind === "video"
|
||||||
? `/api/files/${item.md5}/thumbnail/`
|
? `/api/files/${item.j_id}/thumbnail/`
|
||||||
: `/api/files/${item.md5}/raw/`;
|
: `/api/files/${item.j_id}/raw/`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={`/view/${item.md5}`}
|
to={`/detail/${item.j_id}`}
|
||||||
className="group overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-base transition hover:border-ctp-surface2"
|
className="group overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-base transition hover:border-ctp-surface2"
|
||||||
>
|
>
|
||||||
<div className="relative aspect-square overflow-hidden bg-ctp-mantle">
|
<div className="relative aspect-square overflow-hidden bg-ctp-mantle">
|
||||||
@@ -54,7 +54,11 @@ export function MediaCard({ item }: { item: MediaItem }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-2.5 py-2">
|
<div className="px-2.5 py-2">
|
||||||
<p className="truncate font-mono text-xs text-ctp-subtext1" title={item.filename}>
|
<p className="font-mono text-[10px] text-ctp-overlay0">{item.j_id}</p>
|
||||||
|
<p
|
||||||
|
className="truncate font-mono text-xs text-ctp-subtext1"
|
||||||
|
title={item.filename}
|
||||||
|
>
|
||||||
{item.filename}
|
{item.filename}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ export function Field({
|
|||||||
export const inputClass =
|
export const inputClass =
|
||||||
"w-full rounded-md border border-ctp-surface0 bg-ctp-mantle px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay0 focus:border-ctp-mauve focus:outline-none";
|
"w-full rounded-md border border-ctp-surface0 bg-ctp-mantle px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay0 focus:border-ctp-mauve focus:outline-none";
|
||||||
|
|
||||||
|
export const linkButtonClass =
|
||||||
|
"inline-flex items-center justify-center gap-2 rounded-md border border-ctp-surface1 bg-ctp-surface0 px-3 py-1.5 text-sm font-medium text-ctp-text transition hover:bg-ctp-surface1";
|
||||||
|
|
||||||
export function EmptyState({
|
export function EmptyState({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
|
|||||||
@@ -230,8 +230,11 @@ export default function AccountPage() {
|
|||||||
<header>
|
<header>
|
||||||
<h1 className="text-lg font-semibold">Account</h1>
|
<h1 className="text-lg font-semibold">Account</h1>
|
||||||
{user ? (
|
{user ? (
|
||||||
<p className="mt-1 text-sm text-ctp-overlay0">
|
<p className="mt-1 flex items-center gap-2 text-sm text-ctp-overlay0">
|
||||||
Signed in as {user.username}.
|
<span>Signed in as {user.username}.</span>
|
||||||
|
<span className="rounded-full border border-ctp-surface1 px-2 py-0.5 font-mono text-[11px] capitalize text-ctp-subtext0">
|
||||||
|
{user.role}
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
|
||||||
|
import { EmptyState } from "@/components/ui";
|
||||||
|
import { LibraryDetail } from "@/features/library/LibraryDetail";
|
||||||
|
import { OnlineDetail } from "@/features/online/OnlineDetail";
|
||||||
|
|
||||||
|
const J_ID_RE = /^J-\d+$/i;
|
||||||
|
|
||||||
|
export default function DetailPage() {
|
||||||
|
const { itemId = "" } = useParams();
|
||||||
|
|
||||||
|
if (J_ID_RE.test(itemId)) {
|
||||||
|
const normalized = `J-${itemId.slice(2)}`;
|
||||||
|
return <LibraryDetail jId={normalized} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\d+$/.test(itemId)) {
|
||||||
|
return <OnlineDetail postId={Number(itemId)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title="Unknown item"
|
||||||
|
description={
|
||||||
|
<span className="font-mono text-xs">
|
||||||
|
Use /detail/J-<id> for library items or /detail/<id> for
|
||||||
|
e621 posts.
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export function DetailShell({
|
||||||
|
breadcrumb,
|
||||||
|
actions,
|
||||||
|
media,
|
||||||
|
aside,
|
||||||
|
footer,
|
||||||
|
}: {
|
||||||
|
breadcrumb: ReactNode;
|
||||||
|
actions?: ReactNode;
|
||||||
|
media: ReactNode;
|
||||||
|
aside: ReactNode;
|
||||||
|
footer?: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{breadcrumb}
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[320px_minmax(0,1fr)]">
|
||||||
|
<aside className="order-2 flex flex-col gap-4 lg:order-1">{aside}</aside>
|
||||||
|
<div className="order-1 flex min-w-0 flex-col gap-4 lg:order-2">
|
||||||
|
{actions}
|
||||||
|
{media}
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+92
-38
@@ -1,15 +1,17 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { ArrowLeft, Download } from "lucide-react";
|
import { ArrowLeft, Download } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { Button, EmptyState, Spinner, inputClass } from "@/components/ui";
|
import { Button, EmptyState, Spinner, inputClass, linkButtonClass } from "@/components/ui";
|
||||||
|
import { DetailShell } from "@/features/detail/DetailShell";
|
||||||
import { api, errorMessage } from "@/lib/api";
|
import { api, errorMessage } from "@/lib/api";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { isTypingTarget } from "@/lib/dom";
|
import { isTypingTarget } from "@/lib/dom";
|
||||||
import { formatBytes, formatDate } from "@/lib/format";
|
import { formatBytes, formatDate } from "@/lib/format";
|
||||||
import { useUi } from "@/store/ui";
|
|
||||||
import type { FileNeighbors, MediaItem, Rating } from "@/lib/types";
|
import type { FileNeighbors, MediaItem, Rating } from "@/lib/types";
|
||||||
|
import { useAuth } from "@/store/auth";
|
||||||
|
import { useUi } from "@/store/ui";
|
||||||
|
|
||||||
const ratingChoices: { value: Rating; label: string; active: string }[] = [
|
const ratingChoices: { value: Rating; label: string; active: string }[] = [
|
||||||
{
|
{
|
||||||
@@ -34,7 +36,13 @@ const ratingChoices: { value: Rating; label: string; active: string }[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function CustomDataCard({ item }: { item: MediaItem }) {
|
const ratingLabel: Record<string, string> = {
|
||||||
|
s: "Safe",
|
||||||
|
q: "Questionable",
|
||||||
|
e: "Explicit",
|
||||||
|
};
|
||||||
|
|
||||||
|
function CustomDataCard({ item, canEdit }: { item: MediaItem; canEdit: boolean }) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [rating, setRating] = useState<Rating>(item.rating);
|
const [rating, setRating] = useState<Rating>(item.rating);
|
||||||
const [tags, setTags] = useState(item.tags.join(", "));
|
const [tags, setTags] = useState(item.tags.join(", "));
|
||||||
@@ -42,16 +50,49 @@ function CustomDataCard({ item }: { item: MediaItem }) {
|
|||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (payload: { rating: Rating; tags: string[]; notes: string }) =>
|
mutationFn: (payload: { rating: Rating; tags: string[]; notes: string }) =>
|
||||||
api<MediaItem>(`/api/files/${item.md5}/`, {
|
api<MediaItem>(`/api/files/${item.j_id}/`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
json: payload,
|
json: payload,
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ["file", item.md5] });
|
void queryClient.invalidateQueries({ queryKey: ["file", item.j_id] });
|
||||||
void queryClient.invalidateQueries({ queryKey: ["files"] });
|
void queryClient.invalidateQueries({ queryKey: ["files"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!canEdit) {
|
||||||
|
return (
|
||||||
|
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
|
||||||
|
<h2 className="text-sm font-semibold text-ctp-subtext1">Custom data</h2>
|
||||||
|
<dl className="mt-3 grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-xs">
|
||||||
|
<dt className="text-ctp-overlay0">Rating</dt>
|
||||||
|
<dd className="text-ctp-subtext0">
|
||||||
|
{item.rating ? ratingLabel[item.rating] : "None"}
|
||||||
|
</dd>
|
||||||
|
<dt className="text-ctp-overlay0">Tags</dt>
|
||||||
|
<dd className="flex flex-wrap gap-1">
|
||||||
|
{item.tags.length > 0 ? (
|
||||||
|
item.tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="rounded border border-ctp-surface1 px-1.5 py-0.5 font-mono text-[11px] text-ctp-subtext0"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-ctp-subtext0">—</span>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
<dt className="text-ctp-overlay0">Notes</dt>
|
||||||
|
<dd className="whitespace-pre-line text-ctp-subtext0">
|
||||||
|
{item.notes || "—"}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function save() {
|
function save() {
|
||||||
mutation.mutate({
|
mutation.mutate({
|
||||||
rating,
|
rating,
|
||||||
@@ -114,25 +155,23 @@ function CustomDataCard({ item }: { item: MediaItem }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FileDetailPage() {
|
export function LibraryDetail({ jId }: { jId: string }) {
|
||||||
const { md5 = "" } = useParams();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const ordering = useUi((state) => state.ordering);
|
const ordering = useUi((state) => state.ordering);
|
||||||
const rawUrl = `/api/files/${md5}/raw/`;
|
const user = useAuth((state) => state.user);
|
||||||
|
const rawUrl = `/api/files/${jId}/raw/`;
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ["file", md5],
|
queryKey: ["file", jId],
|
||||||
queryFn: () => api<MediaItem>(`/api/files/${md5}/`),
|
queryFn: () => api<MediaItem>(`/api/files/${jId}/`),
|
||||||
enabled: md5.length > 0,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const neighborsQuery = useQuery({
|
const neighborsQuery = useQuery({
|
||||||
queryKey: ["neighbors", md5, ordering],
|
queryKey: ["neighbors", jId, ordering],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api<FileNeighbors>(
|
api<FileNeighbors>(
|
||||||
`/api/files/${md5}/neighbors/?ordering=${encodeURIComponent(ordering)}`,
|
`/api/files/${jId}/neighbors/?ordering=${encodeURIComponent(ordering)}`,
|
||||||
),
|
),
|
||||||
enabled: md5.length > 0,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -146,9 +185,9 @@ export default function FileDetailPage() {
|
|||||||
}
|
}
|
||||||
const neighbors = neighborsQuery.data;
|
const neighbors = neighborsQuery.data;
|
||||||
if (event.key === "[" && neighbors?.previous) {
|
if (event.key === "[" && neighbors?.previous) {
|
||||||
navigate(`/view/${neighbors.previous.md5}`);
|
navigate(`/detail/${neighbors.previous.j_id}`);
|
||||||
} else if (event.key === "]" && neighbors?.next) {
|
} else if (event.key === "]" && neighbors?.next) {
|
||||||
navigate(`/view/${neighbors.next.md5}`);
|
navigate(`/detail/${neighbors.next.j_id}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
@@ -177,9 +216,16 @@ export default function FileDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const item = query.data;
|
const item = query.data;
|
||||||
|
const canEdit = Boolean(
|
||||||
|
user &&
|
||||||
|
(user.id === item.uploaded_by_id ||
|
||||||
|
user.is_staff ||
|
||||||
|
user.role === "staff"),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<DetailShell
|
||||||
|
breadcrumb={
|
||||||
<div className="flex items-center gap-3 text-sm">
|
<div className="flex items-center gap-3 text-sm">
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
to="/"
|
||||||
@@ -188,16 +234,21 @@ export default function FileDetailPage() {
|
|||||||
<ArrowLeft className="h-4 w-4" /> Library
|
<ArrowLeft className="h-4 w-4" /> Library
|
||||||
</Link>
|
</Link>
|
||||||
<span className="text-ctp-surface1">/</span>
|
<span className="text-ctp-surface1">/</span>
|
||||||
<span className="truncate font-mono text-xs text-ctp-overlay0">
|
<span className="font-mono text-xs text-ctp-overlay0">{item.j_id}</span>
|
||||||
{item.filename}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_360px]">
|
actions={
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<a href={`${rawUrl}?download=1`} className={linkButtonClass}>
|
||||||
|
<Download className="h-4 w-4" /> Download
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
media={
|
||||||
<div className="flex min-h-[320px] items-center justify-center overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-mantle">
|
<div className="flex min-h-[320px] items-center justify-center overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-mantle">
|
||||||
{item.kind === "video" ? (
|
{item.kind === "video" ? (
|
||||||
<video
|
<video
|
||||||
key={item.md5}
|
key={item.j_id}
|
||||||
src={rawUrl}
|
src={rawUrl}
|
||||||
controls
|
controls
|
||||||
autoPlay
|
autoPlay
|
||||||
@@ -207,15 +258,16 @@ export default function FileDetailPage() {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<img
|
<img
|
||||||
key={item.md5}
|
key={item.j_id}
|
||||||
src={rawUrl}
|
src={rawUrl}
|
||||||
alt={item.filename}
|
alt={item.filename}
|
||||||
className="max-h-[75vh] w-full object-contain"
|
className="max-h-[75vh] w-full object-contain"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
<div className="flex flex-col gap-4">
|
aside={
|
||||||
|
<>
|
||||||
<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">
|
||||||
<h1
|
<h1
|
||||||
className="truncate font-mono text-sm text-ctp-subtext1"
|
className="truncate font-mono text-sm text-ctp-subtext1"
|
||||||
@@ -224,6 +276,8 @@ export default function FileDetailPage() {
|
|||||||
{item.filename}
|
{item.filename}
|
||||||
</h1>
|
</h1>
|
||||||
<dl className="mt-3 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-xs">
|
<dl className="mt-3 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-xs">
|
||||||
|
<dt className="text-ctp-overlay0">ID</dt>
|
||||||
|
<dd className="font-mono text-ctp-subtext0">{item.j_id}</dd>
|
||||||
<dt className="text-ctp-overlay0">MD5</dt>
|
<dt className="text-ctp-overlay0">MD5</dt>
|
||||||
<dd className="truncate font-mono text-ctp-subtext0" title={item.md5}>
|
<dd className="truncate font-mono text-ctp-subtext0" title={item.md5}>
|
||||||
{item.md5}
|
{item.md5}
|
||||||
@@ -238,20 +292,14 @@ export default function FileDetailPage() {
|
|||||||
</dd>
|
</dd>
|
||||||
<dt className="text-ctp-overlay0">Added</dt>
|
<dt className="text-ctp-overlay0">Added</dt>
|
||||||
<dd className="text-ctp-subtext0">{formatDate(item.created_at)}</dd>
|
<dd className="text-ctp-subtext0">{formatDate(item.created_at)}</dd>
|
||||||
|
<dt className="text-ctp-overlay0">Uploaded by</dt>
|
||||||
|
<dd className="text-ctp-subtext0">{item.uploaded_by ?? "—"}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
<a
|
|
||||||
href={`${rawUrl}?download=1`}
|
|
||||||
className="mt-4 inline-flex items-center justify-center gap-2 rounded-md border border-ctp-surface1 bg-ctp-surface0 px-3 py-1.5 text-sm font-medium text-ctp-text transition hover:bg-ctp-surface1"
|
|
||||||
>
|
|
||||||
<Download className="h-4 w-4" /> Download
|
|
||||||
</a>
|
|
||||||
<p className="mt-3 font-mono text-[11px] text-ctp-overlay0">
|
<p className="mt-3 font-mono text-[11px] text-ctp-overlay0">
|
||||||
[ ] navigate · D download
|
[ ] navigate · D download
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<CustomDataCard key={item.updated_at} item={item} />
|
|
||||||
|
|
||||||
<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">
|
||||||
<h2 className="text-sm font-semibold text-ctp-subtext1">
|
<h2 className="text-sm font-semibold text-ctp-subtext1">
|
||||||
Locations ({item.locations.length})
|
Locations ({item.locations.length})
|
||||||
@@ -268,8 +316,14 @@ export default function FileDetailPage() {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
|
||||||
</div>
|
<CustomDataCard
|
||||||
</div>
|
key={`${item.j_id}-${item.updated_at}`}
|
||||||
|
item={item}
|
||||||
|
canEdit={canEdit}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,7 @@ import { Button, EmptyState, Spinner, inputClass } from "@/components/ui";
|
|||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { isTypingTarget } from "@/lib/dom";
|
import { isTypingTarget } from "@/lib/dom";
|
||||||
|
import { useAuth } from "@/store/auth";
|
||||||
import { useUi } from "@/store/ui";
|
import { useUi } from "@/store/ui";
|
||||||
import type { MediaItem, Paginated } from "@/lib/types";
|
import type { MediaItem, Paginated } from "@/lib/types";
|
||||||
|
|
||||||
@@ -51,6 +52,7 @@ export default function LibraryPage() {
|
|||||||
const setPerPage = useUi((state) => state.setPerPage);
|
const setPerPage = useUi((state) => state.setPerPage);
|
||||||
const ordering = useUi((state) => state.ordering);
|
const ordering = useUi((state) => state.ordering);
|
||||||
const setOrdering = useUi((state) => state.setOrdering);
|
const setOrdering = useUi((state) => state.setOrdering);
|
||||||
|
const user = useAuth((state) => state.user);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleKeyDown(event: KeyboardEvent) {
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
@@ -214,6 +216,12 @@ export default function LibraryPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!user ? (
|
||||||
|
<p className="mb-4 rounded-md border border-ctp-surface1 bg-ctp-base px-3 py-2 text-xs text-ctp-overlay0">
|
||||||
|
Browsing as a guest — some items may be hidden.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{query.isPending ? (
|
{query.isPending ? (
|
||||||
<div className="flex justify-center py-24">
|
<div className="flex justify-center py-24">
|
||||||
<Spinner className="h-6 w-6" />
|
<Spinner className="h-6 w-6" />
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Navigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
|
import { EmptyState, Spinner } from "@/components/ui";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { MediaItem } from "@/lib/types";
|
||||||
|
|
||||||
|
const MD5_RE = /^[0-9a-fA-F]{32}$/;
|
||||||
|
|
||||||
|
export default function Md5Redirect() {
|
||||||
|
const { md5 = "" } = useParams();
|
||||||
|
const valid = MD5_RE.test(md5);
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ["resolve-md5", md5.toLowerCase()],
|
||||||
|
queryFn: () => api<MediaItem>(`/api/files/${md5.toLowerCase()}/`),
|
||||||
|
enabled: valid,
|
||||||
|
retry: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (valid && query.isPending) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center py-24">
|
||||||
|
<Spinner className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.isError || !query.data) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title="File not found"
|
||||||
|
description="This link may be outdated, or the file is not available."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Navigate to={`/detail/${query.data.j_id}`} replace />;
|
||||||
|
}
|
||||||
+137
-89
@@ -8,10 +8,11 @@ import {
|
|||||||
HeartOff,
|
HeartOff,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
|
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { Button, EmptyState, Spinner } from "@/components/ui";
|
import { Button, EmptyState, Spinner, linkButtonClass } from "@/components/ui";
|
||||||
import { TagChip } from "@/components/TagChip";
|
import { TagChip } from "@/components/TagChip";
|
||||||
|
import { DetailShell } from "@/features/detail/DetailShell";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { renderDText } from "@/lib/dtext";
|
import { renderDText } from "@/lib/dtext";
|
||||||
@@ -26,6 +27,8 @@ import {
|
|||||||
TAG_CATEGORY_ORDER,
|
TAG_CATEGORY_ORDER,
|
||||||
} from "@/lib/e621";
|
} from "@/lib/e621";
|
||||||
import { formatBytes, formatDate } from "@/lib/format";
|
import { formatBytes, formatDate } from "@/lib/format";
|
||||||
|
import type { MediaItem } from "@/lib/types";
|
||||||
|
import { useAuth } from "@/store/auth";
|
||||||
import { useE621 } from "@/store/e621";
|
import { useE621 } from "@/store/e621";
|
||||||
|
|
||||||
const ratingText: Record<string, string> = {
|
const ratingText: Record<string, string> = {
|
||||||
@@ -40,17 +43,18 @@ const relatedBorder: Record<string, string> = {
|
|||||||
e: "border-ctp-red/25 hover:border-ctp-red/60",
|
e: "border-ctp-red/25 hover:border-ctp-red/60",
|
||||||
};
|
};
|
||||||
|
|
||||||
const linkButtonClass =
|
interface LibraryLookup {
|
||||||
"inline-flex items-center justify-center gap-2 rounded-md border border-ctp-surface1 bg-ctp-surface0 px-3 py-1.5 text-sm font-medium text-ctp-text transition hover:bg-ctp-surface1";
|
found: string[];
|
||||||
|
j_ids: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
export default function OnlineDetailPage() {
|
export function OnlineDetail({ postId }: { postId: number }) {
|
||||||
const { id = "" } = useParams();
|
|
||||||
const postId = Number(id);
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const from = (location.state as { from?: string } | null)?.from ?? "/online";
|
const from = (location.state as { from?: string } | null)?.from ?? "/online";
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const credentials = useE621((state) => state.credentials);
|
const credentials = useE621((state) => state.credentials);
|
||||||
|
const user = useAuth((state) => state.user);
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ["e621", "post", postId, credentials?.username ?? ""],
|
queryKey: ["e621", "post", postId, credentials?.username ?? ""],
|
||||||
@@ -91,14 +95,16 @@ export default function OnlineDetailPage() {
|
|||||||
const lookupQuery = useQuery({
|
const lookupQuery = useQuery({
|
||||||
queryKey: ["library-lookup", post?.file.md5 ?? ""],
|
queryKey: ["library-lookup", post?.file.md5 ?? ""],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api<{ found: string[] }>("/api/files/lookup/", {
|
api<LibraryLookup>("/api/files/lookup/", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
json: { md5s: [post?.file.md5] },
|
json: { md5s: [post?.file.md5] },
|
||||||
}),
|
}),
|
||||||
enabled: Boolean(post?.file.md5),
|
enabled: Boolean(post?.file.md5),
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
});
|
});
|
||||||
const inLibrary = (lookupQuery.data?.found.length ?? 0) > 0;
|
const libraryJId = post
|
||||||
|
? lookupQuery.data?.j_ids?.[post.file.md5]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const favoriteMutation = useMutation({
|
const favoriteMutation = useMutation({
|
||||||
mutationFn: (favorite: boolean) =>
|
mutationFn: (favorite: boolean) =>
|
||||||
@@ -110,6 +116,23 @@ export default function OnlineDetailPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const downloadMutation = useMutation({
|
||||||
|
mutationFn: (target: NonNullable<typeof post>) =>
|
||||||
|
api<MediaItem>("/api/online/downloads/", {
|
||||||
|
method: "POST",
|
||||||
|
json: {
|
||||||
|
post_id: target.id,
|
||||||
|
url: target.file.url,
|
||||||
|
filename: `${target.file.md5}.${target.file.ext}`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
onSuccess: (item) => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["library-lookup"] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["files"] });
|
||||||
|
navigate(`/detail/${item.j_id}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (query.isPending) {
|
if (query.isPending) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center py-24">
|
<div className="flex justify-center py-24">
|
||||||
@@ -138,9 +161,11 @@ export default function OnlineDetailPage() {
|
|||||||
const isVideo = post.file.ext === "mp4" || post.file.ext === "webm";
|
const isVideo = post.file.ext === "mp4" || post.file.ext === "webm";
|
||||||
const imageSrc =
|
const imageSrc =
|
||||||
post.sample.has && post.sample.url ? post.sample.url : post.file.url;
|
post.sample.has && post.sample.url ? post.sample.url : post.file.url;
|
||||||
|
const canUpload = Boolean(user?.can_upload);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<DetailShell
|
||||||
|
breadcrumb={
|
||||||
<div className="flex items-center gap-3 text-sm">
|
<div className="flex items-center gap-3 text-sm">
|
||||||
<Link
|
<Link
|
||||||
to={from}
|
to={from}
|
||||||
@@ -153,9 +178,102 @@ export default function OnlineDetailPage() {
|
|||||||
Post #{post.id}
|
Post #{post.id}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant={post.is_favorited ? "secondary" : "primary"}
|
||||||
|
disabled={!credentials?.configured || favoriteMutation.isPending}
|
||||||
|
onClick={() => favoriteMutation.mutate(!post.is_favorited)}
|
||||||
|
title={
|
||||||
|
credentials?.configured
|
||||||
|
? undefined
|
||||||
|
: "Add e621 credentials in Account to favorite posts"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{post.is_favorited ? (
|
||||||
|
<HeartOff className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Heart className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{favoriteMutation.isPending
|
||||||
|
? "Saving…"
|
||||||
|
: post.is_favorited
|
||||||
|
? "Unfavorite"
|
||||||
|
: "Favorite"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-[320px_minmax(0,1fr)]">
|
{libraryJId ? (
|
||||||
<aside className="order-2 flex flex-col gap-4 lg:order-1">
|
<Link to={`/detail/${libraryJId}`} className={linkButtonClass}>
|
||||||
|
<CheckCircle2 className="h-4 w-4" /> In library ({libraryJId})
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
disabled={
|
||||||
|
!canUpload || downloadMutation.isPending || !post.file.url
|
||||||
|
}
|
||||||
|
onClick={() => downloadMutation.mutate(post)}
|
||||||
|
title={
|
||||||
|
canUpload
|
||||||
|
? undefined
|
||||||
|
: "Your account is not allowed to add to the library"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
{downloadMutation.isPending
|
||||||
|
? "Downloading…"
|
||||||
|
: "Download to Library"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={`https://e621.net/posts/${post.id}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className={linkButtonClass}
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4" /> View on e621
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{favoriteMutation.isError ? (
|
||||||
|
<p className="rounded-md border border-ctp-red/30 bg-ctp-red/10 px-3 py-2 text-sm text-ctp-red">
|
||||||
|
{e621ErrorMessage(favoriteMutation.error)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{downloadMutation.isError ? (
|
||||||
|
<p className="rounded-md border border-ctp-red/30 bg-ctp-red/10 px-3 py-2 text-sm text-ctp-red">
|
||||||
|
{e621ErrorMessage(downloadMutation.error)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
media={
|
||||||
|
<div className="flex min-h-[320px] items-center justify-center overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-mantle">
|
||||||
|
{isVideo ? (
|
||||||
|
<video
|
||||||
|
key={post.id}
|
||||||
|
src={post.file.url ?? undefined}
|
||||||
|
controls
|
||||||
|
autoPlay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
className="max-h-[75vh] w-full object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
key={post.id}
|
||||||
|
src={imageSrc ?? undefined}
|
||||||
|
alt={`Post #${post.id}`}
|
||||||
|
className="max-h-[75vh] w-full object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
aside={
|
||||||
|
<>
|
||||||
<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">
|
||||||
<h2 className="text-sm font-semibold text-ctp-subtext1">Tags</h2>
|
<h2 className="text-sm font-semibold text-ctp-subtext1">Tags</h2>
|
||||||
<div className="mt-3 flex flex-col gap-3">
|
<div className="mt-3 flex flex-col gap-3">
|
||||||
@@ -226,81 +344,11 @@ export default function OnlineDetailPage() {
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</dl>
|
</dl>
|
||||||
{inLibrary ? (
|
|
||||||
<p className="mt-3 flex items-center gap-1.5 text-xs text-ctp-green">
|
|
||||||
<CheckCircle2 className="h-3.5 w-3.5" /> In your library
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
</>
|
||||||
|
|
||||||
<div className="order-1 flex min-w-0 flex-col gap-4 lg:order-2">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant={post.is_favorited ? "secondary" : "primary"}
|
|
||||||
disabled={!credentials?.configured || favoriteMutation.isPending}
|
|
||||||
onClick={() => favoriteMutation.mutate(!post.is_favorited)}
|
|
||||||
title={
|
|
||||||
credentials?.configured
|
|
||||||
? undefined
|
|
||||||
: "Add e621 credentials in Account to favorite posts"
|
|
||||||
}
|
}
|
||||||
>
|
footer={
|
||||||
{post.is_favorited ? (
|
<>
|
||||||
<HeartOff className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<Heart className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
{favoriteMutation.isPending
|
|
||||||
? "Saving…"
|
|
||||||
: post.is_favorited
|
|
||||||
? "Unfavorite"
|
|
||||||
: "Favorite"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
disabled
|
|
||||||
title="Coming with the Download to Library update"
|
|
||||||
>
|
|
||||||
<Download className="h-4 w-4" /> Download to Library
|
|
||||||
</Button>
|
|
||||||
<a
|
|
||||||
href={`https://e621.net/posts/${post.id}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className={linkButtonClass}
|
|
||||||
>
|
|
||||||
<ExternalLink className="h-4 w-4" /> View on e621
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{favoriteMutation.isError ? (
|
|
||||||
<p className="rounded-md border border-ctp-red/30 bg-ctp-red/10 px-3 py-2 text-sm text-ctp-red">
|
|
||||||
{e621ErrorMessage(favoriteMutation.error)}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="flex min-h-[320px] items-center justify-center overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-mantle">
|
|
||||||
{isVideo ? (
|
|
||||||
<video
|
|
||||||
key={post.id}
|
|
||||||
src={post.file.url ?? undefined}
|
|
||||||
controls
|
|
||||||
autoPlay
|
|
||||||
loop
|
|
||||||
muted
|
|
||||||
className="max-h-[75vh] w-full object-contain"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<img
|
|
||||||
key={post.id}
|
|
||||||
src={imageSrc ?? undefined}
|
|
||||||
alt={`Post #${post.id}`}
|
|
||||||
className="max-h-[75vh] w-full object-contain"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{post.description.trim() ? (
|
{post.description.trim() ? (
|
||||||
<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">
|
||||||
<details>
|
<details>
|
||||||
@@ -368,7 +416,7 @@ export default function OnlineDetailPage() {
|
|||||||
{(relatedQuery.data ?? []).map((related) => (
|
{(relatedQuery.data ?? []).map((related) => (
|
||||||
<Link
|
<Link
|
||||||
key={related.id}
|
key={related.id}
|
||||||
to={`/online/view/${related.id}`}
|
to={`/detail/${related.id}`}
|
||||||
state={{ from }}
|
state={{ from }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"overflow-hidden rounded-md border bg-ctp-mantle transition",
|
"overflow-hidden rounded-md border bg-ctp-mantle transition",
|
||||||
@@ -394,8 +442,8 @@ export default function OnlineDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</>
|
||||||
</div>
|
}
|
||||||
</div>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -28,7 +28,7 @@ export function PostCard({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={`/online/view/${post.id}`}
|
to={`/detail/${post.id}`}
|
||||||
state={from ? { from } : undefined}
|
state={from ? { from } : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group overflow-hidden rounded-lg border bg-ctp-base transition",
|
"group overflow-hidden rounded-lg border bg-ctp-base transition",
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function PostThumb({ postId }: { postId: number }) {
|
|||||||
return (
|
return (
|
||||||
<span className="my-1 inline-block align-top">
|
<span className="my-1 inline-block align-top">
|
||||||
<Link
|
<Link
|
||||||
to={`/online/view/${postId}`}
|
to={`/detail/${postId}`}
|
||||||
title={`Post #${postId}`}
|
title={`Post #${postId}`}
|
||||||
className="group inline-block"
|
className="group inline-block"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Button, EmptyState } from "@/components/ui";
|
|||||||
import { errorMessage, uploadFile } from "@/lib/api";
|
import { errorMessage, uploadFile } from "@/lib/api";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { formatBytes } from "@/lib/format";
|
import { formatBytes } from "@/lib/format";
|
||||||
|
import { useAuth } from "@/store/auth";
|
||||||
|
|
||||||
type UploadStatus = "queued" | "uploading" | "done" | "error";
|
type UploadStatus = "queued" | "uploading" | "done" | "error";
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ interface UploadEntry {
|
|||||||
|
|
||||||
export default function UploadPage() {
|
export default function UploadPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const user = useAuth((state) => state.user);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
const [entries, setEntries] = useState<UploadEntry[]>([]);
|
const [entries, setEntries] = useState<UploadEntry[]>([]);
|
||||||
@@ -81,6 +83,20 @@ export default function UploadPage() {
|
|||||||
setEntries((current) => current.filter((entry) => entry.status !== "done"));
|
setEntries((current) => current.filter((entry) => entry.status !== "done"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (user && !user.can_upload) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6">
|
||||||
|
<header>
|
||||||
|
<h1 className="text-lg font-semibold">Upload</h1>
|
||||||
|
</header>
|
||||||
|
<EmptyState
|
||||||
|
title="Uploads are restricted"
|
||||||
|
description="Your account doesn't have the uploader role yet. Ask an admin to grant it."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6">
|
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6">
|
||||||
<header>
|
<header>
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ export interface User {
|
|||||||
email: string;
|
email: string;
|
||||||
is_staff: boolean;
|
is_staff: boolean;
|
||||||
date_joined: string;
|
date_joined: string;
|
||||||
|
role: "user" | "uploader" | "staff";
|
||||||
|
can_upload: boolean;
|
||||||
|
e621_username: string;
|
||||||
|
e621_configured: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MediaLocation {
|
export interface MediaLocation {
|
||||||
@@ -16,6 +20,8 @@ export interface MediaLocation {
|
|||||||
export type Rating = "" | "s" | "q" | "e";
|
export type Rating = "" | "s" | "q" | "e";
|
||||||
|
|
||||||
export interface MediaItem {
|
export interface MediaItem {
|
||||||
|
id: number;
|
||||||
|
j_id: string;
|
||||||
md5: string;
|
md5: string;
|
||||||
size: number;
|
size: number;
|
||||||
rating: Rating;
|
rating: Rating;
|
||||||
@@ -24,6 +30,8 @@ export interface MediaItem {
|
|||||||
filename: string;
|
filename: string;
|
||||||
extension: string;
|
extension: string;
|
||||||
kind: "image" | "video";
|
kind: "image" | "video";
|
||||||
|
uploaded_by: string | null;
|
||||||
|
uploaded_by_id: number | null;
|
||||||
locations: MediaLocation[];
|
locations: MediaLocation[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
@@ -56,6 +64,7 @@ export interface SystemStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface FileNeighbor {
|
export interface FileNeighbor {
|
||||||
|
j_id: string;
|
||||||
md5: string;
|
md5: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user