Online browser + post detail: SPA talks to e621 directly

Backend:
- POST /api/files/lookup/ reports which MD5s are already in the library

Frontend:
- e621 client extended with post/tag/favorite types and helpers: post
  search, post detail, batch posts by id, tag autocomplete, toggle
  favorite
- /online: tag search with autocomplete, post grid with rating colors
  and in-library badges, numbered pagination, page tag cloud, blacklist
  panel and filtered counts from /users/me.json, anonymous hint
- /online/view/🆔 media viewer (sample or original, video support),
  taxonomy-colored tags by category, specs sheet, favorite/unfavorite,
  description, sources, pools and parent/children thumbnails
- Shared CollapsibleSidebar extracted from the library page; Online
  added to the nav, command palette and sidebar toggle
This commit is contained in:
2026-09-17 09:00:17 -05:00
parent b6409523e6
commit 04448bf615
14 changed files with 1273 additions and 41 deletions
+9 -1
View File
@@ -10,10 +10,12 @@ import { api } from "@/lib/api";
import { cn } from "@/lib/cn";
import type { SystemStatus } from "@/lib/types";
import { useAuth } from "@/store/auth";
import { useE621 } from "@/store/e621";
import { useUi } from "@/store/ui";
const navItems = [
{ to: "/", label: "Library" },
{ to: "/online", label: "Online" },
{ to: "/upload", label: "Upload" },
];
@@ -21,9 +23,14 @@ export function AppShell() {
const user = useAuth((state) => state.user);
const logout = useAuth((state) => state.logout);
const toggleSidebar = useUi((state) => state.toggleSidebar);
const loadE621 = useE621((state) => state.load);
const location = useLocation();
const [paletteOpen, setPaletteOpen] = useState(false);
useEffect(() => {
void loadE621();
}, [loadE621]);
const statusQuery = useQuery({
queryKey: ["status"],
queryFn: () => api<SystemStatus>("/api/status/"),
@@ -53,7 +60,8 @@ export function AppShell() {
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
const showSidebarToggle = location.pathname === "/";
const showSidebarToggle =
location.pathname === "/" || location.pathname === "/online";
return (
<div className="flex min-h-screen flex-col">
@@ -0,0 +1,47 @@
import type { ReactNode } from "react";
import { X } from "lucide-react";
import { cn } from "@/lib/cn";
import { useUi } from "@/store/ui";
export function CollapsibleSidebar({
title = "Filters",
children,
}: {
title?: string;
children: ReactNode;
}) {
const sidebarOpen = useUi((state) => state.sidebarOpen);
const setSidebarOpen = useUi((state) => state.setSidebarOpen);
if (!sidebarOpen) return null;
return (
<>
<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">{title}</span>
<button
type="button"
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"
>
<X className="h-4 w-4" />
</button>
</div>
{children}
</aside>
</>
);
}
@@ -1,5 +1,6 @@
import {
FolderOpen,
Globe,
LogIn,
LogOut,
Menu,
@@ -56,6 +57,12 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
icon: Upload,
run: () => navigate("/upload"),
},
{
id: "online",
label: "Go to Online browser",
icon: Globe,
run: () => navigate("/online"),
},
{
id: "filters",
label: "Toggle filters",
+49
View File
@@ -0,0 +1,49 @@
import { cn } from "@/lib/cn";
const TAG_STYLES: Record<string, string> = {
general:
"border-ctp-blue/30 bg-ctp-blue/10 text-ctp-blue hover:bg-ctp-blue/20",
artist:
"border-ctp-yellow/30 bg-ctp-yellow/10 text-ctp-yellow hover:bg-ctp-yellow/20",
copyright:
"border-ctp-mauve/30 bg-ctp-mauve/10 text-ctp-mauve hover:bg-ctp-mauve/20",
character:
"border-ctp-green/30 bg-ctp-green/10 text-ctp-green hover:bg-ctp-green/20",
species:
"border-ctp-peach/30 bg-ctp-peach/10 text-ctp-peach hover:bg-ctp-peach/20",
meta: "border-ctp-subtext0/30 bg-ctp-subtext0/10 text-ctp-subtext0 hover:bg-ctp-subtext0/20",
lore: "border-ctp-teal/30 bg-ctp-teal/10 text-ctp-teal hover:bg-ctp-teal/20",
invalid:
"border-ctp-red/30 bg-ctp-red/10 text-ctp-red hover:bg-ctp-red/20",
contributor:
"border-ctp-lavender/30 bg-ctp-lavender/10 text-ctp-lavender hover:bg-ctp-lavender/20",
unknown:
"border-ctp-surface1 text-ctp-subtext0 hover:bg-ctp-surface0 hover:text-ctp-text",
};
export function TagChip({
tag,
category,
count,
onClick,
}: {
tag: string;
category?: string;
count?: number;
onClick?: () => void;
}) {
const style = TAG_STYLES[category ?? "unknown"] ?? TAG_STYLES.unknown;
return (
<button
type="button"
onClick={onClick}
className={cn(
"rounded border px-1.5 py-0.5 font-mono text-[11px] transition",
style,
)}
>
{tag}
{count !== undefined ? ` (${count})` : ""}
</button>
);
}