Files
ttstd-infinite-canvas/web/src/components/layout/workspace-sidebar-nav.tsx
2026-10-13 05:31:30 +08:00

339 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Popover } from "antd";
import { ChevronDown, ChevronRight, CircleUserRound, History as HistoryIcon, Infinity as InfinityIcon, PanelLeftClose, PanelLeftOpen, Plus } from "lucide-react";
import { LayoutGroup, motion, useReducedMotion } from "motion/react";
import { useEffect, useMemo, useRef, useState, type ComponentType, type CSSProperties } from "react";
import { Link, useLocation, useNavigate, useSearchParams } from "react-router";
import { BrandLogoFrame } from "@/components/brand/brand-logo";
import { UserAvatar } from "@/components/layout/user-avatar";
import { Kbd } from "@/components/ui/base/kbd";
import { navigationTools, type NavigationToolSlug } from "@/constant/navigation-tools";
import { useWorkspaceLogout } from "@/hooks/use-workspace-logout";
import { aceternityMotion } from "@/lib/aceternity-motion";
import { cn } from "@/lib/utils";
import { preloadWorkspaceRoute } from "@/lib/workspace-route-modules";
import { useUserStore, type FeatureAvailability } from "@/stores/use-user-store";
import { useAppearanceStore } from "@/stores/use-appearance-store";
import { WorkspaceAccountCard } from "./workspace-account-card";
import { WorkspaceSidebarCheckin } from "./workspace-sidebar-checkin";
import { WorkspaceSidebarStorageMeter } from "./workspace-sidebar-storage-meter";
import { openWorkspaceWallet } from "@/lib/workspace-wallet";
export type WorkspaceNavItem = {
id: string;
title: string;
icon?: ComponentType<{ className?: string; strokeWidth?: number }>;
to?: string;
shortcut?: string;
badge?: string | number;
action?: "search" | "logout";
children?: WorkspaceNavItem[];
};
type WorkspaceNavGroup = {
heading?: string;
items: WorkspaceNavItem[];
};
function toolItem(slug: NavigationToolSlug, to: string): WorkspaceNavItem {
const tool = navigationTools.find((item) => item.slug === slug);
return { id: slug, title: tool?.label ?? slug, icon: tool?.icon, to };
}
function buildNav(features: FeatureAvailability): { groups: WorkspaceNavGroup[]; footer: WorkspaceNavItem[] } {
const groups: WorkspaceNavGroup[] = [
{
items: [
{ ...toolItem("create", "/"), id: "home", title: "创作" },
{ ...toolItem("inspirations", "/inspirations"), title: "灵感" },
{ ...toolItem("projects", "/projects"), title: "短剧 Agent" },
{ ...toolItem("canvas", "/canvas"), title: "自由画布" },
],
},
{
heading: "资源与工具",
items: [{ ...toolItem("assets", "/assets"), title: "资产" }, { ...toolItem("skills", "/skills"), title: "技能" }, ...(features.pluginCenterEnabled ? [{ ...toolItem("plugins", "/plugins"), title: "插件" }] : [])],
},
...(features.taskCenterEnabled ? [{ items: [{ ...toolItem("tasks", "/tasks"), title: "创作历史", icon: HistoryIcon }] }] : []),
];
// 管理、设置和退出登录不再占据参考站式侧栏底部,而是通过用户卡片菜单进入。
// 路由和写操作仍保留,避免把用户端导航变成无法访问的装饰。
return { groups, footer: [] };
}
function WorkspaceSidebarProfile({ collapsed, user, onExpand, onCollapse }: { collapsed: boolean; user: NonNullable<ReturnType<typeof useUserStore.getState>["user"]> | null; onExpand: () => void; onCollapse: () => void }) {
const [menuOpen, setMenuOpen] = useState(false);
const profileName = user?.displayName || user?.username || "未登录";
if (!user) {
return <Link to="/login" className={cn("app-workspace-sidebar-profile", collapsed && "is-collapsed")} aria-label="登录" title="登录"><span className="app-workspace-sidebar-profile-avatar"><CircleUserRound aria-hidden /></span>{!collapsed ? <span>游客模式</span> : null}</Link>;
}
const content = <WorkspaceAccountCard onNavigate={() => setMenuOpen(false)} onWallet={() => { setMenuOpen(false); openWorkspaceWallet(); }} />;
return (
<div className={cn("app-workspace-sidebar-account", collapsed && "is-collapsed")}>
<WorkspaceSidebarCheckin collapsed={collapsed} />
<WorkspaceSidebarStorageMeter collapsed={collapsed} />
<div className={cn("app-workspace-sidebar-profile-row", collapsed && "is-collapsed")}>
<Popover open={menuOpen} onOpenChange={setMenuOpen} trigger="click" placement="topLeft" arrow={false} rootClassName="workspace-account-popover" content={content}>
<button type="button" className={cn("app-workspace-sidebar-profile", collapsed && "is-collapsed")} aria-label="打开账户菜单" title={profileName}>
<UserAvatar user={user} className="app-workspace-sidebar-profile-avatar" />
{!collapsed ? <span className="app-workspace-sidebar-profile-copy"><strong>{profileName}</strong><span>创作工作台</span></span> : null}
</button>
</Popover>
{!collapsed ? <button type="button" className="app-workspace-sidebar-collapse-toggle" aria-label="收起侧栏" title="收起侧栏" onClick={onCollapse}>
<PanelLeftClose className="size-4" strokeWidth={1.7} />
</button> : null}
</div>
{collapsed ? <button type="button" className="app-workspace-sidebar-collapse-toggle is-collapsed" aria-label="展开侧栏菜单" title="展开侧栏菜单" onClick={onExpand}>
<PanelLeftOpen className="size-4" strokeWidth={1.7} />
</button> : null}
</div>
);
}
function WorkspaceSwitcher({ collapsed, onNavigate }: { collapsed: boolean; onNavigate: () => void; onExpand: () => void; onCollapse: () => void }) {
const appearance = useAppearanceStore((state) => state.appearance);
if (collapsed) {
return (
<div className="app-workspace-sidebar-brand-row relative shrink-0 flex items-center justify-center px-3 pt-3">
<Link to="/" onClick={onNavigate} className="app-workspace-sidebar-brand-button group" aria-label={`${appearance.brandName}首页`}>
<BrandLogoFrame className="app-workspace-brand-mark grid size-8 shrink-0 place-items-center rounded-[var(--r-sm)] shadow-sm" logoClassName="size-5 object-contain" alt="" fallback={<InfinityIcon className="size-4" strokeWidth={2.2} />} />
</Link>
</div>
);
}
return (
<div className="app-workspace-sidebar-brand-row relative shrink-0 px-3 pt-3">
<Link to="/" onClick={onNavigate} className="app-workspace-sidebar-brand-button group" aria-label={`${appearance.brandName}首页`}>
<span className="flex min-w-0 items-center gap-2">
<BrandLogoFrame className="app-workspace-brand-mark grid size-8 shrink-0 place-items-center rounded-[var(--r-sm)] shadow-sm" logoClassName="size-5 object-contain" alt="" fallback={<InfinityIcon className="size-4" strokeWidth={2.2} />} />
<span className="flex min-w-0 flex-col">
<span className="app-workspace-brand-wordmark truncate text-[var(--fs-body)] leading-none font-semibold">{appearance.brandName}</span>
<span className="mt-1 truncate text-[var(--fs-label)] leading-none text-foreground/60">创作工作台</span>
</span>
</span>
</Link>
</div>
);
}
function NavItem({
item,
activeId,
onSelect,
onOpenSearch,
onLogout,
level = 0,
collapsed = false,
}: {
item: WorkspaceNavItem;
activeId: string;
onSelect: (id: string) => void;
onOpenSearch: () => void;
onLogout: () => void;
level?: number;
collapsed?: boolean;
}) {
const isActive = activeId === item.id || (item.id === "settings" && activeId.startsWith("settings:"));
const hasChildren = Boolean(item.children?.length);
const [isOpen, setIsOpen] = useState(false);
const reducedMotion = useReducedMotion();
// 激活分支自动展开(如设置分区子项),保证当前位置可见。
useEffect(() => {
if (isActive && hasChildren) setIsOpen(true);
}, [isActive, hasChildren]);
const Icon = item.icon;
const rowStyle = collapsed ? undefined : ({ paddingLeft: `${level * 12 + 10}px` } as CSSProperties);
const collapsedTitle = item.id === "home" ? "创作" : item.id === "projects" ? "短剧" : item.id === "canvas" ? "画布" : item.id === "assets" ? "资产" : item.id === "skills" ? "技能" : item.id === "plugins" ? "插件" : item.id === "tasks" ? "历史" : item.title.slice(0, 2);
const rowContent = (
<>
<span className="app-workspace-nav-main flex min-w-0 items-center gap-2.5">
{Icon ? (
<span className="app-workspace-nav-icon" aria-hidden>
<Icon className="size-4 shrink-0" strokeWidth={1.8} />
</span>
) : (
<span className="size-1.5 shrink-0 rounded-full bg-current opacity-40" aria-hidden />
)}
<span className="app-workspace-nav-title truncate">{collapsed ? collapsedTitle : item.title}</span>
</span>
<span className="app-workspace-nav-meta flex shrink-0 items-center gap-2">
{item.shortcut ? (
<Kbd className="hidden group-hover:inline-flex">{item.shortcut}</Kbd>
) : null}
{item.badge !== undefined ? <span className="flex min-w-5 items-center justify-center rounded-full bg-primary/10 px-1.5 py-0.5 text-[var(--fs-tiny)] font-medium tabular-nums text-primary">{item.badge}</span> : null}
{hasChildren ? <ChevronRight className={cn("size-3.5 shrink-0 text-foreground/40 transition-transform duration-200", isOpen && "rotate-90")} strokeWidth={2} /> : null}
</span>
</>
);
const rowClassName = cn(
"app-workspace-nav-link group relative isolate flex min-h-11 w-full items-center justify-between gap-2 rounded-[var(--r-md)] px-3 py-2 text-[var(--fs-body)] transition-[color,transform] duration-200 select-none",
collapsed && "is-collapsed",
isActive ? "is-active font-medium" : "text-foreground/62 hover:bg-surface-hover hover:text-foreground",
);
const activePill = isActive ? (
<motion.span
layoutId="workspace-nav-active-pill"
className="app-workspace-nav-active-pill"
aria-hidden
transition={reducedMotion ? { duration: 0 } : aceternityMotion.spring.dock}
/>
) : null;
const handleClick = () => {
if (item.action === "search") {
onOpenSearch();
return;
}
if (item.action === "logout") {
onLogout();
return;
}
if (hasChildren) {
setIsOpen((open) => !open);
return;
}
onSelect(item.id);
};
// 局部 const:闭包内 TS 保留窄化,供 preload 回调使用。
const linkTo = item.to;
return (
<div className="flex w-full flex-col">
{linkTo ? (
<Link
to={linkTo}
className={rowClassName}
data-nav-id={item.id}
style={rowStyle}
aria-label={collapsed ? item.title : undefined}
title={collapsed ? item.title : undefined}
onClick={handleClick}
onFocus={() => preloadWorkspaceRoute(linkTo)}
onPointerDown={() => preloadWorkspaceRoute(linkTo)}
onPointerEnter={() => preloadWorkspaceRoute(linkTo)}
>
{activePill}
{rowContent}
</Link>
) : (
<button type="button" className={rowClassName} data-nav-id={item.id} style={rowStyle} aria-label={collapsed ? item.title : undefined} title={collapsed ? item.title : undefined} onClick={handleClick} aria-expanded={hasChildren ? isOpen : undefined}>
{activePill}
{rowContent}
</button>
)}
{hasChildren && !collapsed ? (
<div className={cn("grid transition-[grid-template-rows,opacity] duration-300 ease-in-out", isOpen ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0")}>
<div className="relative flex min-h-0 flex-col gap-0.5 overflow-hidden pt-0.5">
<span className="app-workspace-nav-guide-line" style={{ left: `${(level + 1) * 12 + 12.5}px` }} />
{item.children!.map((child) => (
<NavItem key={child.id} item={child} activeId={activeId} onSelect={onSelect} onOpenSearch={onOpenSearch} onLogout={onLogout} level={level + 1} collapsed={false} />
))}
</div>
</div>
) : null}
</div>
);
}
function NavGroup({ group, activeId, onNavigate, onOpenSearch, onLogout, collapsed }: { group: WorkspaceNavGroup; activeId: string; onNavigate: () => void; onOpenSearch: () => void; onLogout: () => void; collapsed: boolean }) {
const [isOpen, setIsOpen] = useState(true);
const hasActive = group.items.some((item) => item.id === activeId || (item.id === "settings" && activeId.startsWith("settings:")));
// 激活项所在分组自动展开,保证当前位置可见。
useEffect(() => {
if (hasActive) setIsOpen(true);
}, [hasActive]);
const content = (
<div className="flex flex-col gap-2">
{group.items.map((item) => (
<NavItem key={item.id} item={item} activeId={activeId} onSelect={onNavigate} onOpenSearch={onOpenSearch} onLogout={onLogout} collapsed={collapsed} />
))}
</div>
);
// 无标题分组(核心导航入口)常驻展示,不做折叠。
if (!group.heading || collapsed) {
return <div className="app-workspace-nav-group flex shrink-0 flex-col" data-nav-group-heading={group.heading || ""}>{content}</div>;
}
return (
<div className="app-workspace-nav-group flex shrink-0 flex-col" data-nav-group-heading={group.heading}>
<button type="button" onClick={() => setIsOpen((open) => !open)} aria-expanded={isOpen} className="app-workspace-nav-group-toggle select-none">
<span className="app-workspace-nav-group-label">{group.heading}</span>
<ChevronRight className={cn("size-3.5 shrink-0 text-foreground/35 transition-transform duration-200", isOpen && "rotate-90")} strokeWidth={2} />
</button>
<div className={cn("grid transition-[grid-template-rows,opacity] duration-300 ease-in-out", isOpen ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0")}>
<div className="min-h-0 overflow-hidden pt-0.5">{content}</div>
</div>
</div>
);
}
export function WorkspaceSidebarNav({ collapsed, onNavigate, onOpenSearch, onExpand, onCollapse }: { collapsed: boolean; onNavigate: () => void; onOpenSearch: () => void; onExpand: () => void; onCollapse: () => void }) {
const { pathname } = useLocation();
const [searchParams] = useSearchParams();
const features = useUserStore((state) => state.features);
const user = useUserStore((state) => state.user);
const { handleLogout } = useWorkspaceLogout();
const { groups, footer } = useMemo(() => buildNav(features), [features]);
const slug = pathname.split("/").filter(Boolean)[0] || "home";
const section = searchParams.get("section");
const activeId = slug === "settings" && section ? `settings:${section}` : slug;
const scrollRef = useRef<HTMLDivElement>(null);
const [scrollState, setScrollState] = useState({ hasTopFade: false, hasBottomFade: false });
const handleScroll = () => {
const element = scrollRef.current;
if (!element) return;
const { scrollTop, scrollHeight, clientHeight } = element;
setScrollState({
hasTopFade: scrollTop > 0,
hasBottomFade: scrollTop + clientHeight < scrollHeight - 1,
});
};
useEffect(() => {
handleScroll();
}, [groups]);
return (
<div className={cn("app-workspace-sidebar-nav flex h-full shrink-0 flex-col", collapsed && "is-collapsed")}>
<WorkspaceSwitcher collapsed={collapsed} onNavigate={onNavigate} onExpand={onExpand} onCollapse={onCollapse} />
<LayoutGroup id="workspace-sidebar-nav">
<div
ref={scrollRef}
onScroll={handleScroll}
className={cn("app-workspace-sidebar-scroll-area flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto px-3 pb-3 pt-7", collapsed && "is-collapsed", scrollState.hasTopFade && "has-top-fade", scrollState.hasBottomFade && "has-bottom-fade")}
>
{groups.map((group, index) => (
<NavGroup key={index} group={group} activeId={activeId} onNavigate={onNavigate} onOpenSearch={onOpenSearch} onLogout={() => void handleLogout()} collapsed={collapsed} />
))}
</div>
</LayoutGroup>
<div className="app-workspace-sidebar-footer shrink-0 px-3 py-3">
<WorkspaceSidebarProfile collapsed={collapsed} user={user} onExpand={onExpand} onCollapse={onCollapse} />
{footer.length ? <div className="mt-2 flex flex-col gap-0.5">
{footer.map((item) => (
<NavItem key={item.id} item={item} activeId={activeId} onSelect={onNavigate} onOpenSearch={onOpenSearch} onLogout={() => void handleLogout()} collapsed={collapsed} />
))}
</div> : null}
</div>
</div>
);
}