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