M0: scaffold SPA + API monorepo

Backend (Django 6.1 + DRF):
- Token auth with a custom User model (register/login/logout/me)
- Library models (MediaItem, MediaLocation) and REST endpoints
- File list/detail with search, rating filter, sorting, pagination
- Multipart upload with optional rating/tags/notes
- Range-aware media serving (video seeking) and ffmpeg thumbnails
- scan_files management command for the watched folder

Frontend (React 19 + Vite + TypeScript):
- Catppuccin Mocha design tokens from the design docs
- App shell, token persistence, protected routes
- Library grid with filters, file detail with custom data editor
- Upload page with per-file progress via XHR
- Dev proxy to the Django API
This commit is contained in:
2026-09-17 07:56:47 -05:00
commit 555c25d77b
65 changed files with 5237 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
backend/venv/
# Django
backend/db.sqlite3
backend/media/
backend/logs/
backend/staticfiles/
# Environment
.env
.env.local
# Node
node_modules/
frontend/dist/
*.local
# Editors / OS
.vscode/
.idea/
.DS_Store
# Tooling
*.log
+7
View File
@@ -0,0 +1,7 @@
Use the venv on backend/venv/
DO NOT mess with the system's python
For Frontend work:
read both Frontend Design related Markdown files on the frontend/ folder
+38
View File
@@ -0,0 +1,38 @@
# J621
Self-hosted media library and e621 archive manager, rebuilt as a **React SPA + Django REST API**.
## Structure
```
backend/ Django 6 + DRF API (SQLite in dev, MariaDB in production)
frontend/ Vite + React + TypeScript SPA
```
## Development
### Backend
```bash
cd backend
source venv/bin/activate
python manage.py migrate
python manage.py scan_files # index the watched folder
python manage.py runserver
```
Copy `.env.example` to `.env` and set `WATCHED_FOLDER` before scanning.
### Frontend
```bash
cd frontend
npm install
npm run dev # http://localhost:5173, proxies /api to :8000
```
### Production
Not wired up yet — planned: Nginx serving the SPA build, `/media` and `/library` media
directly, and proxying `/api` to Waitress/Django. See `frontend/` design docs for the UI
specification.
+5
View File
@@ -0,0 +1,5 @@
SECRET_KEY=change-me-to-a-long-random-string
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
View File
View File
+9
View File
@@ -0,0 +1,9 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from .models import User
@admin.register(User)
class CustomUserAdmin(UserAdmin):
pass
+5
View File
@@ -0,0 +1,5 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
name = "apps.accounts"
@@ -0,0 +1,44 @@
# Generated by Django 6.1.1 on 2026-09-17 12:49
import django.contrib.auth.models
import django.contrib.auth.validators
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='User',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'user',
'verbose_name_plural': 'users',
'abstract': False,
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
]
+5
View File
@@ -0,0 +1,5 @@
from django.contrib.auth.models import AbstractUser
class User(AbstractUser):
"""Project user. Extended later with avatars and e621 credentials."""
+22
View File
@@ -0,0 +1,22 @@
from django.contrib.auth.password_validation import validate_password
from rest_framework import serializers
from .models import User
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ["id", "username", "email", "is_staff", "date_joined"]
read_only_fields = fields
class RegisterSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True, validators=[validate_password])
class Meta:
model = User
fields = ["username", "email", "password"]
def create(self, validated_data):
return User.objects.create_user(**validated_data)
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+11
View File
@@ -0,0 +1,11 @@
from django.urls import path
from rest_framework.authtoken.views import obtain_auth_token
from .views import LogoutView, MeView, RegisterView
urlpatterns = [
path("register/", RegisterView.as_view(), name="register"),
path("token/", obtain_auth_token, name="login"),
path("logout/", LogoutView.as_view(), name="logout"),
path("me/", MeView.as_view(), name="me"),
]
+37
View File
@@ -0,0 +1,37 @@
from rest_framework import status
from rest_framework.authtoken.models import Token
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from .serializers import RegisterSerializer, UserSerializer
class RegisterView(APIView):
permission_classes = [AllowAny]
def post(self, request):
serializer = RegisterSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = serializer.save()
token, _ = Token.objects.get_or_create(user=user)
return Response(
{"token": token.key, "user": UserSerializer(user).data},
status=status.HTTP_201_CREATED,
)
class MeView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
return Response(UserSerializer(request.user).data)
class LogoutView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request):
if request.auth:
request.auth.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
View File
+23
View File
@@ -0,0 +1,23 @@
from django.contrib import admin
from .models import MediaItem, MediaLocation
class MediaLocationInline(admin.TabularInline):
model = MediaLocation
extra = 0
readonly_fields = ("path", "rel_path", "mtime")
@admin.register(MediaItem)
class MediaItemAdmin(admin.ModelAdmin):
list_display = ("md5", "size", "rating", "created_at")
list_filter = ("rating",)
search_fields = ("md5",)
inlines = [MediaLocationInline]
@admin.register(MediaLocation)
class MediaLocationAdmin(admin.ModelAdmin):
list_display = ("rel_path", "item", "mtime")
search_fields = ("rel_path", "path")
+5
View File
@@ -0,0 +1,5 @@
from django.apps import AppConfig
class LibraryConfig(AppConfig):
name = "apps.library"
@@ -0,0 +1,72 @@
import os
from pathlib import Path
from django.conf import settings
from django.core.management.base import BaseCommand
from apps.library.models import MediaLocation
from apps.library.services import ALLOWED_EXTENSIONS, index_file
class Command(BaseCommand):
help = "Scan the watched folder and index media files into the library."
def add_arguments(self, parser):
parser.add_argument(
"--prune",
action="store_true",
help="Remove locations whose files no longer exist on disk.",
)
def handle(self, *args, **options):
folder = Path(settings.WATCHED_FOLDER)
if not folder.is_dir():
self.stderr.write(
self.style.ERROR(f"Watched folder does not exist: {folder}")
)
return
self.stdout.write(f"Scanning {folder} ...")
seen = set()
new_items = 0
new_locations = 0
updated_locations = 0
skipped = 0
for root, dirs, files in os.walk(folder):
dirs.sort()
for name in sorted(files):
if Path(name).suffix.lower() not in ALLOWED_EXTENSIONS:
continue
path = Path(root) / name
try:
_, created_item, created_location = index_file(path, folder)
except OSError as exc:
skipped += 1
self.stderr.write(f"Skipped {path}: {exc}")
continue
seen.add(str(path))
new_items += int(created_item)
if created_location:
new_locations += 1
else:
updated_locations += 1
processed = len(seen)
if processed % 200 == 0:
self.stdout.write(f"Processed {processed} files ...")
if options["prune"]:
stale = MediaLocation.objects.exclude(path__in=seen)
pruned = stale.count()
stale.delete()
self.stdout.write(f"Pruned {pruned} missing location(s).")
self.stdout.write(
self.style.SUCCESS(
f"Done. {len(seen)} file(s) on disk, "
f"{new_items} new item(s), "
f"{new_locations} new location(s), "
f"{updated_locations} refreshed, "
f"{skipped} skipped."
)
)
@@ -0,0 +1,45 @@
# Generated by Django 6.1.1 on 2026-09-17 12:49
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='MediaItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('md5', models.CharField(max_length=32, unique=True)),
('size', models.BigIntegerField(default=0)),
('rating', models.CharField(blank=True, default='', max_length=1)),
('tags', models.JSONField(blank=True, default=list)),
('notes', models.TextField(blank=True, default='')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='MediaLocation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('path', models.CharField(max_length=1024)),
('rel_path', models.CharField(max_length=1024)),
('mtime', models.FloatField(default=0)),
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='locations', to='library.mediaitem')),
],
options={
'ordering': ['rel_path'],
'unique_together': {('item', 'path')},
},
),
]
+38
View File
@@ -0,0 +1,38 @@
from django.db import models
class MediaItem(models.Model):
"""A logical media file, identified by its MD5 fingerprint."""
md5 = models.CharField(max_length=32, unique=True)
size = models.BigIntegerField(default=0)
# Custom rating: "s" (safe), "q" (questionable), "e" (explicit) or "" (unrated).
rating = models.CharField(max_length=1, blank=True, default="")
tags = models.JSONField(default=list, blank=True)
notes = models.TextField(blank=True, default="")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return self.md5
class MediaLocation(models.Model):
"""A physical copy of a media item inside the watched folder."""
item = models.ForeignKey(
MediaItem, related_name="locations", on_delete=models.CASCADE
)
path = models.CharField(max_length=1024)
rel_path = models.CharField(max_length=1024)
mtime = models.FloatField(default=0)
class Meta:
unique_together = ("item", "path")
ordering = ["rel_path"]
def __str__(self):
return self.rel_path
+63
View File
@@ -0,0 +1,63 @@
import os
from rest_framework import serializers
from .models import MediaItem, MediaLocation
from .services import VIDEO_EXTENSIONS
class MediaLocationSerializer(serializers.ModelSerializer):
class Meta:
model = MediaLocation
fields = ["id", "path", "rel_path", "mtime"]
class MediaItemSerializer(serializers.ModelSerializer):
tags = serializers.ListField(
child=serializers.CharField(max_length=100), required=False
)
rating = serializers.ChoiceField(choices=["", "s", "q", "e"], required=False)
locations = MediaLocationSerializer(many=True, read_only=True)
filename = serializers.SerializerMethodField()
extension = serializers.SerializerMethodField()
kind = serializers.SerializerMethodField()
class Meta:
model = MediaItem
fields = [
"md5",
"size",
"rating",
"tags",
"notes",
"filename",
"extension",
"kind",
"locations",
"created_at",
"updated_at",
]
read_only_fields = [
"md5",
"size",
"filename",
"extension",
"kind",
"locations",
"created_at",
"updated_at",
]
def _first_location(self, obj):
locations = list(obj.locations.all())
return locations[0] if locations else None
def get_filename(self, obj):
location = self._first_location(obj)
return os.path.basename(location.rel_path) if location else obj.md5
def get_extension(self, obj):
return os.path.splitext(self.get_filename(obj))[1].lstrip(".").lower()
def get_kind(self, obj):
return "video" if "." + self.get_extension(obj) in VIDEO_EXTENSIONS else "image"
+191
View File
@@ -0,0 +1,191 @@
import hashlib
import json
import mimetypes
import os
import re
import shutil
import subprocess
from pathlib import Path
from django.conf import settings
from django.http import FileResponse, Http404, HttpResponse
from django.utils.text import get_valid_filename
from .models import MediaItem, MediaLocation
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".apng", ".mp4", ".webm"}
VIDEO_EXTENSIONS = {".mp4", ".webm"}
CHUNK_SIZE = 1024 * 1024
RANGE_RE = re.compile(r"bytes=(\d*)-(\d*)$")
def compute_md5(path):
digest = hashlib.md5()
with open(path, "rb") as handle:
for chunk in iter(lambda: handle.read(CHUNK_SIZE), b""):
digest.update(chunk)
return digest.hexdigest()
def index_file(path, folder):
"""Index one file into MediaItem/MediaLocation.
Returns (item, created_item, created_location).
"""
path = Path(path)
folder = Path(folder)
stat = path.stat()
md5 = compute_md5(path)
item, created_item = MediaItem.objects.get_or_create(
md5=md5, defaults={"size": stat.st_size}
)
if not created_item and item.size != stat.st_size:
item.size = stat.st_size
item.save(update_fields=["size", "updated_at"])
location, created_location = MediaLocation.objects.update_or_create(
item=item,
path=str(path),
defaults={
"rel_path": str(path.relative_to(folder)),
"mtime": stat.st_mtime,
},
)
return item, created_item, created_location
def parse_tags(raw):
"""Normalize a comma-separated string or JSON list into a list of tags."""
if raw is None:
return None
if isinstance(raw, (list, tuple)):
values = raw
else:
text = str(raw).strip()
if not text:
return None
if text.startswith("["):
try:
values = json.loads(text)
except json.JSONDecodeError:
values = text.split(",")
else:
values = text.split(",")
cleaned = []
for value in values:
tag = str(value).strip()
if tag and tag not in cleaned:
cleaned.append(tag[:100])
return cleaned
def unique_destination(folder, filename):
"""Return a non-existing path inside folder for the given filename."""
folder = Path(folder)
folder.mkdir(parents=True, exist_ok=True)
filename = get_valid_filename(Path(filename).name) or "upload"
candidate = folder / filename
stem, suffix = candidate.stem, candidate.suffix
counter = 1
while candidate.exists():
candidate = folder / f"{stem}-{counter}{suffix}"
counter += 1
return candidate
class RangeFileWrapper:
"""Iterate over a limited byte range of an open file."""
def __init__(self, file, length, chunk_size=64 * 1024):
self.file = file
self.remaining = length
self.chunk_size = chunk_size
def __iter__(self):
return self
def __next__(self):
if self.remaining <= 0:
raise StopIteration
data = self.file.read(min(self.chunk_size, self.remaining))
if not data:
raise StopIteration
self.remaining -= len(data)
return data
def close(self):
self.file.close()
def serve_file(request, path, download=False):
"""Serve a file with HTTP range support (needed for video seeking)."""
path = Path(path)
if not path.is_file():
raise Http404
size = path.stat().st_size
content_type = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
range_header = request.headers.get("Range", "").strip()
if range_header:
match = RANGE_RE.match(range_header)
if match:
start_raw, end_raw = match.groups()
if start_raw == "" and end_raw:
length = min(int(end_raw), size)
start, end = size - length, size - 1
else:
start = int(start_raw or 0)
end = min(int(end_raw) if end_raw else size - 1, size - 1)
if start >= size or start > end:
response = HttpResponse(status=416)
response["Content-Range"] = f"bytes */{size}"
return response
length = end - start + 1
handle = open(path, "rb")
handle.seek(start)
response = FileResponse(
RangeFileWrapper(handle, length),
status=206,
content_type=content_type,
)
response["Content-Length"] = str(length)
response["Content-Range"] = f"bytes {start}-{end}/{size}"
response["Accept-Ranges"] = "bytes"
return response
response = FileResponse(
open(path, "rb"),
content_type=content_type,
as_attachment=download,
filename=path.name,
)
response["Accept-Ranges"] = "bytes"
return response
def generate_video_thumbnail(md5, path):
"""Extract a JPEG thumbnail from a video, cached under MEDIA_ROOT/thumbs."""
if not shutil.which("ffmpeg"):
return None
thumbs_dir = Path(settings.MEDIA_ROOT) / "thumbs"
thumbs_dir.mkdir(parents=True, exist_ok=True)
target = thumbs_dir / f"{md5}.jpg"
if target.exists() and target.stat().st_mtime >= os.path.getmtime(path):
return target
command = [
"ffmpeg",
"-y",
"-ss",
"0.5",
"-i",
str(path),
"-frames:v",
"1",
"-vf",
"scale=480:-2",
"-loglevel",
"error",
str(target),
]
try:
subprocess.run(command, check=True, capture_output=True, timeout=60)
except (subprocess.SubprocessError, OSError):
return None
return target if target.exists() else None
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+12
View File
@@ -0,0 +1,12 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from .views import MediaItemViewSet, UploadView
router = DefaultRouter()
router.register("files", MediaItemViewSet, basename="file")
urlpatterns = [
path("", include(router.urls)),
path("uploads/", UploadView.as_view(), name="upload"),
]
+123
View File
@@ -0,0 +1,123 @@
from pathlib import Path
from django.conf import settings
from django.db.models import Min
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 IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from . import services
from .models import MediaItem
from .serializers import MediaItemSerializer
class MediaItemViewSet(
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
serializer_class = MediaItemSerializer
lookup_field = "md5"
http_method_names = ["get", "patch", "head", "options"]
def get_queryset(self):
queryset = MediaItem.objects.prefetch_related("locations").annotate(
name=Min("locations__rel_path")
)
search = self.request.query_params.get("search", "").strip()
if search:
queryset = queryset.filter(locations__rel_path__icontains=search)
ratings = [
value
for value in self.request.query_params.get("rating", "").split(",")
if value in {"s", "q", "e"}
]
if ratings:
queryset = queryset.filter(rating__in=ratings)
ordering = self.request.query_params.get("ordering", "").strip()
allowed = {"name", "-name", "size", "-size", "created_at", "-created_at"}
queryset = queryset.order_by(ordering if ordering in allowed else "-created_at")
return queryset.distinct()
@action(detail=True, methods=["get"])
def raw(self, request, md5=None):
item = self.get_object()
location = item.locations.first()
if location is None:
return Response(
{"detail": "No file for this item."},
status=status.HTTP_404_NOT_FOUND,
)
return services.serve_file(
request, location.path, download=request.query_params.get("download") == "1"
)
@action(detail=True, methods=["get"])
def thumbnail(self, request, md5=None):
item = self.get_object()
location = item.locations.first()
if location is None:
return Response(
{"detail": "No file for this item."},
status=status.HTTP_404_NOT_FOUND,
)
path = Path(location.path)
if path.suffix.lower() in services.VIDEO_EXTENSIONS:
thumbnail = services.generate_video_thumbnail(item.md5, path)
if thumbnail is None:
return Response(
{"detail": "Thumbnail unavailable."},
status=status.HTTP_404_NOT_FOUND,
)
return services.serve_file(request, thumbnail)
return services.serve_file(request, path)
class UploadView(APIView):
permission_classes = [IsAuthenticated]
parser_classes = [MultiPartParser, FormParser]
def post(self, request):
upload = request.FILES.get("file")
if upload is None:
return Response(
{"detail": "A file is required."}, status=status.HTTP_400_BAD_REQUEST
)
folder = Path(settings.WATCHED_FOLDER)
destination = services.unique_destination(folder, upload.name)
with open(destination, "wb") as output:
for chunk in upload.chunks():
output.write(chunk)
try:
item, _, _ = services.index_file(destination, folder)
except Exception as exc: # noqa: BLE001 - report indexing failures
destination.unlink(missing_ok=True)
return Response(
{"detail": f"Could not index upload: {exc}"},
status=status.HTTP_400_BAD_REQUEST,
)
update_fields = []
rating = request.data.get("rating") or ""
if rating in {"s", "q", "e"} and item.rating != rating:
item.rating = rating
update_fields.append("rating")
tags = services.parse_tags(request.data.get("tags"))
if tags is not None:
item.tags = tags
update_fields.append("tags")
notes = request.data.get("notes")
if notes is not None:
item.notes = str(notes)
update_fields.append("notes")
if update_fields:
item.save(update_fields=update_fields + ["updated_at"])
return Response(
MediaItemSerializer(item, context={"request": request}).data,
status=status.HTTP_201_CREATED,
)
View File
+16
View File
@@ -0,0 +1,16 @@
"""
ASGI config for config project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/6.1/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
application = get_asgi_application()
+8
View File
@@ -0,0 +1,8 @@
from rest_framework.pagination import PageNumberPagination
class StandardPagination(PageNumberPagination):
page_size = 48
page_size_query_param = "per_page"
max_page_size = 330
page_query_param = "page"
+156
View File
@@ -0,0 +1,156 @@
"""
Django settings for the J621 backend.
"""
import os
from pathlib import Path
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv(BASE_DIR / ".env")
SECRET_KEY = os.getenv("SECRET_KEY", "django-insecure-dev-only-change-me")
DEBUG = os.getenv("DEBUG", "True").lower() == "true"
ALLOWED_HOSTS = [
host.strip()
for host in os.getenv("ALLOWED_HOSTS", "localhost,127.0.0.1,0.0.0.0").split(",")
if host.strip()
]
# Application definition
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"rest_framework",
"rest_framework.authtoken",
"django_filters",
"apps.accounts",
"apps.library",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "config.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "config.wsgi.application"
# Database
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
# Authentication
AUTH_USER_MODEL = "accounts.User"
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
# Internationalization
LANGUAGE_CODE = "en-us"
TIME_ZONE = os.getenv("TIME_ZONE", "America/Bogota")
USE_I18N = True
USE_TZ = True
# Static and media files
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"
# Watched folder that gets indexed into the library.
_watched = os.getenv("WATCHED_FOLDER", "").strip()
WATCHED_FOLDER = Path(_watched) if _watched else MEDIA_ROOT / "library"
if not WATCHED_FOLDER.is_absolute():
WATCHED_FOLDER = BASE_DIR / WATCHED_FOLDER
WATCHED_FOLDER = str(WATCHED_FOLDER)
# Django REST Framework
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.TokenAuthentication",
],
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticatedOrReadOnly",
],
"DEFAULT_FILTER_BACKENDS": [
"django_filters.rest_framework.DjangoFilterBackend",
"rest_framework.filters.SearchFilter",
"rest_framework.filters.OrderingFilter",
],
"DEFAULT_PAGINATION_CLASS": "config.pagination.StandardPagination",
"PAGE_SIZE": 48,
}
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"simple": {
"format": "{levelname} {asctime} {name} {message}",
"style": "{",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "simple",
},
},
"root": {
"handlers": ["console"],
"level": "INFO",
},
}
+13
View File
@@ -0,0 +1,13 @@
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path("admin/", admin.site.urls),
path("api/auth/", include("apps.accounts.urls")),
path("api/", include("apps.library.urls")),
]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
+16
View File
@@ -0,0 +1,16 @@
"""
WSGI config for config project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/6.1/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
application = get_wsgi_application()
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()
+5
View File
@@ -0,0 +1,5 @@
Django>=6.0,<6.2
djangorestframework>=3.17
django-filter>=25.1
Pillow>=11.0
python-dotenv>=1.0
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+249
View File
@@ -0,0 +1,249 @@
---
name: Mocha Archive Terminal
colors:
surface: '#121221'
surface-dim: '#121221'
surface-bright: '#383849'
surface-container-lowest: '#0d0d1c'
surface-container-low: '#1a1a2a'
surface-container: '#1e1e2e'
surface-container-high: '#292839'
surface-container-highest: '#333344'
on-surface: '#e3e0f7'
on-surface-variant: '#cdc3d1'
inverse-surface: '#e3e0f7'
inverse-on-surface: '#2f2f40'
outline: '#968e9a'
outline-variant: '#4a444f'
surface-tint: '#d9b9ff'
primary: '#e2c7ff'
on-primary: '#3f1e66'
primary-container: '#cba6f7'
on-primary-container: '#57377f'
inverse-primary: '#704f98'
secondary: '#a8c8ff'
on-secondary: '#003062'
secondary-container: '#124988'
on-secondary-container: '#8fbaff'
tertiary: '#c8ceff'
on-tertiary: '#212c62'
tertiary-container: '#a8b2f1'
on-tertiary-container: '#39437a'
error: '#ffb4ab'
on-error: '#690005'
error-container: '#93000a'
on-error-container: '#ffdad6'
primary-fixed: '#eedbff'
primary-fixed-dim: '#d9b9ff'
on-primary-fixed: '#290350'
on-primary-fixed-variant: '#57377f'
secondary-fixed: '#d5e3ff'
secondary-fixed-dim: '#a8c8ff'
on-secondary-fixed: '#001b3c'
on-secondary-fixed-variant: '#0e4685'
tertiary-fixed: '#dee0ff'
tertiary-fixed-dim: '#bac3ff'
on-tertiary-fixed: '#09154c'
on-tertiary-fixed-variant: '#38437a'
background: '#121221'
on-background: '#e3e0f7'
surface-variant: '#333344'
typography:
headline-xl:
fontFamily: Inter
fontSize: 2rem
fontWeight: '700'
lineHeight: 2.5rem
letterSpacing: -0.02em
headline-lg:
fontFamily: Inter
fontSize: 1.5rem
fontWeight: '600'
lineHeight: 2rem
letterSpacing: -0.015em
headline-sm:
fontFamily: Inter
fontSize: 1.125rem
fontWeight: '600'
lineHeight: 1.5rem
letterSpacing: -0.01em
body-lg:
fontFamily: Inter
fontSize: 1rem
fontWeight: '400'
lineHeight: 1.5rem
body-md:
fontFamily: Inter
fontSize: 0.875rem
fontWeight: '400'
lineHeight: 1.25rem
body-sm:
fontFamily: Inter
fontSize: 0.75rem
fontWeight: '400'
lineHeight: 1rem
label-md:
fontFamily: JetBrains Mono
fontSize: 0.8125rem
fontWeight: '500'
lineHeight: 1.125rem
label-sm:
fontFamily: JetBrains Mono
fontSize: 0.6875rem
fontWeight: '500'
lineHeight: 0.875rem
code-md:
fontFamily: JetBrains Mono
fontSize: 0.8125rem
fontWeight: '400'
lineHeight: 1.25rem
code-sm:
fontFamily: JetBrains Mono
fontSize: 0.6875rem
fontWeight: '400'
lineHeight: 1rem
rounded:
sm: 0.125rem
DEFAULT: 0.25rem
md: 0.375rem
lg: 0.5rem
xl: 0.75rem
full: 9999px
spacing:
gutter: 0.75rem
margin: 1rem
space-xs: 0.25rem
space-sm: 0.5rem
space-md: 0.75rem
space-lg: 1rem
space-xl: 1.5rem
---
## Brand & Style
This design system establishes a high-density, terminal-adjacent archival workspace tailored for local storage utilities, self-hosted media indexers, and massive metadata catalogs.
The aesthetic synthesizes modern utilitarian minimalism with tactical developer tool ergonomics. It prioritizes information density, deterministic navigation, and prolonged visual comfort during low-light browsing sessions. Interfaces built with this system feel swift, local-first, engineered, and rigorously organized. Visual noise is aggressively suppressed; structure is created via layered dark surfaces and strict typographical hierarchies rather than decorative borders or heavy drop shadows. High-saturation taxonomy accents serve strictly semantic duties—routing the eye through deep tag trees, cryptographic checksums, storage volumes, and safety ratings without fatigue.
## Colors
The palette is rooted in a layered, warm-tinted dark foundation with functional chromatic categorization.
### Surface Architecture
- **Crust (`#11111b`)**: Global structural chrome, status bars, and deepest canvas sinks.
- **Mantle (`#181825`)**: Application rails, persistent panels, toolbars, and primary viewport backgrounds.
- **Base (`#1e1e2e`)**: Active work surfaces, content display fields, and preview canvas.
- **Surface0 (`#313244`)**: Interactive container backings, inactive input wells, and resting component boundaries.
- **Surface1 (`#45475a`)**: Hover highlights, subtle interior structural lines, and elevated tooltips.
- **Surface2 (`#585b70`)**: Active selection states, focused borders, and drag handles.
### Text & Iconography
- **Text (`#cdd6f4`)**: High-contrast body copy, active inputs, and primary values.
- **Subtext1 (`#bac2de`)**: Secondary labels, section headers, and active state indicators.
- **Subtext0 (`#a6adc8`)**: Metadata keys, column identifiers, and timestamps.
- **Overlay0 (`#6c7086`)**: Placeholder text, inactive controls, disabled states, and structural icon fills.
### Accents & Rating Semantics
- **Mauve (`#cba6f7`)**: Primary action intent, keyboard focus triggers, and copyright categorization.
- **Blue (`#89b4fa`)**: Navigational links, dynamic filtering, informational highlights, and general taxonomy.
- **Lavender (`#b4befe`)**: Tertiary controls, filter operators, and logical modifiers.
- **Green (`#a6e3a1`)**: Safe rating (`s`), verified file integrity, matches, and success responses.
- **Peach (`#fab387`)**: Questionable rating (`q`), unverified states, warnings, and species taxonomy.
- **Red (`#f38ba8`)**: Explicit rating (`e`), destructive actions, corruption alerts, and blacklist locks.
- **Yellow (`#f9e2af`)**: Starred items, bookmarked queries, and artist taxonomy.
### Metadata Taxonomy Roles
Tag tokens apply foreground colors paired with 10% opacity tints of the same hue on `Surface0` backgrounds:
- **Artist**: `#f9e2af`
- **Copyright**: `#cba6f7`
- **Character**: `#a6e3a1`
- **Species**: `#fab387`
- **General**: `#89b4fa`
- **Meta**: `#9399b2`
## Typography
Typography establishes an intentional contrast between narrative/UI structural elements (`Inter`) and raw data points (`JetBrains Mono`).
### Rules & Application
- Use `Inter` for layout hierarchy, navigation labels, button labels, modal headers, and descriptive UI documentation.
- Use `JetBrains Mono` strictly for:
- Cryptographic hashes (MD5, SHA-256).
- Storage paths, pool directories, and UNIX file permissions.
- Tag token text and tag post-counts.
- File resolutions (e.g., `3840x2160`), bitrates, color spaces, and file sizes.
- Post IDs, internal sequence counters, and UTC timestamps.
- Tabular figures (`tnum`) must be enforced globally for `JetBrains Mono` to ensure that vertical numerical data columns remain aligned across dense table and sidebar views.
## Layout & Spacing
The layout model is driven by a flexible, high-density utility grid designed to adapt between deep media discovery canvases and compact metadata inspection panels.
### Layout Model
- **Inspector Layout**: A persistent 3-tier layout consisting of a fixed 64px left global navigation rail, an auto-collapsible 260px metadata hierarchy sidebar, and an auto-fitting masonry/grid canvas.
- **Fluid Canvas**: Media grids adjust from 2 to 8 columns based on thumbnail zoom sliders, using CSS Grid with dynamic `minmax()` constraints and a standardized `0.75rem` (`gutter`) boundary.
- **Compact Padding**: Interactive components employ compressed internal metrics (`space-xs` and `space-sm`) to maximize visible information density per vertical screen inch.
### Responsive Breakpoints
- **Mobile (< 768px)**: Metadata panels collapse into a sliding bottom drawer. The media grid fixes to a 2-column layout with `0.5rem` gutters and `0.75rem` outer canvas margins.
- **Tablet (768px - 1024px)**: Left rail persists. Sidebar transitions to an overlay toggle. Media grid utilizes 3 to 4 fluid columns.
- **Desktop (> 1024px)**: Full multi-pane viewport. Three columns: Navigation/Filter sidebar (280px), Content stream (fluid), and Active Item Inspector (340px, collapsible).
## Elevation & Depth
Visual depth is achieved through **tonal layering** and **subtle surface borders**, completely avoiding heavy drop shadows to retain a crisp, utilitarian screen presence.
### Depth Hierarchy
- **Level 0 (Canvas Base)**: `Crust (#11111b)` forms the base environment underneath all views and side navigation wells.
- **Level 1 (Structural Paneling)**: `Mantle (#181825)` elevated above Crust, partitioned with a 1px solid `Surface0 (#313244)` edge.
- **Level 2 (Containers & Cards)**: `Base (#1e1e2e)` for post cards, table rows, and media backings. Uses 1px `Surface0 (#313244)` outline.
- **Level 3 (Interactive Floating Wells)**: Dropdown menus, contextual popovers, and quick-tag menus sit on `Surface0 (#313244)` with a 1px border of `Surface1 (#45475a)` and a 4px ambient tint: `box-shadow: 0 4px 12px rgba(17, 17, 27, 0.6)`.
Focus states reject fuzzy drop shadows in favor of a crisp 2px offset outline using `Mauve (#cba6f7)`.
## Shapes
This design system uses a **Soft (`roundedness: 1`)** geometry specification (baseline `0.25rem` / 4px, `rounded-lg` at `0.5rem` / 8px).
- **Micro Controls**: Checkboxes, tag badges, status pills, and thumbnail corner overlays use strict `0.25rem` radii.
- **Standard Controls**: Inputs, buttons, segmented selectors, and dropdown triggers use `0.375rem` (6px).
- **Major Containers**: Media cards, modal dialogs, storage pool panels, and image viewing canvases use `0.5rem` (`rounded-lg`).
- **Pill Shapes (`9999px`)**: Reserved exclusively for safety rating badges (`Safe`, `Questionable`, `Explicit`) and active search-token pills in the omnibar.
## Components
### Buttons
- **Primary**: Background `Mauve (#cba6f7)`, text `Crust (#11111b)`, font `Inter` semibold. Hover: lightens brightness by 10%. Active: scales down to `0.98`. Focus: 2px ring of `Mauve` with a 2px `Base` gap.
- **Secondary/Ghost**: Background `Surface0 (#313244)`, text `Text (#cdd6f4)`, border 1px solid `Surface1 (#45475a)`. Hover: background `Surface1 (#45475a)`.
- **Destructive**: Background `transparent`, text `Red (#f38ba8)`, border 1px solid `Red` at 40% opacity. Hover: background `Red` at 15% opacity.
### Chips & Taxonomy Badges
- **Tag Badges**: Mono-font (`JetBrains Mono`), `0.6875rem`, letter-spacing normal.
- Structure: A 1px border matching the taxonomy category color at 30% opacity, background set to category color at 10% opacity, text set to the pure category color.
- Hover: Background shifts to 20% opacity; shows append/negate controls (`+`, `-`).
- **Rating Pills**: Compact badges positioned over thumbnails or in item manifests.
- `Safe`: Text `Crust`, background `Green (#a6e3a1)`.
- `Questionable`: Text `Crust`, background `Peach (#fab387)`.
- `Explicit`: Text `Crust`, background `Red (#f38ba8)`.
### Input Fields & Omnibar
- **Resting**: Background `Mantle (#181825)`, border 1px solid `Surface0 (#313244)`, text `Text (#cdd6f4)`, placeholder `Overlay0 (#6c7086)`. Font: `JetBrains Mono` for syntax inputs, `Inter` for general forms.
- **Focused**: Border color `Mauve (#cba6f7)`, background `Base (#1e1e2e)`, box-shadow `0 0 0 1px #cba6f7`.
- **Tag Autocomplete Inputs**: Displays color-coded prefixes (`artist:`, `series:`, `md5:`) matching taxonomy tokens within the text caret flow.
### Media Cards
- **Shell**: Background `Base (#1e1e2e)`, border 1px solid `Surface0 (#313244)`, overflow hidden.
- **Image Frame**: Center-cropped preview with an aspect ratio of 1:1 in grid mode.
- **Card Overlays**: Hover reveals a `Mantle (#181825)` gradient at the bottom containing file dimensions, extension label (e.g., `WEBM`, `PNG`), and file size in `JetBrains Mono` sm. Top corners feature safety rating pill (left) and favorite/star status (right).
### Checkboxes & Radio Elements
- **Box**: 14x14px, background `Mantle (#181825)`, border 1px solid `Surface2 (#585b70)`, border radius 3px.
- **Checked**: Background `Mauve (#cba6f7)`, border `Mauve (#cba6f7)`, checkmark icon tinted `Crust (#11111b)`.
### Data Tables & Lists
- **Rows**: Alternating stripes of `Base (#1e1e2e)` and `Mantle (#181825)`. Border bottom 1px solid `Surface0 (#313244)`.
- **Row Hover**: Background `Surface0 (#313244)` at 50% opacity.
- **Cell Metric Formatting**: Aligned right for sizes, IDs, and dimensions; formatted using `JetBrains Mono`.
### Storage & Pool Indicators
- **Capacity Bars**: 4px high track in `Surface0 (#313244)`, fill color defaults to `Blue (#89b4fa)`. Transitions to `Peach (#fab387)` at 80% capacity and `Red (#f38ba8)` at 95% capacity.
+142
View File
@@ -0,0 +1,142 @@
# DESIGN.md — J621 Frontend Design System & Architecture Specification
> **Version:** 1.4.2-stable
> **Theme Foundation:** Catppuccin Mocha + e621 Authentic Taxonomy Architecture
> **Target Platform:** Desktop-first, fully responsive self-hosted NAS / Home Server Media Web Application
> **Reference Design System:** `Mocha Archive Terminal` (`{{DATA:DESIGN_SYSTEM:DESIGN_SYSTEM_3}}`)
---
## 1. Visual Language & Foundation
The visual identity of J621 blends a high-density, pro-tier terminal aesthetics (inspired by modern system monitors and desktop DAM software) with the **Catppuccin Mocha** palette and authentic **e621.net** color-coded taxonomy standards.
### 1.1 Color Palette Tokens
```css
:root {
/* Surface Layers (Catppuccin Mocha) */
--bg-crust: #11111b; /* Outer frame, status strips, sticky bottom bars */
--bg-mantle: #181825; /* Panels, sidebars, modal surfaces, table headers */
--bg-base: #1e1e2e; /* Primary canvas & workspace background */
--surface-0: #313244; /* Cards, inputs, table rows, neutral borders */
--surface-1: #45475a; /* Hover states, active borders, elevated chips */
--surface-2: #585b70; /* Active button states, selected boundaries */
/* Text & Typography */
--text-primary: #cdd6f4; /* High-contrast body, primary titles */
--text-subtext1: #bac2de; /* Secondary labels, active badges */
--text-subtext0: #a6adc8; /* Metadata captions, counters */
--text-overlay0: #6c7086; /* Disabled states, placeholders, icons */
--text-overlay1: #7f849c; /* Subtle metadata */
/* Brand & Core Accents */
--accent-mauve: #cba6f7; /* Primary brand accent, active tabs, buttons */
--accent-mauve-dim: rgba(203, 166, 247, 0.15); /* Tinted selection pill */
--accent-lavender: #b4befe; /* Secondary links & focus rings */
--accent-teal: #94e2d5; /* System online indicators, verified states */
--accent-blue: #89b4fa; /* Primary links, remote indicators */
/* Status & Severity */
--status-success: #a6e3a1; /* Safe rating, verified checksums, healthy */
--status-warning: #fab387; /* Questionable rating, pending warnings */
--status-danger: #f38ba8; /* Explicit rating, blacklisted items, errors */
--status-highlight: #f9e2af; /* Followed stars, active favorites */
}
```
---
## 2. e621 Authentic Taxonomy Color Specification
All tag chips, counters, and sidebar taxonomy badges strictly adhere to e621's established namespace semantics mapped to Mocha tokens:
| Namespace | Mocha Token | Hex Code | UI Semantic Usage |
|---|---|---|---|
| **Artist** | Yellow / Gold | `#f9e2af` | Creators, animators, illustrators |
| **Copyright** | Mauve / Purple | `#cba6f7` | Franchises, studios, properties (e.g. *Pokemon*, *Nintendo*) |
| **Character** | Green | `#a6e3a1` | Named characters (*Krystal*, *Fox McCloud*) |
| **Species** | Peach / Orange | `#fab387` | Biological species, body forms (*Jolteon*, *Canine*, *Dragon*) |
| **General** | Sky Blue | `#89b4fa` | Action, posture, apparel, thematic tags (*solo*, *clothed*, *bag*) |
| **Meta** | Subtext / Slate | `#9399b2` | Resolution, digital specs, years (*hi_res*, *2026*, *webm*) |
| **Lore** | Teal | `#94e2d5` | Fictional universe lore and faction metadata |
| **Invalid / Blacklist** | Red | `#f38ba8` | Filtered content, blacklisted rules, dangerous operations |
---
## 3. Rating & Moderation Semantics
- **Safe (S):** Tagged with `#a6e3a1` (Green). 1px border on gallery cards, badge background `rgba(166, 227, 161, 0.15)`.
- **Questionable (Q):** Tagged with `#fab387` (Peach/Amber). 1px border, badge background `rgba(250, 179, 135, 0.15)`.
- **Explicit (E):** Tagged with `#f38ba8` (Red). 1px border, badge background `rgba(243, 139, 168, 0.15)`.
- **Blacklisted Overlay:** Filtered pools/tags replace the preview with a dark container (`#11111b`), a red ban shield icon (`#f38ba8`), and explicit `Bypass & Reveal` or `Manage Filter` options.
---
## 4. Layout & Information Architecture
### 4.1 Global Frame (Shell)
- **Top App Bar (`#11111b`, height: 56px):**
- Left: Brand mark `J621` badge (`#cba6f7` badge with `NAS UI` tag).
- Center: Nav pills: `Library`, `Online`, `Pools`, `Followed`, `Upload`, `Duplicates`, and `Stats [STAFF]`.
- Right: System latency pill (`ENV: PROD · LINUX · 18ms (e621: 4ms)`), user avatar.
- **Global Footer Status Strip (`#11111b`, height: 32px):**
- Left: `📁 Local Storage: /data/media/j621/archive`
- Center: `⚡ Active Workers: 4 Parsing | Sync Queue: 0 Pending`
- Right: `J621 Engine v1.4.2-stable`
- **Collapsible Sidebar (Behavior):**
- **Closed by default** across all gallery and viewer views to prioritize screen real estate.
- Toggled via `☰` button or quick keyboard shortcut (`Ctrl+B` or `⌘B`).
- On mobile/tablet viewports (< 1024px), opens as an overlay modal drawer with backdrop blur.
---
## 5. Screen Component Catalog
### 5.1 Library Main Gallery
- **Header Filters:** Search query input with autocomplete (`filename:`, `tag:`, `score:`, `rating:`), items-per-page selector (24, 48, 96), and sorting dropdown (`Date Added`, `Filesize`, `Post ID`).
- **Media Card Grid:** CSS Grid (`grid-template-columns: repeat(auto-fill, minmax(220px, 1fr))`).
- **Card Specs:**
- Rating corner indicator ribbon (`S`, `Q`, `E`).
- Sync verification icon (`✓ Ingested`).
- Monospace footer: `#<post_id>` · `<filesize>` · `<score> ▲ <faves> ★ <comments> 💬`.
### 5.2 Library & Online Detail View (e621 Aligned)
- **2-Column Layout:**
- **Left Rail (320px):** Categorized tag hierarchy (`Artist`, `Copyright`, `Species`, `General`, `Meta`) with tag search links `?` and follow `+` toggles. Specs sheet (Post ID, MD5, dimensions, format, source URL).
- **Right Main Area:**
- Action Header: `Download to Local Library`, `Write to NAS`, `Favorite`, `Add to Pool`.
- Media Canvas: Centered high-resolution image or loopable WebM/MP4 viewer with pan/zoom.
- Context Panels: Artist commentary, local storage allocation paths (`/data/media/j621/...`), inode/checksum integrity report, and user comment stream.
### 5.3 Upload & Ingestion Pipeline
- **Dropzone:** Drag-and-drop target with support for PNG, JPG, WEBM, MP4, GIF (up to 512MB).
- **3-Column Board:**
1. `Pending & Unmatched`: Local uploads awaiting e621 ID link or custom tag specification.
2. `Visual Similarity Detected`: Perceptual hash clash resolution (side-by-side diff with replacement or duplicate-keep buttons).
3. `Auto-uploaded & Indexed`: Files verified by MD5 or IQDB and written to the archive.
### 5.4 Duplicates & Similarity Engine
- **MD5 Exact Match Section:** Hash collision certainty (100%), 1-click `Keep File A & Delete File B`.
- **Perceptual Similarity Groups:** Confidence bar (e.g. 96%), side-by-side artwork previews with crop/resolution indicators, and `Commit Deduplication` queue.
### 5.5 Followed Tags & Pools
- **Subscription Cards:** Multi-post collage or tag cover, `Unseen Badge` (`14 Unseen`, `99+ Unseen`), follow/unfollow toggle star, and `Mark Seen` button.
### 5.6 Staff System & Backend Statistics
- **Telemetry Cards:** AMD Ryzen CPU load/temp gauge, ECC RAM allocation bar, ZFS NVMe pool usage, NVIDIA NVENC hardware acceleration status.
- **Worker Pipeline:** 4 active background parsing threads with job IDs and operations (`pHash`, `BLAKE3`, `e621_api`).
- **Live Terminal Log:** Monospace tail container with autoscroll toggle and log level highlighting (`[INFO]`, `[INGEST]`, `[API]`).
---
## 6. Implementation Notes for Frontend Engineers
1. **State Persistence:** Sidebar open/closed state, active rating filter toggles, and items-per-page must persist in `localStorage`.
2. **Smooth Layout Shift Prevention:** Preload image dimension aspect ratios (`aspect-ratio: attr(width) / attr(height)`) to prevent grid jumping during infinite scroll or pagination loads.
3. **Keyboard Accessibility:**
- `/`: Focus search bar
- `Ctrl+K` / `⌘K`: Quick command palette & tag finder
- `[` / `]`: Previous / Next post in detail view
- `D`: Trigger "Download / Ingest to NAS"
- `F`: Toggle favorite / follow
+20
View File
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#11111b" />
<title>J621</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2166
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
{
"name": "j621-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.103.1",
"clsx": "^2.1.1",
"lucide-react": "^1.47.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.4",
"tailwind-merge": "^3.7.0",
"zustand": "^5.0.15"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"oxlint": "^1.81.0",
"tailwindcss": "^4.3.3",
"typescript": "~6.0.2",
"vite": "^8.3.0"
}
}
+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#cba6f7" />
<text
x="32"
y="44"
font-family="ui-monospace, SFMono-Regular, Menlo, monospace"
font-size="32"
font-weight="700"
text-anchor="middle"
fill="#11111b"
>J</text>
</svg>

