Files
J621/frontend/src/features/library/LibraryPage.tsx
T
JakeBreath 555c25d77b 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
2026-09-17 07:56:47 -05:00

243 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}