Shell polish: storage line, status pill, footer strip, sidebar
Backend: - apps.core with GET /api/status/ (env, git hash, OS, watched folder, worker counts) and a TimingMiddleware adding X-Server-Time-Ms - status endpoint reports its own server-side assembly time Frontend: - top bar shows the storage line (watched folder) and a status pill with ENV, git hash, host OS, server time (e621 time once it exists) - fixed 32px footer strip: storage path, active workers, build version - collapsible filter sidebar: closed by default, Ctrl/Cmd+B toggle, overlay drawer with backdrop blur under 1024px - rating filters, per-page and sidebar state persist in localStorage
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowLeft, Download } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
|
||||
import { Button, EmptyState, Spinner, inputClass } from "@/components/ui";
|
||||
@@ -38,12 +38,6 @@ function CustomDataCard({ item }: { item: MediaItem }) {
|
||||
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}/`, {
|
||||
@@ -219,7 +213,7 @@ export default function FileDetailPage() {
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<CustomDataCard item={item} />
|
||||
<CustomDataCard key={item.updated_at} item={item} />
|
||||
|
||||
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
|
||||
<h2 className="text-sm font-semibold text-ctp-subtext1">
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Search } from "lucide-react";
|
||||
import { Search, X } 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 { useUi } from "@/store/ui";
|
||||
import type { MediaItem, Paginated } from "@/lib/types";
|
||||
|
||||
const sortOptions = [
|
||||
@@ -40,11 +41,16 @@ const ratingFilters = [
|
||||
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 sidebarOpen = useUi((state) => state.sidebarOpen);
|
||||
const setSidebarOpen = useUi((state) => state.setSidebarOpen);
|
||||
const ratings = useUi((state) => state.ratings);
|
||||
const setRatings = useUi((state) => state.setRatings);
|
||||
const perPage = useUi((state) => state.perPage);
|
||||
const setPerPage = useUi((state) => state.setPerPage);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["files", { search, ratings, ordering, perPage, page }],
|
||||
queryFn: () => {
|
||||
@@ -71,10 +77,10 @@ export default function LibraryPage() {
|
||||
|
||||
function toggleRating(value: string) {
|
||||
setPage(1);
|
||||
setRatings((current) =>
|
||||
current.includes(value)
|
||||
? current.filter((rating) => rating !== value)
|
||||
: [...current, value],
|
||||
setRatings(
|
||||
ratings.includes(value)
|
||||
? ratings.filter((rating) => rating !== value)
|
||||
: [...ratings, value],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -85,95 +91,128 @@ export default function LibraryPage() {
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
const filterPanel = (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
|
||||
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) => (
|
||||
{sidebarOpen ? (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-ctp-crust/60 backdrop-blur-sm lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<aside
|
||||
className={cn(
|
||||
"fixed inset-y-0 left-0 z-50 flex w-72 flex-col gap-5 overflow-y-auto border-r border-ctp-surface0 bg-ctp-base p-4",
|
||||
"lg:sticky lg:inset-y-auto lg:top-20 lg:z-auto lg:w-64 lg:shrink-0 lg:overflow-visible lg:rounded-lg lg:border",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-ctp-subtext1">
|
||||
Filters
|
||||
</span>
|
||||
<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",
|
||||
)}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
title="Close filters"
|
||||
className="rounded-md p-1 text-ctp-overlay0 transition hover:bg-ctp-surface0 hover:text-ctp-text lg:hidden"
|
||||
>
|
||||
{option.label}
|
||||
<X className="h-4 w-4" />
|
||||
</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>
|
||||
</div>
|
||||
{filterPanel}
|
||||
</aside>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<section className="min-w-0 flex-1">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
|
||||
Reference in New Issue
Block a user