- Files page uses a wide layout (route handle layout:"wide"): no max width, full-height directory tree docked next to the sidebar, resizable (200-520px, remembered, double-click resets) and collapsible; mobile keeps the tree in a drawer. - GET /files/preview (admin console session only, same path guards as download): text/NFO with encoding detection (BOM/UTF-16/UTF-8/GB18030/Big5), subtitles first 200 cues (SRT/VTT/ASS/SSA, PGS timings), .torrent file list/trackers (passkey masked)/infohash, image dimensions. Reads at most 1 MiB of text; .torrent capped at 4 MiB. - Download also allows text files, .torrent and avif/gif. - Research notes in docs/design/file-preview-research.md. Co-Authored-By: Claude Opus 5.5 <[email protected]>
262 lines
11 KiB
TypeScript
262 lines
11 KiB
TypeScript
import type { SessionInfo } from "@umt/contracts";
|
|
import { ChevronLeft, ChevronRight, Clapperboard, LoaderCircle, Menu } from "lucide-react";
|
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
import { Link, Outlet, useLocation, useMatches } from "react-router";
|
|
import { useSession } from "~/api/auth.ts";
|
|
import { AgentDrawer, AgentDrawerTrigger } from "~/components/agent/agent-drawer.tsx";
|
|
import { AccountMenu } from "~/components/console/account-menu.tsx";
|
|
import { AgentLauncher, AgentNavEntry } from "~/components/console/agent-launcher.tsx";
|
|
import { ErrorState } from "~/components/console/empty-state.tsx";
|
|
import { InboxBell } from "~/components/console/inbox-bell.tsx";
|
|
import { findNavItem, NAV_GROUPS, type NavGroup, pageTitle } from "~/components/console/nav.ts";
|
|
import PrismLeak from "~/components/prism-leak.tsx";
|
|
import { CommandPalette } from "~/components/search/command-palette.tsx";
|
|
import { SearchTrigger } from "~/components/search/search-trigger.tsx";
|
|
import { ThemeToggle } from "~/components/theme-toggle.tsx";
|
|
import { Button } from "~/components/ui/button.tsx";
|
|
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "~/components/ui/sheet.tsx";
|
|
import { redirectToLogin } from "~/lib/api.ts";
|
|
import { cn } from "~/lib/utils.ts";
|
|
|
|
const PORTAL_NAME = "媒体自动化";
|
|
|
|
// ─── 展开态导航:选中项由一枚玻璃滑块(seg-thumb)滑到激活链接背后 ───
|
|
function ExpandedNav({ groups, onNavigate }: { groups: NavGroup[]; onNavigate?: () => void }) {
|
|
const location = useLocation();
|
|
const activeTo = findNavItem(location.pathname)?.to;
|
|
const navRef = useRef<HTMLElement>(null);
|
|
const [pill, setPill] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
|
|
|
|
const measure = useCallback(() => {
|
|
const nav = navRef.current;
|
|
if (!nav) return;
|
|
const active = nav.querySelector<HTMLElement>('[aria-current="page"]');
|
|
if (!active) {
|
|
setPill(null);
|
|
return;
|
|
}
|
|
const navRect = nav.getBoundingClientRect();
|
|
const rect = active.getBoundingClientRect();
|
|
setPill({ x: rect.left - navRect.left, y: rect.top - navRect.top + nav.scrollTop, w: rect.width, h: rect.height });
|
|
}, []);
|
|
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: 路由变化时重新量测
|
|
useLayoutEffect(() => {
|
|
measure();
|
|
}, [measure, location.pathname]);
|
|
|
|
useEffect(() => {
|
|
window.addEventListener("resize", measure);
|
|
return () => window.removeEventListener("resize", measure);
|
|
}, [measure]);
|
|
|
|
return (
|
|
<nav ref={navRef} className="relative flex-1 space-y-4 overflow-y-auto px-2.5 py-4">
|
|
{pill ? (
|
|
<span
|
|
aria-hidden
|
|
className="tab-slider seg-thumb pointer-events-none absolute top-0 left-0 -z-[1] rounded-lg"
|
|
style={{ transform: `translate(${pill.x}px, ${pill.y}px)`, width: pill.w, height: pill.h }}
|
|
/>
|
|
) : null}
|
|
<AgentNavEntry onNavigate={onNavigate} />
|
|
{groups.map((group) => (
|
|
<div key={group.title}>
|
|
<p className="mb-1 px-2 text-micro font-semibold tracking-[0.15em] text-muted-foreground/75 uppercase">
|
|
{group.title}
|
|
</p>
|
|
<div className="space-y-0.5">
|
|
{group.items.map((item) => {
|
|
const active = item.to === activeTo;
|
|
return (
|
|
<Link
|
|
key={item.to}
|
|
to={item.to}
|
|
onClick={onNavigate}
|
|
aria-current={active ? "page" : undefined}
|
|
className={cn(
|
|
"flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors duration-base",
|
|
active
|
|
? "text-foreground"
|
|
: "text-muted-foreground hover:bg-foreground/[0.04] hover:text-foreground",
|
|
)}
|
|
>
|
|
<item.icon className={cn("h-4 w-4 shrink-0", active && "text-primary")} />
|
|
<span className="flex-1 truncate">{item.label}</span>
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</nav>
|
|
);
|
|
}
|
|
|
|
// ─── 折叠态:只显示图标,组间细线分隔 ───
|
|
function CollapsedNav() {
|
|
const location = useLocation();
|
|
const activeTo = findNavItem(location.pathname)?.to;
|
|
return (
|
|
<nav className="flex flex-1 flex-col items-center gap-1 overflow-y-auto py-4">
|
|
<AgentNavEntry collapsed />
|
|
{NAV_GROUPS.map((group, gi) => (
|
|
<div key={group.title} className="flex flex-col items-center gap-1">
|
|
{gi > 0 ? <span aria-hidden className="my-1 h-px w-6 bg-border" /> : null}
|
|
{group.items.map((item) => {
|
|
const active = item.to === activeTo;
|
|
return (
|
|
<Link
|
|
key={item.to}
|
|
to={item.to}
|
|
title={item.label}
|
|
aria-current={active ? "page" : undefined}
|
|
className={cn(
|
|
"relative flex h-10 w-10 items-center justify-center rounded-xl transition-colors duration-micro",
|
|
active
|
|
? "seg-thumb text-primary"
|
|
: "text-muted-foreground hover:bg-foreground/[0.04] hover:text-foreground",
|
|
)}
|
|
>
|
|
<item.icon className="h-[18px] w-[18px]" />
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
))}
|
|
</nav>
|
|
);
|
|
}
|
|
|
|
function Brand({ collapsed = false }: { collapsed?: boolean }) {
|
|
return (
|
|
<Link
|
|
to="/console"
|
|
className={cn(
|
|
"flex items-center transition-opacity hover:opacity-80",
|
|
collapsed ? "w-full justify-center" : "gap-2",
|
|
)}
|
|
>
|
|
<span className="grid h-7 w-7 place-items-center rounded-lg pill-selected">
|
|
<Clapperboard className="h-4 w-4" />
|
|
</span>
|
|
{!collapsed && <p className="text-xs font-bold tracking-wide text-foreground">UHD Media Tool</p>}
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
/** 会话探测:未登录跳登录页;探测中/失败给全屏占位 */
|
|
export default function ConsoleLayout() {
|
|
const session = useSession();
|
|
const unauthenticated = session.data && !session.data.authenticated;
|
|
|
|
useEffect(() => {
|
|
if (unauthenticated) redirectToLogin();
|
|
}, [unauthenticated]);
|
|
|
|
if (session.isError) {
|
|
return (
|
|
<div className="grid min-h-screen place-items-center bg-background px-4">
|
|
<ErrorState title="无法获取登录状态" error={session.error} onRetry={() => session.refetch()} />
|
|
</div>
|
|
);
|
|
}
|
|
if (!session.data || unauthenticated) {
|
|
return (
|
|
<div className="grid min-h-screen place-items-center bg-background" role="status" aria-label="正在确认登录状态">
|
|
<LoaderCircle className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
</div>
|
|
);
|
|
}
|
|
return <ConsoleShell session={session.data} />;
|
|
}
|
|
|
|
function ConsoleShell({ session }: { session: SessionInfo }) {
|
|
const location = useLocation();
|
|
const [collapsed, setCollapsed] = useState(false);
|
|
const [sheetOpen, setSheetOpen] = useState(false);
|
|
const activeLabel = pageTitle(location.pathname) ?? PORTAL_NAME;
|
|
// 路由 handle.layout === "wide"(文件管理):内容区不限最大宽度、不留外边距,页面自己排版
|
|
const wide = useMatches().some((m) => (m.handle as { layout?: string } | undefined)?.layout === "wide");
|
|
|
|
return (
|
|
<div className="app-scrollbars relative isolate flex min-h-screen overflow-x-clip bg-background">
|
|
<div className="pointer-events-none fixed inset-0 -z-10 bg-page-glow" />
|
|
<PrismLeak className="-z-10" />
|
|
|
|
{/* 桌面端侧栏 */}
|
|
<aside
|
|
className={cn(
|
|
"pg-chrome pg-chrome-side fixed inset-y-0 left-0 z-30 hidden flex-col border-r transition-all duration-layout select-none xl:flex",
|
|
collapsed ? "w-[60px]" : "w-60",
|
|
)}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => setCollapsed((v) => !v)}
|
|
className="absolute top-3 -right-4 z-40 flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border border-[var(--shell-border)] bg-background text-muted-foreground shadow-md transition-all duration-base hover:scale-110 hover:bg-foreground/[0.04] hover:text-foreground"
|
|
title={collapsed ? "展开侧边栏" : "收起侧边栏"}
|
|
>
|
|
{collapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
|
|
</button>
|
|
<div
|
|
className={cn(
|
|
"flex h-14 shrink-0 items-center border-b border-[var(--shell-border)]",
|
|
collapsed ? "justify-center px-0" : "px-4",
|
|
)}
|
|
>
|
|
<Brand collapsed={collapsed} />
|
|
</div>
|
|
{collapsed ? <CollapsedNav /> : <ExpandedNav groups={NAV_GROUPS} />}
|
|
</aside>
|
|
|
|
{/* 主内容区 */}
|
|
<div
|
|
className={cn(
|
|
"relative flex min-h-screen min-w-0 flex-1 flex-col transition-all duration-layout",
|
|
collapsed ? "xl:ml-[60px]" : "xl:ml-60",
|
|
)}
|
|
>
|
|
<header className="pg-chrome sticky top-0 z-20 flex h-14 items-center border-b px-4 md:px-6">
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
|
<SheetTrigger asChild>
|
|
<Button variant="ghost" size="icon" className="h-8 w-8 xl:hidden" aria-label="打开导航">
|
|
<Menu className="h-4 w-4" />
|
|
</Button>
|
|
</SheetTrigger>
|
|
<SheetContent side="left" className="w-64 p-0 select-none">
|
|
<SheetHeader className="border-b border-[var(--shell-border)] px-5 py-4">
|
|
<SheetTitle className="text-sm">
|
|
<Brand />
|
|
</SheetTitle>
|
|
</SheetHeader>
|
|
<ExpandedNav groups={NAV_GROUPS} onNavigate={() => setSheetOpen(false)} />
|
|
</SheetContent>
|
|
</Sheet>
|
|
<span className="truncate text-sm font-semibold text-foreground">{activeLabel}</span>
|
|
</div>
|
|
<div className="ml-auto flex items-center gap-2">
|
|
<SearchTrigger />
|
|
{/* 桌面有侧栏主入口与右下角悬浮按钮,顶栏小按钮只在窄屏保留 */}
|
|
<InboxBell />
|
|
<AgentDrawerTrigger className="xl:hidden" />
|
|
<ThemeToggle />
|
|
<AccountMenu session={session} />
|
|
</div>
|
|
</header>
|
|
|
|
{/* 底部留出悬浮按钮的高度,页面末尾的内容能滚到它上面 */}
|
|
<main className={cn("relative flex-1 overflow-x-clip", !wide && "px-4 pt-6 pb-24 md:px-6 md:pt-8")}>
|
|
<div key={location.pathname} className={cn(!wide && "mx-auto max-w-6xl", "motion-safe:animate-blur-in")}>
|
|
<Outlet />
|
|
</div>
|
|
</main>
|
|
<AgentDrawer />
|
|
<AgentLauncher />
|
|
<CommandPalette />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|