commit 555c25d77b348da50fe36dbbfb29f3ad81292449 Author: JakeBreath Date: Thu Sep 17 07:56:47 2026 -0500 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..11b7085 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7492c58 --- /dev/null +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..28976f6 --- /dev/null +++ b/README.md @@ -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. diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..c1517a7 --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/apps/__init__.py b/backend/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/accounts/__init__.py b/backend/apps/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/accounts/admin.py b/backend/apps/accounts/admin.py new file mode 100644 index 0000000..54d281b --- /dev/null +++ b/backend/apps/accounts/admin.py @@ -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 diff --git a/backend/apps/accounts/apps.py b/backend/apps/accounts/apps.py new file mode 100644 index 0000000..e3d765b --- /dev/null +++ b/backend/apps/accounts/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + name = "apps.accounts" diff --git a/backend/apps/accounts/migrations/0001_initial.py b/backend/apps/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..d5a250a --- /dev/null +++ b/backend/apps/accounts/migrations/0001_initial.py @@ -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()), + ], + ), + ] diff --git a/backend/apps/accounts/migrations/__init__.py b/backend/apps/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/accounts/models.py b/backend/apps/accounts/models.py new file mode 100644 index 0000000..5e8894d --- /dev/null +++ b/backend/apps/accounts/models.py @@ -0,0 +1,5 @@ +from django.contrib.auth.models import AbstractUser + + +class User(AbstractUser): + """Project user. Extended later with avatars and e621 credentials.""" diff --git a/backend/apps/accounts/serializers.py b/backend/apps/accounts/serializers.py new file mode 100644 index 0000000..2ffe1c2 --- /dev/null +++ b/backend/apps/accounts/serializers.py @@ -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) diff --git a/backend/apps/accounts/tests.py b/backend/apps/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/apps/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/apps/accounts/urls.py b/backend/apps/accounts/urls.py new file mode 100644 index 0000000..2d538f6 --- /dev/null +++ b/backend/apps/accounts/urls.py @@ -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"), +] diff --git a/backend/apps/accounts/views.py b/backend/apps/accounts/views.py new file mode 100644 index 0000000..0c284db --- /dev/null +++ b/backend/apps/accounts/views.py @@ -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) diff --git a/backend/apps/library/__init__.py b/backend/apps/library/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/library/admin.py b/backend/apps/library/admin.py new file mode 100644 index 0000000..5bd5afd --- /dev/null +++ b/backend/apps/library/admin.py @@ -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") diff --git a/backend/apps/library/apps.py b/backend/apps/library/apps.py new file mode 100644 index 0000000..1a1d68e --- /dev/null +++ b/backend/apps/library/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class LibraryConfig(AppConfig): + name = "apps.library" diff --git a/backend/apps/library/management/__init__.py b/backend/apps/library/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/library/management/commands/__init__.py b/backend/apps/library/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/library/management/commands/scan_files.py b/backend/apps/library/management/commands/scan_files.py new file mode 100644 index 0000000..4b2460c --- /dev/null +++ b/backend/apps/library/management/commands/scan_files.py @@ -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." + ) + ) diff --git a/backend/apps/library/migrations/0001_initial.py b/backend/apps/library/migrations/0001_initial.py new file mode 100644 index 0000000..7a1a9a5 --- /dev/null +++ b/backend/apps/library/migrations/0001_initial.py @@ -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')}, + }, + ), + ] diff --git a/backend/apps/library/migrations/__init__.py b/backend/apps/library/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/library/models.py b/backend/apps/library/models.py new file mode 100644 index 0000000..f103d59 --- /dev/null +++ b/backend/apps/library/models.py @@ -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 diff --git a/backend/apps/library/serializers.py b/backend/apps/library/serializers.py new file mode 100644 index 0000000..2727573 --- /dev/null +++ b/backend/apps/library/serializers.py @@ -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" diff --git a/backend/apps/library/services.py b/backend/apps/library/services.py new file mode 100644 index 0000000..84ffd05 --- /dev/null +++ b/backend/apps/library/services.py @@ -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 diff --git a/backend/apps/library/tests.py b/backend/apps/library/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/apps/library/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/apps/library/urls.py b/backend/apps/library/urls.py new file mode 100644 index 0000000..7bcefa3 --- /dev/null +++ b/backend/apps/library/urls.py @@ -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"), +] diff --git a/backend/apps/library/views.py b/backend/apps/library/views.py new file mode 100644 index 0000000..e9922f6 --- /dev/null +++ b/backend/apps/library/views.py @@ -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, + ) diff --git a/backend/config/__init__.py b/backend/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/config/asgi.py b/backend/config/asgi.py new file mode 100644 index 0000000..33a47a1 --- /dev/null +++ b/backend/config/asgi.py @@ -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() diff --git a/backend/config/pagination.py b/backend/config/pagination.py new file mode 100644 index 0000000..4eb462c --- /dev/null +++ b/backend/config/pagination.py @@ -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" diff --git a/backend/config/settings.py b/backend/config/settings.py new file mode 100644 index 0000000..087c627 --- /dev/null +++ b/backend/config/settings.py @@ -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", + }, +} diff --git a/backend/config/urls.py b/backend/config/urls.py new file mode 100644 index 0000000..93e7b34 --- /dev/null +++ b/backend/config/urls.py @@ -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) diff --git a/backend/config/wsgi.py b/backend/config/wsgi.py new file mode 100644 index 0000000..1b4cc01 --- /dev/null +++ b/backend/config/wsgi.py @@ -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() diff --git a/backend/manage.py b/backend/manage.py new file mode 100755 index 0000000..8e7ac79 --- /dev/null +++ b/backend/manage.py @@ -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() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..cc49060 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,5 @@ +Django>=6.0,<6.2 +djangorestframework>=3.17 +django-filter>=25.1 +Pillow>=11.0 +python-dotenv>=1.0 diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -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? diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/frontend/.oxlintrc.json @@ -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 }] + } +} diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md new file mode 100644 index 0000000..8cc3d8e --- /dev/null +++ b/frontend/DESIGN.md @@ -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. \ No newline at end of file diff --git a/frontend/design.md_j621_frontend_spec.md b/frontend/design.md_j621_frontend_spec.md new file mode 100644 index 0000000..adc21be --- /dev/null +++ b/frontend/design.md_j621_frontend_spec.md @@ -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: `#` · `` · ` ▲ ★ 💬`. + +### 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 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..7352d78 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,20 @@ + + + + + + + + J621 + + + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..50af4db --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2166 @@ +{ + "name": "vite-scaffold", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vite-scaffold", + "version": "0.0.0", + "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" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz", + "integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.83.0.tgz", + "integrity": "sha512-0yGY24EwsLk5YDe6F+VkmZyRHSwJDALa3nIrPpq7FXmp2lV2d0TzvBCGeZk+wgiULRGr5blhyr4QMp5KCXJUqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.83.0.tgz", + "integrity": "sha512-hHfJ0vc17A4iUjH5p9BsTUPYbYRNxGpvD2lbu1aBRk54bzNIx9o5TtYF39QPZcV95DagZd+4DEAw2RH3G2ZsMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.83.0.tgz", + "integrity": "sha512-hsOjYjszLb/3zym/TkzUMPAoQlTJcuzSyEPOAyA+skXJIX9M0o+4JfOtqopX/Vf4hSLrJ98j0nvFo23gzk8auQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.83.0.tgz", + "integrity": "sha512-mjh5oH2EA+wl5yRJYT9K9G61O2zFlpuv+yf2JwZOi0+dq2FnTUtm1h8i+5Ik0fXPWIu/k84I1psZR9aQsLAnyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.83.0.tgz", + "integrity": "sha512-fNHr64/YaO8YssuoDVC8+F4Uk5enR86q5uxfHkQrjAPs1dbAILOrD2uaud+J7MO8Fx774g44ERLD0IGIvZE48w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.83.0.tgz", + "integrity": "sha512-Qpwy3zzAwMj+8/lyYItHmkSMwbkprFNWTK7jPYDOxSyxEhaSLOWYUTCMkjF334J8/WD0nznCCsoBbIH6hpsuIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.83.0.tgz", + "integrity": "sha512-s+BirYLFq7JL2k9sP0XI3ZXJ9dYvJ8sX3jLCLoag7tt+zrSHpZxP0jqznfL+Gdgwu7ay0dYgGYJXrQvq3iWloA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.83.0.tgz", + "integrity": "sha512-7lihXt3vKr+GIyapNbHrnFHm/biiW30le6Zv/DExbAFPF6YwCQXVFlONPFehxs0CpGO4CBfYPM9rdDT+XMoIlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.83.0.tgz", + "integrity": "sha512-q63JalLYVkZiZvls1z3PPUnpmQluOMXp0khqQMznCeAPLGydfNY8JhvuA4WlK57JfrvikU8wB5lPVveqpIXvew==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.83.0.tgz", + "integrity": "sha512-krQmDF+dRbxvdqVPV88ZuOoPPu8X5BuqDA8Hd+qcS4YMRQCb+nexA57DazgGsc/rGdKBe3QmV0mnv0bdpW/p5g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.83.0.tgz", + "integrity": "sha512-MmOl8Y6txEAXZU1RG8Rr264jQ6D7VPmqFsU/45x/FeWsGe32hklTqGrLE6UxHzp5Rjt0wP+20tY8YXKgSFB3mw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.83.0.tgz", + "integrity": "sha512-u1rMymh0W3JZkq370kzQsYPULGWqhE09pZRqnZvUSoYaI9pVO5yVX+iYIslmWuEgwuzH9YAaOsScJiobWCHoOw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.83.0.tgz", + "integrity": "sha512-y0zK3HNwGysu7rqtE+BQG/d0bx5gh/KwlOtghN8oWeK1KcWzeaLqtZrbm8owqdma1lFyrce/hTO5ismuNu+INQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.83.0.tgz", + "integrity": "sha512-rS5gM0NgD7ngmuJmbIehsidtrOwKkLFwCQbKEeb9KuyQrrWNq5Zkn0uV6AYdXOMJ0grrWEiLwBuvMxt8w5vsNw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.83.0.tgz", + "integrity": "sha512-W2IH4EtpcPaWcvNGCA95YoDg4vxqE/ZiPCi3arrxEEpsK7+JQN9WYwrlYFx9pcdP6KPXqRqkv3zdQPHcx7b6YQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.83.0.tgz", + "integrity": "sha512-6LyKkUyoajssTPLlZmDbZIbu4IZ5B4bGuRUnBgCGpEvHP3FQMaYITncHA/unPUo7q+Z+pIu2HhdkQ+8d1SG7iA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.83.0.tgz", + "integrity": "sha512-Uz/fObEtF0jmNJQJ8CGRBKfefYstS0/wjD3s6IGzP8nUwsJykHQJBiN3npHwKiGRGn/vvBEgNr4B3cCzmmatvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.83.0.tgz", + "integrity": "sha512-u7XcvPW6Bk58tY5iWs2ESb0vJjoE/kuSpHxopbwp/p3ZtWVQXZ6wor5w3ssVTHOqd/v8b+QdhSFWQ4grEUNWpA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.83.0.tgz", + "integrity": "sha512-LZRubd7ph13QmAg4fFecTYVZkiYbROR2Htaxh/ufWRkDhPOm2wrwaEYR89e0YpPFD3dqBrPoxS7myBw5hmYA7Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.9.tgz", + "integrity": "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.9.tgz", + "integrity": "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.9.tgz", + "integrity": "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.9.tgz", + "integrity": "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.9.tgz", + "integrity": "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.9.tgz", + "integrity": "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.9.tgz", + "integrity": "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.9.tgz", + "integrity": "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.9.tgz", + "integrity": "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.9.tgz", + "integrity": "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.9.tgz", + "integrity": "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.9.tgz", + "integrity": "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.9.tgz", + "integrity": "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.9.tgz", + "integrity": "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.9.tgz", + "integrity": "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.103.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.103.1.tgz", + "integrity": "sha512-rms8HqTGp6zA00dM+cUQ2eBcgzNJefuu5CAMB37i/6MiGT1zulPOytCFu2a0qjLqVR2n1jENPj9woqFQNuCzWA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.103.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.103.1.tgz", + "integrity": "sha512-rmAPPApNEK17VXRJhtE1rja53n23VV5w30C0saCgJhhX+EpdUnVqMGV/s5nnzV43X75KTHKzuoiuxSzGTBIkag==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.103.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/node": { + "version": "24.13.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.5.tgz", + "integrity": "sha512-TXyindR+lBr22aJIdMQzCFHPHR6cR4js838mRDCSz5hOKWZvZwsXSSiXDmjRj4iJmgl+sR9O+1mkoVBSMadNug==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.25.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.25.1.tgz", + "integrity": "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lucide-react": { + "version": "1.47.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.47.0.tgz", + "integrity": "sha512-o8C23aXpNQypRY73W7fW02EyvWJinEMXgKeGjFoKym0zj3Q73hD97A1IQps1g8C14HTGsOpZL0Am+xjfgFZAbg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oxlint": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.83.0.tgz", + "integrity": "sha512-cyDzSzaw3uzP0TeCeq3lLRPPoaUxkbB4ZOXj+kn+5r+BX9V+4bNVGk9lxer+WrgcpebH4JxLlJ3KQjveVztOLQ==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.83.0", + "@oxlint/binding-android-arm64": "1.83.0", + "@oxlint/binding-darwin-arm64": "1.83.0", + "@oxlint/binding-darwin-x64": "1.83.0", + "@oxlint/binding-freebsd-x64": "1.83.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.83.0", + "@oxlint/binding-linux-arm-musleabihf": "1.83.0", + "@oxlint/binding-linux-arm64-gnu": "1.83.0", + "@oxlint/binding-linux-arm64-musl": "1.83.0", + "@oxlint/binding-linux-ppc64-gnu": "1.83.0", + "@oxlint/binding-linux-riscv64-gnu": "1.83.0", + "@oxlint/binding-linux-riscv64-musl": "1.83.0", + "@oxlint/binding-linux-s390x-gnu": "1.83.0", + "@oxlint/binding-linux-x64-gnu": "1.83.0", + "@oxlint/binding-linux-x64-musl": "1.83.0", + "@oxlint/binding-openharmony-arm64": "1.83.0", + "@oxlint/binding-win32-arm64-msvc": "1.83.0", + "@oxlint/binding-win32-ia32-msvc": "1.83.0", + "@oxlint/binding-win32-x64-msvc": "1.83.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-router": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", + "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz", + "integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/rolldown": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz", + "integrity": "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.150.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.9", + "@rolldown/binding-android-arm64": "1.2.9", + "@rolldown/binding-darwin-arm64": "1.2.9", + "@rolldown/binding-darwin-x64": "1.2.9", + "@rolldown/binding-freebsd-x64": "1.2.9", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.9", + "@rolldown/binding-linux-arm64-gnu": "1.2.9", + "@rolldown/binding-linux-arm64-musl": "1.2.9", + "@rolldown/binding-linux-ppc64-gnu": "1.2.9", + "@rolldown/binding-linux-s390x-gnu": "1.2.9", + "@rolldown/binding-linux-x64-gnu": "1.2.9", + "@rolldown/binding-linux-x64-musl": "1.2.9", + "@rolldown/binding-openharmony-arm64": "1.2.9", + "@rolldown/binding-win32-arm64-msvc": "1.2.9", + "@rolldown/binding-win32-x64-msvc": "1.2.9" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwind-merge": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.7.0.tgz", + "integrity": "sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/zustand": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz", + "integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..c0262c6 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..b2b8326 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,12 @@ + + + J + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..2fcffa4 --- /dev/null +++ b/frontend/src/App.tsx @@ -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 ; + } + 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 ; + } + + return ( + + }> + } /> + } /> + + + + } + /> + } /> + + } /> + } /> + + ); +} diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx new file mode 100644 index 0000000..2f1bd1f --- /dev/null +++ b/frontend/src/components/AppShell.tsx @@ -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 ( +
+
+
+ + + J621 + + + Mocha Archive Terminal + + + + + +
+ {user ? ( + <> +
+ + {user.username.slice(0, 1).toUpperCase()} + + + {user.username} + +
+ + + ) : ( + + Log in + + )} +
+
+
+ +
+ +
+
+ ); +} diff --git a/frontend/src/components/MediaCard.tsx b/frontend/src/components/MediaCard.tsx new file mode 100644 index 0000000..4a90416 --- /dev/null +++ b/frontend/src/components/MediaCard.tsx @@ -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 = { + 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 = { + 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 ( + +
+ {item.filename} + {item.rating ? ( + + {ratingLabels[item.rating]} + + ) : null} + + {item.extension} + +
+

+ {formatBytes(item.size)} +

+
+
+
+

+ {item.filename} +

+
+ + ); +} diff --git a/frontend/src/components/ui.tsx b/frontend/src/components/ui.tsx new file mode 100644 index 0000000..c415815 --- /dev/null +++ b/frontend/src/components/ui.tsx @@ -0,0 +1,90 @@ +import type { ButtonHTMLAttributes, ReactNode } from "react"; + +import { cn } from "@/lib/cn"; + +type ButtonVariant = "primary" | "secondary" | "ghost" | "danger"; + +const buttonVariants: Record = { + 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 { + variant?: ButtonVariant; +} + +export function Button({ variant = "primary", className, ...props }: ButtonProps) { + return ( + + +

+ No account?{" "} + + Register + +

+ + + ); +} diff --git a/frontend/src/features/auth/RegisterPage.tsx b/frontend/src/features/auth/RegisterPage.tsx new file mode 100644 index 0000000..95c94cd --- /dev/null +++ b/frontend/src/features/auth/RegisterPage.tsx @@ -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(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 ( +
+
+
+ + J621 + +

Create account

+
+
+ + setUsername(event.target.value)} + autoComplete="username" + autoFocus + required + /> + + + setPassword(event.target.value)} + autoComplete="new-password" + required + /> + + + setConfirm(event.target.value)} + autoComplete="new-password" + required + /> + + {error ? ( +

+ {error} +

+ ) : null} + +
+

+ Already have an account?{" "} + + Log in + +

+
+
+ ); +} diff --git a/frontend/src/features/library/FileDetailPage.tsx b/frontend/src/features/library/FileDetailPage.tsx new file mode 100644 index 0000000..47c2e5b --- /dev/null +++ b/frontend/src/features/library/FileDetailPage.tsx @@ -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(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(`/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 ( +
+

Custom data

+ +
+
+ {ratingChoices.map((choice) => ( + + ))} +
+ + setTags(event.target.value)} + /> + +