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:
+30
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
|
||||
|
||||
class User(AbstractUser):
|
||||
"""Project user. Extended later with avatars and e621 credentials."""
|
||||
@@ -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)
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
Executable
+22
@@ -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()
|
||||
@@ -0,0 +1,5 @@
|
||||
Django>=6.0,<6.2
|
||||
djangorestframework>=3.17
|
||||
django-filter>=25.1
|
||||
Pillow>=11.0
|
||||
python-dotenv>=1.0
|
||||
@@ -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?
|
||||
@@ -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 }]
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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>
|
||||
Generated
+2166
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user