From e5cc63b0cc0589b22834fda3cff23ee5134051e5 Mon Sep 17 00:00:00 2001 From: JakeBreath Date: Thu, 17 Sep 2026 10:16:45 -0500 Subject: [PATCH] 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- identity (serializer, admin, scan_files --user, first superuser as default owner) - API resolves J-, 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- renders the library item, bare numbers render the e621 post - Legacy /view/ and /online/view/ 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 --- backend/.env.example | 15 ++ backend/apps/accounts/admin.py | 16 +- .../accounts/migrations/0003_user_role.py | 18 ++ backend/apps/accounts/models.py | 16 +- backend/apps/accounts/serializers.py | 3 + backend/apps/library/admin.py | 9 +- backend/apps/library/guest_filter.py | 114 ++++++++ .../commands/refresh_guest_blacklist.py | 20 ++ .../library/management/commands/scan_files.py | 32 ++- ...idden_from_guests_mediaitem_uploaded_by.py | 26 ++ backend/apps/library/models.py | 18 +- backend/apps/library/permissions.py | 27 ++ backend/apps/library/serializers.py | 17 ++ backend/apps/library/services.py | 13 + backend/apps/library/urls.py | 7 +- backend/apps/library/views.py | 93 ++++++- backend/config/__init__.py | 13 + backend/config/settings.py | 37 ++- backend/requirements.txt | 3 + docker-compose.yml | 42 +++ frontend/src/App.tsx | 22 +- frontend/src/components/AppShell.tsx | 12 +- frontend/src/components/CommandPalette.tsx | 16 +- frontend/src/components/MediaCard.tsx | 12 +- frontend/src/components/ui.tsx | 3 + frontend/src/features/account/AccountPage.tsx | 7 +- frontend/src/features/detail/DetailPage.tsx | 32 +++ frontend/src/features/detail/DetailShell.tsx | 29 ++ .../{FileDetailPage.tsx => LibraryDetail.tsx} | 148 ++++++---- frontend/src/features/library/LibraryPage.tsx | 8 + frontend/src/features/library/Md5Redirect.tsx | 39 +++ ...{OnlineDetailPage.tsx => OnlineDetail.tsx} | 252 +++++++++++------- frontend/src/features/online/PostCard.tsx | 2 +- frontend/src/features/online/PostThumb.tsx | 2 +- frontend/src/features/upload/UploadPage.tsx | 16 ++ frontend/src/lib/types.ts | 9 + 36 files changed, 954 insertions(+), 194 deletions(-) create mode 100644 backend/apps/accounts/migrations/0003_user_role.py create mode 100644 backend/apps/library/guest_filter.py create mode 100644 backend/apps/library/management/commands/refresh_guest_blacklist.py create mode 100644 backend/apps/library/migrations/0002_mediaitem_hidden_from_guests_mediaitem_uploaded_by.py create mode 100644 backend/apps/library/permissions.py create mode 100644 docker-compose.yml create mode 100644 frontend/src/features/detail/DetailPage.tsx create mode 100644 frontend/src/features/detail/DetailShell.tsx rename frontend/src/features/library/{FileDetailPage.tsx => LibraryDetail.tsx} (67%) create mode 100644 frontend/src/features/library/Md5Redirect.tsx rename frontend/src/features/online/{OnlineDetailPage.tsx => OnlineDetail.tsx} (76%) diff --git a/backend/.env.example b/backend/.env.example index c1517a7..b1e0fc7 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -3,3 +3,18 @@ DEBUG=True ALLOWED_HOSTS=localhost,127.0.0.1 # Absolute path to your media folder, or relative to the backend/ folder. 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 diff --git a/backend/apps/accounts/admin.py b/backend/apps/accounts/admin.py index 54d281b..76efbf7 100644 --- a/backend/apps/accounts/admin.py +++ b/backend/apps/accounts/admin.py @@ -6,4 +6,18 @@ from .models import User @admin.register(User) 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", + ) + }, + ), + ) diff --git a/backend/apps/accounts/migrations/0003_user_role.py b/backend/apps/accounts/migrations/0003_user_role.py new file mode 100644 index 0000000..f523c52 --- /dev/null +++ b/backend/apps/accounts/migrations/0003_user_role.py @@ -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), + ), + ] diff --git a/backend/apps/accounts/models.py b/backend/apps/accounts/models.py index 7878011..3599de9 100644 --- a/backend/apps/accounts/models.py +++ b/backend/apps/accounts/models.py @@ -3,8 +3,18 @@ from django.db import models 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_api_key = models.CharField(max_length=100, blank=True, default="") e621_base_url = models.CharField(max_length=200, default="https://e621.net") @@ -12,3 +22,7 @@ class User(AbstractUser): @property def e621_configured(self): 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} diff --git a/backend/apps/accounts/serializers.py b/backend/apps/accounts/serializers.py index 5d4c6c1..05f381e 100644 --- a/backend/apps/accounts/serializers.py +++ b/backend/apps/accounts/serializers.py @@ -6,6 +6,7 @@ from .models import User class UserSerializer(serializers.ModelSerializer): e621_configured = serializers.BooleanField(read_only=True) + can_upload = serializers.BooleanField(read_only=True) class Meta: model = User @@ -15,6 +16,8 @@ class UserSerializer(serializers.ModelSerializer): "email", "is_staff", "date_joined", + "role", + "can_upload", "e621_username", "e621_configured", ] diff --git a/backend/apps/library/admin.py b/backend/apps/library/admin.py index 5bd5afd..3ece66e 100644 --- a/backend/apps/library/admin.py +++ b/backend/apps/library/admin.py @@ -11,11 +11,16 @@ class MediaLocationInline(admin.TabularInline): @admin.register(MediaItem) class MediaItemAdmin(admin.ModelAdmin): - list_display = ("md5", "size", "rating", "created_at") - list_filter = ("rating",) + list_display = ("__str__", "j_id", "size", "rating", "uploaded_by", "hidden_from_guests", "created_at") + list_filter = ("rating", "hidden_from_guests") search_fields = ("md5",) + readonly_fields = ("hidden_from_guests",) inlines = [MediaLocationInline] + @admin.display(description="J-ID") + def j_id(self, obj): + return f"J-{obj.id}" + @admin.register(MediaLocation) class MediaLocationAdmin(admin.ModelAdmin): diff --git a/backend/apps/library/guest_filter.py b/backend/apps/library/guest_filter.py new file mode 100644 index 0000000..7bdef1b --- /dev/null +++ b/backend/apps/library/guest_filter.py @@ -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, + } diff --git a/backend/apps/library/management/commands/refresh_guest_blacklist.py b/backend/apps/library/management/commands/refresh_guest_blacklist.py new file mode 100644 index 0000000..484ff7d --- /dev/null +++ b/backend/apps/library/management/commands/refresh_guest_blacklist.py @@ -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." + ) + ) diff --git a/backend/apps/library/management/commands/scan_files.py b/backend/apps/library/management/commands/scan_files.py index 4b2460c..58fe4ad 100644 --- a/backend/apps/library/management/commands/scan_files.py +++ b/backend/apps/library/management/commands/scan_files.py @@ -2,11 +2,14 @@ import os from pathlib import Path from django.conf import settings +from django.contrib.auth import get_user_model from django.core.management.base import BaseCommand from apps.library.models import MediaLocation from apps.library.services import ALLOWED_EXTENSIONS, index_file +User = get_user_model() + class Command(BaseCommand): help = "Scan the watched folder and index media files into the library." @@ -17,6 +20,10 @@ class Command(BaseCommand): action="store_true", 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): folder = Path(settings.WATCHED_FOLDER) @@ -26,6 +33,26 @@ class Command(BaseCommand): ) 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 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} ...") seen = set() new_items = 0 @@ -40,11 +67,14 @@ class Command(BaseCommand): continue path = Path(root) / name try: - _, created_item, created_location = index_file(path, folder) + item, created_item, created_location = index_file(path, folder) except OSError as exc: skipped += 1 self.stderr.write(f"Skipped {path}: {exc}") 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)) new_items += int(created_item) if created_location: diff --git a/backend/apps/library/migrations/0002_mediaitem_hidden_from_guests_mediaitem_uploaded_by.py b/backend/apps/library/migrations/0002_mediaitem_hidden_from_guests_mediaitem_uploaded_by.py new file mode 100644 index 0000000..37fa99b --- /dev/null +++ b/backend/apps/library/migrations/0002_mediaitem_hidden_from_guests_mediaitem_uploaded_by.py @@ -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), + ), + ] diff --git a/backend/apps/library/models.py b/backend/apps/library/models.py index f103d59..287469a 100644 --- a/backend/apps/library/models.py +++ b/backend/apps/library/models.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.db import models @@ -10,6 +11,15 @@ class MediaItem(models.Model): rating = models.CharField(max_length=1, blank=True, default="") tags = models.JSONField(default=list, blank=True) 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) updated_at = models.DateTimeField(auto_now=True) @@ -17,7 +27,13 @@ class MediaItem(models.Model): ordering = ["-created_at"] 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): diff --git a/backend/apps/library/permissions.py b/backend/apps/library/permissions.py new file mode 100644 index 0000000..bab538e --- /dev/null +++ b/backend/apps/library/permissions.py @@ -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 diff --git a/backend/apps/library/serializers.py b/backend/apps/library/serializers.py index 2727573..f9cff4d 100644 --- a/backend/apps/library/serializers.py +++ b/backend/apps/library/serializers.py @@ -18,13 +18,18 @@ class MediaItemSerializer(serializers.ModelSerializer): ) rating = serializers.ChoiceField(choices=["", "s", "q", "e"], required=False) locations = MediaLocationSerializer(many=True, read_only=True) + j_id = serializers.SerializerMethodField() filename = serializers.SerializerMethodField() extension = serializers.SerializerMethodField() kind = serializers.SerializerMethodField() + uploaded_by = serializers.SerializerMethodField() + uploaded_by_id = serializers.IntegerField(read_only=True) class Meta: model = MediaItem fields = [ + "id", + "j_id", "md5", "size", "rating", @@ -33,21 +38,33 @@ class MediaItemSerializer(serializers.ModelSerializer): "filename", "extension", "kind", + "uploaded_by", + "uploaded_by_id", "locations", "created_at", "updated_at", ] read_only_fields = [ + "id", + "j_id", "md5", "size", "filename", "extension", "kind", + "uploaded_by", + "uploaded_by_id", "locations", "created_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): locations = list(obj.locations.all()) return locations[0] if locations else None diff --git a/backend/apps/library/services.py b/backend/apps/library/services.py index 84ffd05..13c469f 100644 --- a/backend/apps/library/services.py +++ b/backend/apps/library/services.py @@ -160,6 +160,19 @@ def serve_file(request, path, download=False): 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): """Extract a JPEG thumbnail from a video, cached under MEDIA_ROOT/thumbs.""" if not shutil.which("ffmpeg"): diff --git a/backend/apps/library/urls.py b/backend/apps/library/urls.py index 7bcefa3..87102d8 100644 --- a/backend/apps/library/urls.py +++ b/backend/apps/library/urls.py @@ -1,7 +1,7 @@ from django.urls import include, path from rest_framework.routers import DefaultRouter -from .views import MediaItemViewSet, UploadView +from .views import DownloadToLibraryView, MediaItemViewSet, UploadView router = DefaultRouter() router.register("files", MediaItemViewSet, basename="file") @@ -9,4 +9,9 @@ router.register("files", MediaItemViewSet, basename="file") urlpatterns = [ path("", include(router.urls)), path("uploads/", UploadView.as_view(), name="upload"), + path( + "online/downloads/", + DownloadToLibraryView.as_view(), + name="download_to_library", + ), ] diff --git a/backend/apps/library/views.py b/backend/apps/library/views.py index be91b08..f2d9862 100644 --- a/backend/apps/library/views.py +++ b/backend/apps/library/views.py @@ -1,19 +1,25 @@ +import re from pathlib import Path +from urllib.parse import urlparse from django.conf import settings 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.decorators import action 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.views import APIView from . import services from .models import MediaItem +from .permissions import CanUpload, IsUploaderOrStaffOrReadOnly from .serializers import MediaItemSerializer LIST_ORDERINGS = {"name", "-name", "size", "-size", "created_at", "-created_at"} +MD5_RE = re.compile(r"[0-9a-fA-F]{32}") class MediaItemViewSet( @@ -23,13 +29,15 @@ class MediaItemViewSet( viewsets.GenericViewSet, ): serializer_class = MediaItemSerializer - lookup_field = "md5" + permission_classes = [IsAuthenticatedOrReadOnly, IsUploaderOrStaffOrReadOnly] http_method_names = ["get", "post", "patch", "head", "options"] def get_queryset(self): queryset = MediaItem.objects.prefetch_related("locations").annotate( 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() if search: queryset = queryset.filter(locations__rel_path__icontains=search) @@ -46,8 +54,22 @@ class MediaItemViewSet( ) return queryset.distinct() + def get_object(self): + """Resolve J-, 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"]) - def raw(self, request, md5=None): + def raw(self, request, pk=None): item = self.get_object() location = item.locations.first() if location is None: @@ -60,7 +82,7 @@ class MediaItemViewSet( ) @action(detail=True, methods=["get"]) - def thumbnail(self, request, md5=None): + def thumbnail(self, request, pk=None): item = self.get_object() location = item.locations.first() if location is None: @@ -81,7 +103,7 @@ class MediaItemViewSet( @action(detail=False, methods=["post"], permission_classes=[AllowAny]) 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") if not isinstance(md5s, list): return Response( @@ -89,11 +111,19 @@ class MediaItemViewSet( status=status.HTTP_400_BAD_REQUEST, ) wanted = {str(value).strip().lower()[:32] for value in md5s if value} - found = MediaItem.objects.filter(md5__in=wanted).values_list("md5", flat=True) - return Response({"found": sorted(found)}) + queryset = MediaItem.objects.filter(md5__in=wanted) + 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"]) - def neighbors(self, request, md5=None): + def neighbors(self, request, pk=None): """Previous/next items in the ordered list, for keyboard navigation.""" item = self.get_object() ordering = request.query_params.get("ordering", "-created_at").strip() @@ -120,6 +150,7 @@ class MediaItemViewSet( def brief(obj): location = obj.locations.first() return { + "j_id": f"J-{obj.id}", "md5": obj.md5, "filename": Path(location.rel_path).name if location else obj.md5, } @@ -135,7 +166,7 @@ class MediaItemViewSet( class UploadView(APIView): - permission_classes = [IsAuthenticated] + permission_classes = [CanUpload] parser_classes = [MultiPartParser, FormParser] def post(self, request): @@ -150,7 +181,7 @@ class UploadView(APIView): for chunk in upload.chunks(): output.write(chunk) try: - item, _, _ = services.index_file(destination, folder) + item, created_item, _ = services.index_file(destination, folder) except Exception as exc: # noqa: BLE001 - report indexing failures destination.unlink(missing_ok=True) return Response( @@ -159,6 +190,9 @@ class UploadView(APIView): ) 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 "" if rating in {"s", "q", "e"} and item.rating != rating: item.rating = rating @@ -178,3 +212,42 @@ class UploadView(APIView): MediaItemSerializer(item, context={"request": request}).data, 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, + ) diff --git a/backend/config/__init__.py b/backend/config/__init__.py index e69de29..b4cefba 100644 --- a/backend/config/__init__.py +++ b/backend/config/__init__.py @@ -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() diff --git a/backend/config/settings.py b/backend/config/settings.py index 2a4cf2b..70e9595 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -85,12 +85,17 @@ TEMPLATES = [ WSGI_APPLICATION = "config.wsgi.application" -# Database +# Database (MariaDB, run via docker compose at the repo root) DATABASES = { "default": { - "ENGINE": "django.db.backends.sqlite3", - "NAME": BASE_DIR / "db.sqlite3", + "ENGINE": "django.db.backends.mysql", + "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 = 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 REST_FRAMEWORK = { diff --git a/backend/requirements.txt b/backend/requirements.txt index cc49060..dc6ddaf 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,3 +3,6 @@ djangorestframework>=3.17 django-filter>=25.1 Pillow>=11.0 python-dotenv>=1.0 +requests>=2.32 +PyMySQL>=1.1 +redis>=5.0 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..457a590 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 962bbc8..0546564 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,14 +1,20 @@ 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 { FullPageSpinner } from "@/components/ui"; import AccountPage from "@/features/account/AccountPage"; import LoginPage from "@/features/auth/LoginPage"; 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 OnlineDetailPage from "@/features/online/OnlineDetailPage"; +import Md5Redirect from "@/features/library/Md5Redirect"; import OnlinePage from "@/features/online/OnlinePage"; import UploadPage from "@/features/upload/UploadPage"; import { useAuth } from "@/store/auth"; @@ -22,6 +28,11 @@ function RequireAuth({ children }: { children: ReactNode }) { return <>{children}; } +function PostRedirect() { + const { postId = "" } = useParams(); + return ; +} + export default function App() { const ready = useAuth((state) => state.ready); const loadUser = useAuth((state) => state.loadUser); @@ -38,9 +49,10 @@ export default function App() { }> } /> - } /> + } /> + } /> } /> - } /> + } /> state.user); const logout = useAuth((state) => state.logout); @@ -63,6 +57,12 @@ export function AppShell() { const showSidebarToggle = location.pathname === "/" || location.pathname === "/online"; + const navItems = [ + { to: "/", label: "Library" }, + { to: "/online", label: "Online" }, + ...(user?.can_upload ? [{ to: "/upload", label: "Upload" }] : []), + ]; + const navigationType = useNavigationType(); useEffect(() => { // New navigations start at the top; history back/forward keeps position. diff --git a/frontend/src/components/CommandPalette.tsx b/frontend/src/components/CommandPalette.tsx index a65eba7..f4b8683 100644 --- a/frontend/src/components/CommandPalette.tsx +++ b/frontend/src/components/CommandPalette.tsx @@ -51,12 +51,16 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) { icon: FolderOpen, run: () => navigate("/"), }, - { - id: "upload", - label: "Go to Upload", - icon: Upload, - run: () => navigate("/upload"), - }, + ...(user?.can_upload + ? [ + { + id: "upload", + label: "Go to Upload", + icon: Upload, + run: () => navigate("/upload"), + }, + ] + : []), { id: "online", label: "Go to Online browser", diff --git a/frontend/src/components/MediaCard.tsx b/frontend/src/components/MediaCard.tsx index 4a90416..dd07514 100644 --- a/frontend/src/components/MediaCard.tsx +++ b/frontend/src/components/MediaCard.tsx @@ -19,12 +19,12 @@ const ratingLabels: Record = { export function MediaCard({ item }: { item: MediaItem }) { const preview = item.kind === "video" - ? `/api/files/${item.md5}/thumbnail/` - : `/api/files/${item.md5}/raw/`; + ? `/api/files/${item.j_id}/thumbnail/` + : `/api/files/${item.j_id}/raw/`; return (
@@ -54,7 +54,11 @@ export function MediaCard({ item }: { item: MediaItem }) {
-

+

{item.j_id}

+

{item.filename}

diff --git a/frontend/src/components/ui.tsx b/frontend/src/components/ui.tsx index c415815..dd15171 100644 --- a/frontend/src/components/ui.tsx +++ b/frontend/src/components/ui.tsx @@ -72,6 +72,9 @@ export function Field({ 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"; +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({ title, description, diff --git a/frontend/src/features/account/AccountPage.tsx b/frontend/src/features/account/AccountPage.tsx index 1fac397..2b22057 100644 --- a/frontend/src/features/account/AccountPage.tsx +++ b/frontend/src/features/account/AccountPage.tsx @@ -230,8 +230,11 @@ export default function AccountPage() {

Account

{user ? ( -

- Signed in as {user.username}. +

+ Signed in as {user.username}. + + {user.role} +

) : null}
diff --git a/frontend/src/features/detail/DetailPage.tsx b/frontend/src/features/detail/DetailPage.tsx new file mode 100644 index 0000000..882dbc7 --- /dev/null +++ b/frontend/src/features/detail/DetailPage.tsx @@ -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 ; + } + + if (/^\d+$/.test(itemId)) { + return ; + } + + return ( + + Use /detail/J-<id> for library items or /detail/<id> for + e621 posts. + + } + /> + ); +} diff --git a/frontend/src/features/detail/DetailShell.tsx b/frontend/src/features/detail/DetailShell.tsx new file mode 100644 index 0000000..0b82d64 --- /dev/null +++ b/frontend/src/features/detail/DetailShell.tsx @@ -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 ( +
+ {breadcrumb} +
+ +
+ {actions} + {media} + {footer} +
+
+
+ ); +} diff --git a/frontend/src/features/library/FileDetailPage.tsx b/frontend/src/features/library/LibraryDetail.tsx similarity index 67% rename from frontend/src/features/library/FileDetailPage.tsx rename to frontend/src/features/library/LibraryDetail.tsx index cc37125..99c217c 100644 --- a/frontend/src/features/library/FileDetailPage.tsx +++ b/frontend/src/features/library/LibraryDetail.tsx @@ -1,15 +1,17 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowLeft, Download } from "lucide-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 { cn } from "@/lib/cn"; import { isTypingTarget } from "@/lib/dom"; import { formatBytes, formatDate } from "@/lib/format"; -import { useUi } from "@/store/ui"; 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 }[] = [ { @@ -34,7 +36,13 @@ const ratingChoices: { value: Rating; label: string; active: string }[] = [ }, ]; -function CustomDataCard({ item }: { item: MediaItem }) { +const ratingLabel: Record = { + s: "Safe", + q: "Questionable", + e: "Explicit", +}; + +function CustomDataCard({ item, canEdit }: { item: MediaItem; canEdit: boolean }) { const queryClient = useQueryClient(); const [rating, setRating] = useState(item.rating); const [tags, setTags] = useState(item.tags.join(", ")); @@ -42,16 +50,49 @@ function CustomDataCard({ item }: { item: MediaItem }) { const mutation = useMutation({ mutationFn: (payload: { rating: Rating; tags: string[]; notes: string }) => - api(`/api/files/${item.md5}/`, { + api(`/api/files/${item.j_id}/`, { method: "PATCH", json: payload, }), onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ["file", item.md5] }); + void queryClient.invalidateQueries({ queryKey: ["file", item.j_id] }); void queryClient.invalidateQueries({ queryKey: ["files"] }); }, }); + if (!canEdit) { + return ( +
+

