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
@@ -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>
);
}