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:
2026-09-17 08:19:27 -05:00
parent 0ff6232ba5
commit 3aa42f3703
14 changed files with 401 additions and 110 deletions
+58 -12
View File
@@ -1,9 +1,15 @@
import { LogOut } from "lucide-react";
import { Link, NavLink, Outlet } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { LogOut, Menu } from "lucide-react";
import { useEffect } from "react";
import { Link, NavLink, Outlet, useLocation } from "react-router-dom";
import { StatusFooter } from "@/components/StatusFooter";
import { StatusPill } from "@/components/StatusPill";
import { api } from "@/lib/api";
import { cn } from "@/lib/cn";
import { APP_VERSION } from "@/lib/version";
import type { SystemStatus } from "@/lib/types";
import { useAuth } from "@/store/auth";
import { useUi } from "@/store/ui";
const navItems = [
{ to: "/", label: "Library" },
@@ -13,17 +19,60 @@ const navItems = [
export function AppShell() {
const user = useAuth((state) => state.user);
const logout = useAuth((state) => state.logout);
const toggleSidebar = useUi((state) => state.toggleSidebar);
const location = useLocation();
const statusQuery = useQuery({
queryKey: ["status"],
queryFn: () => api<SystemStatus>("/api/status/"),
refetchInterval: 30_000,
staleTime: 15_000,
});
const status = statusQuery.data;
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "b") {
event.preventDefault();
toggleSidebar();
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
const showSidebarToggle = location.pathname === "/";
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">
<div className="mx-auto flex h-14 w-full max-w-[1600px] items-center gap-4 px-4">
<Link to="/" className="flex shrink-0 items-center">
<span className="rounded bg-ctp-mauve px-2 py-1 font-mono text-xs font-bold tracking-wide text-ctp-crust">
J621
</span>
</Link>
{status ? (
<span
className="hidden max-w-[240px] truncate font-mono text-[11px] text-ctp-overlay0 md:block"
title={status.watched_folder}
>
{status.watched_folder}
</span>
) : null}
{showSidebarToggle ? (
<button
type="button"
onClick={toggleSidebar}
title="Toggle filters (Ctrl+B)"
className="rounded-md p-1.5 text-ctp-overlay0 transition hover:bg-ctp-surface0 hover:text-ctp-text"
>
<Menu className="h-4 w-4" />
</button>
) : null}
<nav className="flex items-center gap-1">
{navItems.map((item) => (
<NavLink
@@ -45,12 +94,7 @@ export function AppShell() {
</nav>
<div className="ml-auto flex items-center gap-3">
<span
className="hidden font-mono text-[11px] text-ctp-overlay0 lg:block"
title="Running build"
>
{APP_VERSION}
</span>
<StatusPill status={status} />
{user ? (
<>
<div className="flex items-center gap-2">
@@ -82,9 +126,11 @@ export function AppShell() {
</div>
</header>
<main className="mx-auto w-full max-w-[1600px] flex-1 px-4 py-6">
<main className="mx-auto w-full max-w-[1600px] flex-1 px-4 pb-14 pt-6">
<Outlet />
</main>
<StatusFooter status={status} />
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { Folder, Zap } from "lucide-react";
import type { SystemStatus } from "@/lib/types";
import { APP_VERSION } from "@/lib/version";
export function StatusFooter({ status }: { status?: SystemStatus }) {
return (
<footer className="fixed inset-x-0 bottom-0 z-30 border-t border-ctp-surface0 bg-ctp-crust">
<div className="mx-auto flex h-8 w-full max-w-[1600px] items-center gap-4 px-4 font-mono text-[11px] text-ctp-overlay0">
<span
className="flex min-w-0 items-center gap-1.5"
title={status?.watched_folder ?? ""}
>
<Folder className="h-3 w-3 shrink-0" />
<span className="hidden shrink-0 sm:inline">Local Storage:</span>
<span className="truncate">{status?.watched_folder ?? "…"}</span>
</span>
<span
className="hidden shrink-0 items-center gap-1.5 md:flex"
title="Active background jobs"
>
<Zap className="h-3 w-3" />
<span>
Active Workers:{" "}
{status
? `${status.workers.active} running | ${status.workers.queued} pending`
: "…"}
</span>
</span>
<span className="ml-auto shrink-0" title="Running build">
{status?.version ?? APP_VERSION}
</span>
</div>
</footer>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { Laptop, Monitor, Terminal, type LucideIcon } from "lucide-react";
import { cn } from "@/lib/cn";
import type { SystemStatus } from "@/lib/types";
const osIcons: Record<string, LucideIcon> = {
linux: Terminal,
apple: Laptop,
windows: Monitor,
};
function formatMs(value: number): string {
return value >= 100 ? `${Math.round(value)}ms` : `${value.toFixed(1)}ms`;
}
export function StatusPill({ status }: { status?: SystemStatus }) {
if (!status) return null;
const OsIcon = osIcons[status.os.icon] ?? Monitor;
return (
<span
className="hidden select-none items-center gap-1.5 rounded-full border border-ctp-surface0 bg-ctp-mantle px-2.5 py-1 font-mono text-[11px] text-ctp-overlay1 lg:flex"
title={`${status.os.name} ${status.os.release} · built from ${status.git_hash}`}
>
<span
className={cn(
"font-medium",
status.env === "prod" ? "text-ctp-teal" : "text-ctp-yellow",
)}
>
ENV: {status.env.toUpperCase()}
</span>
<span className="text-ctp-surface2">·</span>
<span>{status.git_hash}</span>
<span className="text-ctp-surface2">·</span>
<OsIcon className="h-3 w-3" />
<span>{status.os.name.toUpperCase()}</span>
<span className="text-ctp-surface2">·</span>
<span>{formatMs(status.server_time_ms)}</span>
{status.e621_time_ms !== null ? (
<>
<span className="text-ctp-surface2">·</span>
<span>e621: {formatMs(status.e621_time_ms)}</span>
</>
) : null}
</span>
);
}
@@ -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">
+129 -90
View File
@@ -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">
+19
View File
@@ -35,3 +35,22 @@ export interface Paginated<T> {
previous: string | null;
results: T[];
}
export interface SystemStatus {
app: string;
env: "dev" | "prod";
git_hash: string;
version: string;
os: {
name: string;
release: string;
icon: "linux" | "apple" | "windows" | "unknown";
};
watched_folder: string;
e621_time_ms: number | null;
workers: {
active: number;
queued: number;
};
server_time_ms: number;
}
+29
View File
@@ -0,0 +1,29 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface UiState {
sidebarOpen: boolean;
setSidebarOpen: (open: boolean) => void;
toggleSidebar: () => void;
ratings: string[];
setRatings: (ratings: string[]) => void;
perPage: number;
setPerPage: (perPage: number) => void;
}
export const useUi = create<UiState>()(
persist(
(set) => ({
sidebarOpen: false,
setSidebarOpen: (sidebarOpen) => set({ sidebarOpen }),
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
ratings: [],
setRatings: (ratings) => set({ ratings }),
perPage: 48,
setPerPage: (perPage) => set({ perPage }),
}),
{ name: "j621.ui" },
),
);