Custom data

+
+
Rating
+
+ {item.rating ? ratingLabel[item.rating] : "None"} +
+
Tags
+
+ {item.tags.length > 0 ? ( + item.tags.map((tag) => ( + + {tag} + + )) + ) : ( + — + )} +
+
Notes
+
+ {item.notes || "—"} +
+
+
+ ); + } + function save() { mutation.mutate({ rating, @@ -114,25 +155,23 @@ function CustomDataCard({ item }: { item: MediaItem }) { ); } -export default function FileDetailPage() { - const { md5 = "" } = useParams(); +export function LibraryDetail({ jId }: { jId: string }) { const navigate = useNavigate(); 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({ - queryKey: ["file", md5], - queryFn: () => api(`/api/files/${md5}/`), - enabled: md5.length > 0, + queryKey: ["file", jId], + queryFn: () => api(`/api/files/${jId}/`), }); const neighborsQuery = useQuery({ - queryKey: ["neighbors", md5, ordering], + queryKey: ["neighbors", jId, ordering], queryFn: () => api( - `/api/files/${md5}/neighbors/?ordering=${encodeURIComponent(ordering)}`, + `/api/files/${jId}/neighbors/?ordering=${encodeURIComponent(ordering)}`, ), - enabled: md5.length > 0, }); useEffect(() => { @@ -146,9 +185,9 @@ export default function FileDetailPage() { } const neighbors = neighborsQuery.data; if (event.key === "[" && neighbors?.previous) { - navigate(`/view/${neighbors.previous.md5}`); + navigate(`/detail/${neighbors.previous.j_id}`); } else if (event.key === "]" && neighbors?.next) { - navigate(`/view/${neighbors.next.md5}`); + navigate(`/detail/${neighbors.next.j_id}`); } } window.addEventListener("keydown", handleKeyDown); @@ -177,27 +216,39 @@ export default function FileDetailPage() { } const item = query.data; + const canEdit = Boolean( + user && + (user.id === item.uploaded_by_id || + user.is_staff || + user.role === "staff"), + ); return ( -
-
- - Library - - / - - {item.filename} - -
- -
+ + + Library + + / + {item.j_id} +
+ } + actions={ + + } + media={
{item.kind === "video" ? (
- -
+ } + aside={ + <>

+
ID
+
{item.j_id}
MD5
{item.md5} @@ -238,20 +292,14 @@ export default function FileDetailPage() {
Added
{formatDate(item.created_at)}
+
Uploaded by
+
{item.uploaded_by ?? "—"}
- - Download -

[ ] navigate · D download

- -

Locations ({item.locations.length}) @@ -268,8 +316,14 @@ export default function FileDetailPage() { ))}

-
-
- + + + + } + /> ); } diff --git a/frontend/src/features/library/LibraryPage.tsx b/frontend/src/features/library/LibraryPage.tsx index 864b6c3..e907d39 100644 --- a/frontend/src/features/library/LibraryPage.tsx +++ b/frontend/src/features/library/LibraryPage.tsx @@ -8,6 +8,7 @@ import { Button, EmptyState, Spinner, inputClass } from "@/components/ui"; import { api } from "@/lib/api"; import { cn } from "@/lib/cn"; import { isTypingTarget } from "@/lib/dom"; +import { useAuth } from "@/store/auth"; import { useUi } from "@/store/ui"; import type { MediaItem, Paginated } from "@/lib/types"; @@ -51,6 +52,7 @@ export default function LibraryPage() { const setPerPage = useUi((state) => state.setPerPage); const ordering = useUi((state) => state.ordering); const setOrdering = useUi((state) => state.setOrdering); + const user = useAuth((state) => state.user); useEffect(() => { function handleKeyDown(event: KeyboardEvent) { @@ -214,6 +216,12 @@ export default function LibraryPage() { ) : null} + {!user ? ( +

+ Browsing as a guest — some items may be hidden. +

+ ) : null} + {query.isPending ? (
diff --git a/frontend/src/features/library/Md5Redirect.tsx b/frontend/src/features/library/Md5Redirect.tsx new file mode 100644 index 0000000..ddacaa1 --- /dev/null +++ b/frontend/src/features/library/Md5Redirect.tsx @@ -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(`/api/files/${md5.toLowerCase()}/`), + enabled: valid, + retry: 0, + }); + + if (valid && query.isPending) { + return ( +
+ +
+ ); + } + + if (query.isError || !query.data) { + return ( + + ); + } + + return ; +} diff --git a/frontend/src/features/online/OnlineDetailPage.tsx b/frontend/src/features/online/OnlineDetail.tsx similarity index 76% rename from frontend/src/features/online/OnlineDetailPage.tsx rename to frontend/src/features/online/OnlineDetail.tsx index 7796e00..25926ff 100644 --- a/frontend/src/features/online/OnlineDetailPage.tsx +++ b/frontend/src/features/online/OnlineDetail.tsx @@ -8,10 +8,11 @@ import { HeartOff, } from "lucide-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 { DetailShell } from "@/features/detail/DetailShell"; import { api } from "@/lib/api"; import { cn } from "@/lib/cn"; import { renderDText } from "@/lib/dtext"; @@ -26,6 +27,8 @@ import { TAG_CATEGORY_ORDER, } from "@/lib/e621"; import { formatBytes, formatDate } from "@/lib/format"; +import type { MediaItem } from "@/lib/types"; +import { useAuth } from "@/store/auth"; import { useE621 } from "@/store/e621"; const ratingText: Record = { @@ -40,17 +43,18 @@ const relatedBorder: Record = { e: "border-ctp-red/25 hover:border-ctp-red/60", }; -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"; +interface LibraryLookup { + found: string[]; + j_ids: Record; +} -export default function OnlineDetailPage() { - const { id = "" } = useParams(); - const postId = Number(id); +export function OnlineDetail({ postId }: { postId: number }) { const navigate = useNavigate(); const location = useLocation(); const from = (location.state as { from?: string } | null)?.from ?? "/online"; const queryClient = useQueryClient(); const credentials = useE621((state) => state.credentials); + const user = useAuth((state) => state.user); const query = useQuery({ queryKey: ["e621", "post", postId, credentials?.username ?? ""], @@ -91,14 +95,16 @@ export default function OnlineDetailPage() { const lookupQuery = useQuery({ queryKey: ["library-lookup", post?.file.md5 ?? ""], queryFn: () => - api<{ found: string[] }>("/api/files/lookup/", { + api("/api/files/lookup/", { method: "POST", json: { md5s: [post?.file.md5] }, }), enabled: Boolean(post?.file.md5), 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({ mutationFn: (favorite: boolean) => @@ -110,6 +116,23 @@ export default function OnlineDetailPage() { }, }); + const downloadMutation = useMutation({ + mutationFn: (target: NonNullable) => + api("/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) { return (
@@ -138,24 +161,119 @@ export default function OnlineDetailPage() { const isVideo = post.file.ext === "mp4" || post.file.ext === "webm"; const imageSrc = post.sample.has && post.sample.url ? post.sample.url : post.file.url; + const canUpload = Boolean(user?.can_upload); return ( -
-
- - Online - - / - - Post #{post.id} - -
+ + + Online + + / + + Post #{post.id} + +
+ } + actions={ + <> +
+ -
-
+ + {favoriteMutation.isError ? ( +

+ {e621ErrorMessage(favoriteMutation.error)} +

+ ) : null} + {downloadMutation.isError ? ( +

+ {e621ErrorMessage(downloadMutation.error)} +

+ ) : null} + + } + media={ +
+ {isVideo ? ( +
+ } + aside={ + <>

Tags

@@ -226,81 +344,11 @@ export default function OnlineDetailPage() { ) : null} - {inLibrary ? ( -

- In your library -

- ) : null}
- - -
-
- - - - View on e621 - -
- - {favoriteMutation.isError ? ( -

- {e621ErrorMessage(favoriteMutation.error)} -

- ) : null} - -
- {isVideo ? ( -
- + + } + footer={ + <> {post.description.trim() ? (
@@ -368,7 +416,7 @@ export default function OnlineDetailPage() { {(relatedQuery.data ?? []).map((related) => ( ) : null} -
-
-
+ + } + /> ); } diff --git a/frontend/src/features/online/PostCard.tsx b/frontend/src/features/online/PostCard.tsx index f9cb60e..c6982f6 100644 --- a/frontend/src/features/online/PostCard.tsx +++ b/frontend/src/features/online/PostCard.tsx @@ -28,7 +28,7 @@ export function PostCard({ }) { return ( diff --git a/frontend/src/features/upload/UploadPage.tsx b/frontend/src/features/upload/UploadPage.tsx index 364f9c6..7928050 100644 --- a/frontend/src/features/upload/UploadPage.tsx +++ b/frontend/src/features/upload/UploadPage.tsx @@ -6,6 +6,7 @@ import { Button, EmptyState } from "@/components/ui"; import { errorMessage, uploadFile } from "@/lib/api"; import { cn } from "@/lib/cn"; import { formatBytes } from "@/lib/format"; +import { useAuth } from "@/store/auth"; type UploadStatus = "queued" | "uploading" | "done" | "error"; @@ -19,6 +20,7 @@ interface UploadEntry { export default function UploadPage() { const queryClient = useQueryClient(); + const user = useAuth((state) => state.user); const inputRef = useRef(null); const [dragging, setDragging] = useState(false); const [entries, setEntries] = useState([]); @@ -81,6 +83,20 @@ export default function UploadPage() { setEntries((current) => current.filter((entry) => entry.status !== "done")); } + if (user && !user.can_upload) { + return ( +
+
+

Upload

+
+ +
+ ); + } + return (
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index cfdb3cc..d908aa9 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -4,6 +4,10 @@ export interface User { email: string; is_staff: boolean; date_joined: string; + role: "user" | "uploader" | "staff"; + can_upload: boolean; + e621_username: string; + e621_configured: boolean; } export interface MediaLocation { @@ -16,6 +20,8 @@ export interface MediaLocation { export type Rating = "" | "s" | "q" | "e"; export interface MediaItem { + id: number; + j_id: string; md5: string; size: number; rating: Rating; @@ -24,6 +30,8 @@ export interface MediaItem { filename: string; extension: string; kind: "image" | "video"; + uploaded_by: string | null; + uploaded_by_id: number | null; locations: MediaLocation[]; created_at: string; updated_at: string; @@ -56,6 +64,7 @@ export interface SystemStatus { } export interface FileNeighbor { + j_id: string; md5: string; filename: string; }