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:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user