After

Width:  |  Height:  |  Size: 317 B

+53
View File
@@ -0,0 +1,53 @@
import { useEffect, type ReactNode } from "react";
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
import { AppShell } from "@/components/AppShell";
import { FullPageSpinner } from "@/components/ui";
import LoginPage from "@/features/auth/LoginPage";
import RegisterPage from "@/features/auth/RegisterPage";
import FileDetailPage from "@/features/library/FileDetailPage";
import LibraryPage from "@/features/library/LibraryPage";
import UploadPage from "@/features/upload/UploadPage";
import { useAuth } from "@/store/auth";
function RequireAuth({ children }: { children: ReactNode }) {
const user = useAuth((state) => state.user);
const location = useLocation();
if (!user) {
return <Navigate to="/login" state={{ from: location.pathname }} replace />;
}
return <>{children}</>;
}
export default function App() {
const ready = useAuth((state) => state.ready);
const loadUser = useAuth((state) => state.loadUser);
useEffect(() => {
void loadUser();
}, [loadUser]);
if (!ready) {
return <FullPageSpinner />;
}
return (
<Routes>
<Route element={<AppShell />}>
<Route path="/" element={<LibraryPage />} />
<Route path="/view/:md5" element={<FileDetailPage />} />
<Route
path="/upload"
element={
<RequireAuth>
<UploadPage />
</RequireAuth>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
</Routes>
);
}
+86
View File
@@ -0,0 +1,86 @@
import { LogOut } from "lucide-react";
import { Link, NavLink, Outlet } from "react-router-dom";
import { cn } from "@/lib/cn";
import { useAuth } from "@/store/auth";
const navItems = [
{ to: "/", label: "Library" },
{ to: "/upload", label: "Upload" },
];
export function AppShell() {
const user = useAuth((state) => state.user);
const logout = useAuth((state) => state.logout);
return (
<div className="flex min-h-screen flex-col">
<header className="sticky top-0 z-40 border-b border-ctp-surface0 bg-ctp-crust/95 backdrop-blur">
<div className="mx-auto flex h-14 w-full max-w-[1600px] items-center gap-6 px-4">
<Link to="/" className="flex items-center gap-2.5">
<span className="rounded bg-ctp-mauve px-2 py-1 font-mono text-xs font-bold tracking-wide text-ctp-crust">
J621
</span>
<span className="hidden text-[11px] font-medium uppercase tracking-[0.14em] text-ctp-overlay0 md:block">
Mocha Archive Terminal
</span>
</Link>
<nav className="flex items-center gap-1">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === "/"}
className={({ isActive }) =>
cn(
"rounded-md px-3 py-1.5 text-sm font-medium transition",
isActive
? "bg-ctp-surface0 text-ctp-text"
: "text-ctp-subtext0 hover:bg-ctp-surface0/60 hover:text-ctp-text",
)
}
>
{item.label}
</NavLink>
))}
</nav>
<div className="ml-auto flex items-center gap-3">
{user ? (
<>
<div className="flex items-center gap-2">
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-ctp-surface1 font-mono text-xs font-semibold text-ctp-lavender">
{user.username.slice(0, 1).toUpperCase()}
</span>
<span className="hidden text-sm text-ctp-subtext1 sm:block">
{user.username}
</span>
</div>
<button
type="button"
onClick={() => void logout()}
title="Log out"
className="rounded-md p-1.5 text-ctp-overlay0 transition hover:bg-ctp-surface0 hover:text-ctp-text"
>
<LogOut className="h-4 w-4" />
</button>
</>
) : (
<Link
to="/login"
className="rounded-md px-3 py-1.5 text-sm font-medium text-ctp-subtext0 transition hover:bg-ctp-surface0 hover:text-ctp-text"
>
Log in
</Link>
)}
</div>
</div>
</header>
<main className="mx-auto w-full max-w-[1600px] flex-1 px-4 py-6">
<Outlet />
</main>
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { Link } from "react-router-dom";
import { cn } from "@/lib/cn";
import { formatBytes } from "@/lib/format";
import type { MediaItem } from "@/lib/types";
const ratingStyles: Record<string, string> = {
s: "border-ctp-green/30 bg-ctp-green/15 text-ctp-green",
q: "border-ctp-peach/30 bg-ctp-peach/15 text-ctp-peach",
e: "border-ctp-red/30 bg-ctp-red/15 text-ctp-red",
};
const ratingLabels: Record<string, string> = {
s: "S",
q: "Q",
e: "E",
};
export function MediaCard({ item }: { item: MediaItem }) {
const preview =
item.kind === "video"
? `/api/files/${item.md5}/thumbnail/`
: `/api/files/${item.md5}/raw/`;
return (
<Link
to={`/view/${item.md5}`}
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">
<img
src={preview}
alt={item.filename}
loading="lazy"
className="h-full w-full object-cover transition duration-200 group-hover:scale-[1.03]"
/>
{item.rating ? (
<span
className={cn(
"absolute left-2 top-2 rounded-full border px-2 py-0.5 font-mono text-[11px] font-medium",
ratingStyles[item.rating],
)}
>
{ratingLabels[item.rating]}
</span>
) : null}
<span className="absolute right-2 top-2 rounded-sm bg-ctp-crust/80 px-1.5 py-0.5 font-mono text-[10px] uppercase text-ctp-overlay1">
{item.extension}
</span>
<div className="absolute inset-x-0 bottom-0 translate-y-full bg-gradient-to-t from-ctp-mantle via-ctp-mantle/80 to-transparent p-2 pt-8 transition duration-150 group-hover:translate-y-0">
<p className="font-mono text-[11px] text-ctp-subtext0">
{formatBytes(item.size)}
</p>
</div>
</div>
<div className="px-2.5 py-2">
<p className="truncate font-mono text-xs text-ctp-subtext1" title={item.filename}>
{item.filename}
</p>
</div>
</Link>
);
}
+90
View File
@@ -0,0 +1,90 @@
import type { ButtonHTMLAttributes, ReactNode } from "react";
import { cn } from "@/lib/cn";
type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
const buttonVariants: Record<ButtonVariant, string> = {
primary: "bg-ctp-mauve text-ctp-crust hover:brightness-110",
secondary:
"border border-ctp-surface1 bg-ctp-surface0 text-ctp-text hover:bg-ctp-surface1",
ghost: "text-ctp-subtext0 hover:bg-ctp-surface0 hover:text-ctp-text",
danger: "border border-ctp-red/40 text-ctp-red hover:bg-ctp-red/15",
};
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
}
export function Button({ variant = "primary", className, ...props }: ButtonProps) {
return (
<button
className={cn(
"inline-flex items-center justify-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium transition",
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ctp-mauve",
"active:scale-[.98] disabled:pointer-events-none disabled:opacity-50",
buttonVariants[variant],
className,
)}
{...props}
/>
);
}
export function Spinner({ className }: { className?: string }) {
return (
<div
className={cn(
"h-4 w-4 animate-spin rounded-full border-2 border-ctp-surface2 border-t-ctp-mauve",
className,
)}
role="status"
aria-label="Loading"
/>
);
}
export function FullPageSpinner() {
return (
<div className="flex min-h-screen items-center justify-center bg-ctp-crust">
<Spinner className="h-6 w-6" />
</div>
);
}
export function Field({
label,
children,
}: {
label: string;
children: ReactNode;
}) {
return (
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
{label}
</span>
{children}
</label>
);
}
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 function EmptyState({
title,
description,
}: {
title: string;
description?: ReactNode;
}) {
return (
<div className="flex flex-col items-center justify-center gap-1 rounded-lg border border-dashed border-ctp-surface1 px-6 py-16 text-center">
<p className="text-sm font-medium text-ctp-subtext1">{title}</p>
{description ? (
<p className="text-sm text-ctp-overlay0">{description}</p>
) : null}
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
import { useState, type FormEvent } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { Button, Field, inputClass } from "@/components/ui";
import { errorMessage } from "@/lib/api";
import { useAuth } from "@/store/auth";
export default function LoginPage() {
const login = useAuth((state) => state.login);
const navigate = useNavigate();
const location = useLocation();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const from = (location.state as { from?: string } | null)?.from ?? "/";
async function handleSubmit(event: FormEvent) {
event.preventDefault();
setBusy(true);
setError(null);
try {
await login(username, password);
navigate(from, { replace: true });
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
return (
<div className="flex min-h-screen items-center justify-center px-4">
<div className="w-full max-w-sm rounded-xl border border-ctp-surface0 bg-ctp-mantle p-6">
<div className="mb-6 flex flex-col items-center gap-3">
<span className="rounded bg-ctp-mauve px-3 py-1 font-mono text-sm font-bold text-ctp-crust">
J621
</span>
<h1 className="text-lg font-semibold">Log in</h1>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<Field label="Username">
<input
className={inputClass}
value={username}
onChange={(event) => setUsername(event.target.value)}
autoComplete="username"
autoFocus
required
/>
</Field>
<Field label="Password">
<input
className={inputClass}
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="current-password"
required
/>
</Field>
{error ? (
<p className="rounded-md border border-ctp-red/30 bg-ctp-red/10 px-3 py-2 text-sm text-ctp-red">
{error}
</p>
) : null}
<Button type="submit" disabled={busy}>
{busy ? "Signing in…" : "Sign in"}
</Button>
</form>
<p className="mt-4 text-center text-sm text-ctp-overlay0">
No account?{" "}
<Link to="/register" className="text-ctp-blue hover:underline">
Register
</Link>
</p>
</div>
</div>
);
}
@@ -0,0 +1,93 @@
import { useState, type FormEvent } from "react";
import { Link, useNavigate } from "react-router-dom";
import { Button, Field, inputClass } from "@/components/ui";
import { errorMessage } from "@/lib/api";
import { useAuth } from "@/store/auth";
export default function RegisterPage() {
const register = useAuth((state) => state.register);
const navigate = useNavigate();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function handleSubmit(event: FormEvent) {
event.preventDefault();
if (password !== confirm) {
setError("Passwords do not match.");
return;
}
setBusy(true);
setError(null);
try {
await register(username, password);
navigate("/", { replace: true });
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
return (
<div className="flex min-h-screen items-center justify-center px-4">
<div className="w-full max-w-sm rounded-xl border border-ctp-surface0 bg-ctp-mantle p-6">
<div className="mb-6 flex flex-col items-center gap-3">
<span className="rounded bg-ctp-mauve px-3 py-1 font-mono text-sm font-bold text-ctp-crust">
J621
</span>
<h1 className="text-lg font-semibold">Create account</h1>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<Field label="Username">
<input
className={inputClass}
value={username}
onChange={(event) => setUsername(event.target.value)}
autoComplete="username"
autoFocus
required
/>
</Field>
<Field label="Password">
<input
className={inputClass}
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="new-password"
required
/>
</Field>
<Field label="Confirm password">
<input
className={inputClass}
type="password"
value={confirm}
onChange={(event) => setConfirm(event.target.value)}
autoComplete="new-password"
required
/>
</Field>
{error ? (
<p className="rounded-md border border-ctp-red/30 bg-ctp-red/10 px-3 py-2 text-sm text-ctp-red">
{error}
</p>
) : null}
<Button type="submit" disabled={busy}>
{busy ? "Creating account…" : "Register"}
</Button>
</form>
<p className="mt-4 text-center text-sm text-ctp-overlay0">
Already have an account?{" "}
<Link to="/login" className="text-ctp-blue hover:underline">
Log in
</Link>
</p>
</div>
</div>
);
}
@@ -0,0 +1,244 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Download } from "lucide-react";
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { Button, EmptyState, Spinner, inputClass } from "@/components/ui";
import { api, errorMessage } from "@/lib/api";
import { cn } from "@/lib/cn";
import { formatBytes, formatDate } from "@/lib/format";
import type { MediaItem, Rating } from "@/lib/types";
const ratingChoices: { value: Rating; label: string; active: string }[] = [
{
value: "",
label: "None",
active: "border-ctp-surface2 bg-ctp-surface0 text-ctp-text",
},
{
value: "s",
label: "Safe",
active: "border-ctp-green/40 bg-ctp-green/15 text-ctp-green",
},
{
value: "q",
label: "Questionable",
active: "border-ctp-peach/40 bg-ctp-peach/15 text-ctp-peach",
},
{
value: "e",
label: "Explicit",
active: "border-ctp-red/40 bg-ctp-red/15 text-ctp-red",
},
];
function CustomDataCard({ item }: { item: MediaItem }) {
const queryClient = useQueryClient();
const [rating, setRating] = useState<Rating>(item.rating);
const [tags, setTags] = useState(item.tags.join(", "));
const [notes, setNotes] = useState(item.notes);
useEffect(() => {
setRating(item.rating);
setTags(item.tags.join(", "));
setNotes(item.notes);
}, [item.md5, item.rating, item.tags, item.notes]);
const mutation = useMutation({
mutationFn: (payload: { rating: Rating; tags: string[]; notes: string }) =>
api<MediaItem>(`/api/files/${item.md5}/`, {
method: "PATCH",
json: payload,
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["file", item.md5] });
void queryClient.invalidateQueries({ queryKey: ["files"] });
},
});
function save() {
mutation.mutate({
rating,
tags: tags
.split(",")
.map((tag) => tag.trim())
.filter(Boolean),
notes,
});
}
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>
<div className="mt-3 flex flex-col gap-3">
<div className="flex flex-wrap gap-1.5">
{ratingChoices.map((choice) => (
<button
key={choice.value || "none"}
type="button"
onClick={() => setRating(choice.value)}
className={cn(
"rounded-full border px-2.5 py-1 text-xs font-medium transition",
rating === choice.value
? choice.active
: "border-ctp-surface1 text-ctp-subtext0 hover:border-ctp-surface2 hover:text-ctp-text",
)}
>
{choice.label}
</button>
))}
</div>
<input
className={inputClass}
placeholder="Tags, comma separated"
value={tags}
onChange={(event) => setTags(event.target.value)}
/>
<textarea
className={cn(inputClass, "min-h-20 resize-y")}
placeholder="Notes…"
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
{mutation.isError ? (
<p className="text-sm text-ctp-red">{errorMessage(mutation.error)}</p>
) : mutation.isSuccess ? (
<p className="text-sm text-ctp-green">Saved.</p>
) : null}
<Button onClick={save} disabled={mutation.isPending}>
{mutation.isPending ? "Saving…" : "Save"}
</Button>
</div>
</section>
);
}
export default function FileDetailPage() {
const { md5 = "" } = useParams();
const query = useQuery({
queryKey: ["file", md5],
queryFn: () => api<MediaItem>(`/api/files/${md5}/`),
enabled: md5.length > 0,
});
if (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={
<Link to="/" className="text-ctp-blue hover:underline">
Back to the library
</Link>
}
/>
);
}
const item = query.data;
const rawUrl = `/api/files/${item.md5}/raw/`;
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3 text-sm">
<Link
to="/"
className="inline-flex items-center gap-1 text-ctp-subtext0 transition hover:text-ctp-text"
>
<ArrowLeft className="h-4 w-4" /> Library
</Link>
<span className="text-ctp-surface1">/</span>
<span className="truncate font-mono text-xs text-ctp-overlay0">
{item.filename}
</span>
</div>
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_360px]">
<div className="flex min-h-[320px] items-center justify-center overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-mantle">
{item.kind === "video" ? (
<video
key={item.md5}
src={rawUrl}
controls
autoPlay
loop
muted
className="max-h-[75vh] w-full object-contain"
/>
) : (
<img
key={item.md5}
src={rawUrl}
alt={item.filename}
className="max-h-[75vh] w-full object-contain"
/>
)}
</div>
<div className="flex flex-col gap-4">
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
<h1
className="truncate font-mono text-sm text-ctp-subtext1"
title={item.filename}
>
{item.filename}
</h1>
<dl className="mt-3 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-xs">
<dt className="text-ctp-overlay0">MD5</dt>
<dd className="truncate font-mono text-ctp-subtext0" title={item.md5}>
{item.md5}
</dd>
<dt className="text-ctp-overlay0">Size</dt>
<dd className="font-mono text-ctp-subtext0">
{formatBytes(item.size)}
</dd>
<dt className="text-ctp-overlay0">Type</dt>
<dd className="font-mono uppercase text-ctp-subtext0">
{item.extension} · {item.kind}
</dd>
<dt className="text-ctp-overlay0">Added</dt>
<dd className="text-ctp-subtext0">{formatDate(item.created_at)}</dd>
</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>
</section>
<CustomDataCard item={item} />
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
<h2 className="text-sm font-semibold text-ctp-subtext1">
Locations ({item.locations.length})
</h2>
<ul className="mt-2 flex flex-col gap-1.5">
{item.locations.map((location) => (
<li
key={location.id}
className="truncate font-mono text-xs text-ctp-overlay1"
title={location.path}
>
{location.rel_path}
</li>
))}
</ul>
</section>
</div>
</div>
</div>
);
}
@@ -0,0 +1,242 @@
import { useQuery } from "@tanstack/react-query";
import { Search } from "lucide-react";
import { useState, type FormEvent } from "react";
import { MediaCard } from "@/components/MediaCard";
import { Button, EmptyState, Spinner, inputClass } from "@/components/ui";
import { api } from "@/lib/api";
import { cn } from "@/lib/cn";
import type { MediaItem, Paginated } from "@/lib/types";
const sortOptions = [
{ value: "-created_at", label: "Newest first" },
{ value: "created_at", label: "Oldest first" },
{ value: "-size", label: "Largest first" },
{ value: "size", label: "Smallest first" },
{ value: "name", label: "Name A–Z" },
{ value: "-name", label: "Name Z–A" },
];
const perPageOptions = [24, 48, 96];
const ratingFilters = [
{
value: "s",
label: "Safe",
active: "border-ctp-green/40 bg-ctp-green/15 text-ctp-green",
},
{
value: "q",
label: "Questionable",
active: "border-ctp-peach/40 bg-ctp-peach/15 text-ctp-peach",
},
{
value: "e",
label: "Explicit",
active: "border-ctp-red/40 bg-ctp-red/15 text-ctp-red",
},
];
export default function LibraryPage() {
const [searchInput, setSearchInput] = useState("");
const [search, setSearch] = useState("");
const [ratings, setRatings] = useState<string[]>([]);
const [ordering, setOrdering] = useState("-created_at");
const [perPage, setPerPage] = useState(48);
const [page, setPage] = useState(1);
const query = useQuery({
queryKey: ["files", { search, ratings, ordering, perPage, page }],
queryFn: () => {
const params = new URLSearchParams({
page: String(page),
per_page: String(perPage),
ordering,
});
if (search) params.set("search", search);
if (ratings.length) params.set("rating", ratings.join(","));
return api<Paginated<MediaItem>>(`/api/files/?${params.toString()}`);
},
});
const pageCount = query.data
? Math.max(1, Math.ceil(query.data.count / perPage))
: 1;
function applySearch(event: FormEvent) {
event.preventDefault();
setPage(1);
setSearch(searchInput.trim());
}
function toggleRating(value: string) {
setPage(1);
setRatings((current) =>
current.includes(value)
? current.filter((rating) => rating !== value)
: [...current, value],
);
}
function clearFilters() {
setSearchInput("");
setSearch("");
setRatings([]);
setPage(1);
}
return (
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
<aside className="flex w-full shrink-0 flex-col gap-5 rounded-lg border border-ctp-surface0 bg-ctp-base p-4 lg:sticky lg:top-20 lg:w-64">
<form onSubmit={applySearch} className="flex flex-col gap-2">
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
Search
</span>
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-ctp-overlay0" />
<input
className={cn(inputClass, "pl-8")}
placeholder="Filename…"
value={searchInput}
onChange={(event) => setSearchInput(event.target.value)}
/>
</div>
<div className="flex gap-2">
<Button type="submit" className="flex-1">
Search
</Button>
<Button type="button" variant="secondary" onClick={clearFilters}>
Clear
</Button>
</div>
</form>
<div className="flex flex-col gap-2">
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
Rating
</span>
<div className="flex flex-wrap gap-1.5">
{ratingFilters.map((option) => (
<button
key={option.value}
type="button"
onClick={() => toggleRating(option.value)}
className={cn(
"rounded-full border px-2.5 py-1 text-xs font-medium transition",
ratings.includes(option.value)
? option.active
: "border-ctp-surface1 text-ctp-subtext0 hover:border-ctp-surface2 hover:text-ctp-text",
)}
>
{option.label}
</button>
))}
</div>
</div>
<label className="flex flex-col gap-2">
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
Sort
</span>
<select
className={inputClass}
value={ordering}
onChange={(event) => {
setPage(1);
setOrdering(event.target.value);
}}
>
{sortOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-2">
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
Per page
</span>
<select
className={inputClass}
value={perPage}
onChange={(event) => {
setPage(1);
setPerPage(Number(event.target.value));
}}
>
{perPageOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
</aside>
<section className="min-w-0 flex-1">
<div className="mb-4 flex items-center justify-between gap-3">
<p className="text-sm text-ctp-overlay0">
{query.data
? `${query.data.count} item${query.data.count === 1 ? "" : "s"}`
: "…"}
</p>
{pageCount > 1 ? (
<p className="font-mono text-xs text-ctp-overlay0">
Page {page} / {pageCount}
</p>
) : null}
</div>
{query.isPending ? (
<div className="flex justify-center py-24">
<Spinner className="h-6 w-6" />
</div>
) : query.isError || !query.data ? (
<EmptyState
title="Could not load the library"
description="Make sure the backend is running."
/>
) : query.data.results.length === 0 ? (
<EmptyState
title="No files found"
description={
search || ratings.length
? "Try different filters."
: "Upload files or run the scan command."
}
/>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(190px,1fr))] gap-3">
{query.data.results.map((item) => (
<MediaCard key={item.md5} item={item} />
))}
</div>
)}
{pageCount > 1 ? (
<div className="mt-6 flex items-center justify-center gap-3">
<Button
variant="secondary"
disabled={page <= 1 || query.isFetching}
onClick={() => setPage((current) => Math.max(1, current - 1))}
>
Previous
</Button>
<span className="font-mono text-xs text-ctp-subtext0">
{page} / {pageCount}
</span>
<Button
variant="secondary"
disabled={page >= pageCount || query.isFetching}
onClick={() => setPage((current) => current + 1)}
>
Next
</Button>
</div>
) : null}
</section>
</div>
);
}
+195
View File
@@ -0,0 +1,195 @@
import { useQueryClient } from "@tanstack/react-query";
import { UploadCloud } from "lucide-react";
import { useRef, useState, type DragEvent } from "react";
import { Button, EmptyState } from "@/components/ui";
import { errorMessage, uploadFile } from "@/lib/api";
import { cn } from "@/lib/cn";
import { formatBytes } from "@/lib/format";
type UploadStatus = "queued" | "uploading" | "done" | "error";
interface UploadEntry {
id: string;
file: File;
progress: number;
status: UploadStatus;
error?: string;
}
export default function UploadPage() {
const queryClient = useQueryClient();
const inputRef = useRef<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false);
const [entries, setEntries] = useState<UploadEntry[]>([]);
const [busy, setBusy] = useState(false);
const pendingCount = entries.filter(
(entry) => entry.status === "queued" || entry.status === "error",
).length;
function addFiles(files: FileList | null) {
if (!files || files.length === 0) return;
const next: UploadEntry[] = Array.from(files).map((file) => ({
id: `${file.name}-${file.size}-${file.lastModified}-${Math.random()
.toString(36)
.slice(2, 8)}`,
file,
progress: 0,
status: "queued",
}));
setEntries((current) => [...current, ...next]);
}
function handleDrop(event: DragEvent<HTMLDivElement>) {
event.preventDefault();
setDragging(false);
addFiles(event.dataTransfer.files);
}
function updateEntry(id: string, patch: Partial<UploadEntry>) {
setEntries((current) =>
current.map((entry) => (entry.id === id ? { ...entry, ...patch } : entry)),
);
}
async function startUploads() {
if (busy) return;
setBusy(true);
const queue = entries.filter(
(entry) => entry.status === "queued" || entry.status === "error",
);
for (const entry of queue) {
updateEntry(entry.id, { status: "uploading", progress: 0, error: undefined });
try {
await uploadFile(entry.file, {}, (percent) =>
updateEntry(entry.id, { progress: percent }),
);
updateEntry(entry.id, { status: "done", progress: 100 });
} catch (error) {
updateEntry(entry.id, {
status: "error",
error: errorMessage(error),
});
}
}
void queryClient.invalidateQueries({ queryKey: ["files"] });
setBusy(false);
}
function clearFinished() {
setEntries((current) => current.filter((entry) => entry.status !== "done"));
}
return (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6">
<header>
<h1 className="text-lg font-semibold">Upload</h1>
<p className="mt-1 text-sm text-ctp-overlay0">
Files are written to the watched folder and indexed into the library.
</p>
</header>
<div
role="button"
tabIndex={0}
onClick={() => inputRef.current?.click()}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
inputRef.current?.click();
}
}}
onDragOver={(event) => {
event.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
className={cn(
"flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed px-6 py-14 text-center transition",
dragging
? "border-ctp-mauve bg-ctp-mauve/5"
: "border-ctp-surface1 hover:border-ctp-surface2",
)}
>
<UploadCloud className="h-8 w-8 text-ctp-overlay0" />
<p className="text-sm font-medium text-ctp-subtext1">
Drag &amp; drop files here
</p>
<p className="text-xs text-ctp-overlay0">
or click to select — PNG, JPG, GIF, APNG, MP4, WEBM
</p>
<input
ref={inputRef}
type="file"
multiple
className="hidden"
onChange={(event) => {
addFiles(event.target.files);
event.target.value = "";
}}
/>
</div>
{entries.length > 0 ? (
<section className="flex flex-col gap-3">
{entries.map((entry) => (
<div
key={entry.id}
className="rounded-lg border border-ctp-surface0 bg-ctp-base p-3"
>
<div className="flex items-center justify-between gap-3 text-sm">
<span className="truncate font-mono text-xs text-ctp-subtext1">
{entry.file.name}
</span>
<span className="shrink-0 font-mono text-xs text-ctp-overlay0">
{formatBytes(entry.file.size)}
</span>
</div>
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-ctp-surface0">
<div
className={cn(
"h-full transition-[width] duration-150",
entry.status === "error"
? "bg-ctp-red"
: entry.status === "done"
? "bg-ctp-green"
: "bg-ctp-mauve",
)}
style={{
width: `${entry.status === "done" ? 100 : entry.progress}%`,
}}
/>
</div>
<div className="mt-1.5 flex items-center justify-between text-xs">
<span className="text-ctp-overlay0">
{entry.status === "queued"
? "Queued"
: entry.status === "uploading"
? `Uploading… ${entry.progress}%`
: entry.status === "done"
? "Done"
: (entry.error ?? "Failed")}
</span>
</div>
</div>
))}
</section>
) : (
<EmptyState
title="No files selected"
description="Drop files above to queue them for upload."
/>
)}
<div className="flex items-center gap-3">
<Button onClick={() => void startUploads()} disabled={busy || pendingCount === 0}>
{busy ? "Uploading…" : `Upload ${pendingCount || ""} file${pendingCount === 1 ? "" : "s"}`.trim()}
</Button>
<Button variant="ghost" onClick={clearFinished} disabled={busy}>
Clear finished
</Button>
</div>
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
@import "tailwindcss";
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
/* Catppuccin Mocha surfaces */
--color-ctp-crust: #11111b;
--color-ctp-mantle: #181825;
--color-ctp-base: #1e1e2e;
--color-ctp-surface0: #313244;
--color-ctp-surface1: #45475a;
--color-ctp-surface2: #585b70;
/* Text tiers */
--color-ctp-text: #cdd6f4;
--color-ctp-subtext1: #bac2de;
--color-ctp-subtext0: #a6adc8;
--color-ctp-overlay1: #7f849c;
--color-ctp-overlay0: #6c7086;
/* Accents */
--color-ctp-mauve: #cba6f7;
--color-ctp-lavender: #b4befe;
--color-ctp-blue: #89b4fa;
--color-ctp-teal: #94e2d5;
--color-ctp-green: #a6e3a1;
--color-ctp-peach: #fab387;
--color-ctp-red: #f38ba8;
--color-ctp-yellow: #f9e2af;
/* Rating semantics */
--color-rating-safe: #a6e3a1;
--color-rating-questionable: #fab387;
--color-rating-explicit: #f38ba8;
}
@layer base {
html {
color-scheme: dark;
}
body {
@apply bg-ctp-crust font-sans text-ctp-text antialiased;
}
::selection {
@apply bg-ctp-mauve/40;
}
* {
scrollbar-color: var(--color-ctp-surface1) transparent;
scrollbar-width: thin;
}
}
+120
View File
@@ -0,0 +1,120 @@
import type { MediaItem } from "@/lib/types";
const TOKEN_KEY = "j621.token";
export function getToken(): string | null {
return window.localStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string | null): void {
if (token) {
window.localStorage.setItem(TOKEN_KEY, token);
} else {
window.localStorage.removeItem(TOKEN_KEY);
}
}
export class ApiError extends Error {
status: number;
data: unknown;
constructor(status: number, data: unknown) {
super(`Request failed with status ${status}`);
this.name = "ApiError";
this.status = status;
this.data = data;
}
}
export function errorMessage(error: unknown): string {
if (!(error instanceof ApiError)) {
return error instanceof Error ? error.message : "Something went wrong.";
}
const data = error.data;
if (data && typeof data === "object") {
const record = data as Record<string, unknown>;
if (typeof record.detail === "string") return record.detail;
for (const [field, value] of Object.entries(record)) {
if (Array.isArray(value) && value.length > 0) {
return `${field}: ${String(value[0])}`;
}
if (typeof value === "string") {
return `${field}: ${value}`;
}
}
}
return `Request failed (${error.status}).`;
}
interface ApiOptions extends Omit<RequestInit, "body"> {
json?: unknown;
body?: BodyInit;
}
export async function api<T>(path: string, options: ApiOptions = {}): Promise<T> {
const { json, headers, ...rest } = options;
const finalHeaders = new Headers(headers);
let body = options.body;
if (json !== undefined) {
finalHeaders.set("Content-Type", "application/json");
body = JSON.stringify(json);
}
const token = getToken();
if (token) finalHeaders.set("Authorization", `Token ${token}`);
const response = await fetch(path, { ...rest, headers: finalHeaders, body });
if (!response.ok) {
let data: unknown = null;
try {
data = await response.json();
} catch {
data = null;
}
throw new ApiError(response.status, data);
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
}
export function uploadFile(
file: File,
fields: Record<string, string> = {},
onProgress?: (percent: number) => void,
): Promise<MediaItem> {
return new Promise((resolve, reject) => {
const form = new FormData();
form.append("file", file);
for (const [key, value] of Object.entries(fields)) {
form.append(key, value);
}
const request = new XMLHttpRequest();
request.open("POST", "/api/uploads/");
const token = getToken();
if (token) request.setRequestHeader("Authorization", `Token ${token}`);
request.upload.addEventListener("progress", (event) => {
if (event.lengthComputable && onProgress) {
onProgress(Math.round((event.loaded / event.total) * 100));
}
});
request.addEventListener("load", () => {
let data: unknown = null;
try {
data = JSON.parse(request.responseText);
} catch {
data = null;
}
if (request.status >= 200 && request.status < 300) {
resolve(data as MediaItem);
} else {
reject(new ApiError(request.status, data));
}
});
request.addEventListener("error", () => {
reject(new ApiError(0, { detail: "Network error" }));
});
request.send(form);
});
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
+20
View File
@@ -0,0 +1,20 @@
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return "—";
const units = ["B", "KB", "MB", "GB", "TB"];
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
const digits = value >= 100 || unit === 0 ? 0 : 1;
return `${value.toFixed(digits)} ${units[unit]}`;
}
export function formatDate(value: string): string {
return new Date(value).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
+37
View File
@@ -0,0 +1,37 @@
export interface User {
id: number;
username: string;
email: string;
is_staff: boolean;
date_joined: string;
}
export interface MediaLocation {
id: number;
path: string;
rel_path: string;
mtime: number;
}
export type Rating = "" | "s" | "q" | "e";
export interface MediaItem {
md5: string;
size: number;
rating: Rating;
tags: string[];
notes: string;
filename: string;
extension: string;
kind: "image" | "video";
locations: MediaLocation[];
created_at: string;
updated_at: string;
}
export interface Paginated<T> {
count: number;
next: string | null;
previous: string | null;
results: T[];
}
+27
View File
@@ -0,0 +1,27 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./index.css";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 30_000,
refetchOnWindowFocus: false,
},
},
});
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</StrictMode>,
);
+61
View File
@@ -0,0 +1,61 @@
import { create } from "zustand";
import { api, getToken, setToken } from "@/lib/api";
import type { User } from "@/lib/types";
interface AuthState {
user: User | null;
ready: boolean;
login: (username: string, password: string) => Promise<void>;
register: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
loadUser: () => Promise<void>;
}
export const useAuth = create<AuthState>((set) => ({
user: null,
ready: false,
login: async (username, password) => {
const data = await api<{ token: string }>("/api/auth/token/", {
method: "POST",
json: { username, password },
});
setToken(data.token);
const user = await api<User>("/api/auth/me/");
set({ user, ready: true });
},
register: async (username, password) => {
const data = await api<{ token: string; user: User }>("/api/auth/register/", {
method: "POST",
json: { username, password },
});
setToken(data.token);
set({ user: data.user, ready: true });
},
logout: async () => {
try {
await api("/api/auth/logout/", { method: "POST" });
} catch {
// The token may already be invalid; clearing it locally is enough.
}
setToken(null);
set({ user: null, ready: true });
},
loadUser: async () => {
if (!getToken()) {
set({ user: null, ready: true });
return;
}
try {
const user = await api<User>("/api/auth/me/");
set({ user, ready: true });
} catch {
setToken(null);
set({ user: null, ready: true });
}
},
}));
+29
View File
@@ -0,0 +1,29 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
"paths": {
"@/*": ["./src/*"]
},
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+24
View File
@@ -0,0 +1,24 @@
import { fileURLToPath, URL } from "node:url";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
const backend = process.env.VITE_BACKEND ?? "http://127.0.0.1:8000";
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
server: {
proxy: {
"/api": backend,
"/media": backend,
"/admin": backend,
},
},
});