M0: scaffold SPA + API monorepo

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

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