278 lines
8.3 KiB
TypeScript
278 lines
8.3 KiB
TypeScript
import { ThemeToggle } from "./ThemeToggle";
|
||
import { Brand } from "./Brand";
|
||
import Head from "next/head";
|
||
import Link from "next/link";
|
||
import { useRouter } from "next/router";
|
||
import {
|
||
createContext,
|
||
useCallback,
|
||
useContext,
|
||
useEffect,
|
||
useRef,
|
||
useState,
|
||
type ReactNode,
|
||
} from "react";
|
||
import {
|
||
ArrowUpRight,
|
||
BookOpen,
|
||
Bot,
|
||
ChevronRight,
|
||
Compass,
|
||
Files,
|
||
Globe2,
|
||
LogIn,
|
||
Menu,
|
||
Plus,
|
||
SquarePen,
|
||
UserRound,
|
||
Waypoints,
|
||
X,
|
||
} from "lucide-react";
|
||
import s from "@/styles/guidance.module.css";
|
||
|
||
export type AccountUser = { id: string; email: string; verified_at: string };
|
||
type AccountState = {
|
||
user: AccountUser | null;
|
||
loading: boolean;
|
||
error: string;
|
||
refresh: () => Promise<void>;
|
||
};
|
||
const AccountContext = createContext<AccountState>({
|
||
user: null,
|
||
loading: true,
|
||
error: "",
|
||
refresh: async () => {},
|
||
});
|
||
export const useGuidanceAccount = () => useContext(AccountContext);
|
||
|
||
export default function GuidanceShell({
|
||
children,
|
||
title = "探索文档",
|
||
standalone = false,
|
||
}: {
|
||
children: ReactNode;
|
||
title?: string;
|
||
standalone?: boolean;
|
||
}) {
|
||
const router = useRouter();
|
||
const dialog = useRef<HTMLDialogElement>(null);
|
||
const [user, setUser] = useState<AccountUser | null>(null),
|
||
[loading, setLoading] = useState(true),
|
||
[error, setError] = useState("");
|
||
const refresh = useCallback(async () => {
|
||
setLoading(true);
|
||
setError("");
|
||
try {
|
||
const r = await fetch("/api/account/session");
|
||
const b = await r.json();
|
||
if (!r.ok) throw Error(b.error || "账户暂时不可用");
|
||
setUser(b.user || null);
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : "账户暂时不可用");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
useEffect(() => {
|
||
void refresh();
|
||
}, [refresh]);
|
||
useEffect(() => {
|
||
dialog.current?.close();
|
||
}, [router.pathname, router.query.scope]);
|
||
const mine = router.query.scope === "mine";
|
||
const nav = [
|
||
{
|
||
href: "/",
|
||
label: "探索文档",
|
||
Icon: Compass,
|
||
active: router.pathname === "/" || router.pathname.startsWith("/guide/"),
|
||
},
|
||
{
|
||
href: "/documents",
|
||
label: "社区文档",
|
||
Icon: Files,
|
||
active: router.pathname.startsWith("/documents") && !mine,
|
||
},
|
||
{
|
||
href: "/documents?scope=mine",
|
||
label: "我的文档",
|
||
Icon: BookOpen,
|
||
active: router.pathname === "/documents" && mine,
|
||
},
|
||
{
|
||
href: "/write",
|
||
label: "发布文档",
|
||
Icon: SquarePen,
|
||
active: router.pathname === "/write",
|
||
},
|
||
];
|
||
function navigation(mobile = false) {
|
||
return (
|
||
<>
|
||
<Link
|
||
className={s.brand}
|
||
href="/"
|
||
onClick={() => dialog.current?.close()}
|
||
>
|
||
<Brand/>
|
||
</Link>
|
||
<div className={s.workspaceLabel}>
|
||
<Globe2 size={16} />
|
||
<span>公共知识空间</span>
|
||
<span className={s.workspaceTag}>Public</span>
|
||
</div>
|
||
<p className={s.navLabel}>工作空间</p>
|
||
<nav
|
||
aria-label={mobile ? "移动端主导航" : "主导航"}
|
||
className={s.navigation}
|
||
>
|
||
{nav.map(({ href, label, Icon, active }) => (
|
||
<Link
|
||
href={href}
|
||
key={href}
|
||
aria-current={active ? "page" : undefined}
|
||
className={active ? s.navActive : ""}
|
||
onClick={() => dialog.current?.close()}
|
||
>
|
||
<Icon size={18} />
|
||
<span>{label}</span>
|
||
</Link>
|
||
))}
|
||
</nav>
|
||
<p className={s.navLabel}>资源</p>
|
||
<nav
|
||
aria-label={mobile ? "移动端资源" : "资源导航"}
|
||
className={s.navigation}
|
||
>
|
||
<Link
|
||
href="/protocol"
|
||
aria-current={router.pathname === "/protocol" ? "page" : undefined}
|
||
className={router.pathname === "/protocol" ? s.navActive : ""}
|
||
onClick={() => dialog.current?.close()}
|
||
>
|
||
<BookOpen size={18} />
|
||
<span>文档协议</span>
|
||
</Link>
|
||
<a href="/agent/index.md" target="_blank" rel="noreferrer">
|
||
<Bot size={18} />
|
||
<span>Agent 读取入口</span>
|
||
<ArrowUpRight size={14} className={s.navExternal} />
|
||
</a>
|
||
</nav>
|
||
<div className={s.sidebarBottom}>
|
||
<div className={s.sidebarHelp}>
|
||
<span className={s.smallLabel}>从一篇文档开始</span>
|
||
<p>选择适用的指导,需要更多背景时,再沿链接继续阅读。</p>
|
||
<Link href="/protocol">
|
||
了解使用方法 <ArrowUpRight size={13} />
|
||
</Link>
|
||
</div>
|
||
<Link href="/account" className={s.userArea}>
|
||
<span className={s.avatar}>
|
||
{user ? (
|
||
user.email.slice(0, 1).toUpperCase()
|
||
) : (
|
||
<UserRound size={18} />
|
||
)}
|
||
</span>
|
||
<span className={s.userInfo}>
|
||
<strong>
|
||
{user ? user.email : loading ? "正在检查账户…" : "登录你的账户"}
|
||
</strong>
|
||
<small>
|
||
{user ? "管理与发布你的文档" : "邮箱登录 · 开始发布"}
|
||
</small>
|
||
</span>
|
||
<ChevronRight size={15} />
|
||
</Link>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
if (standalone) return (
|
||
<AccountContext.Provider value={{ user, loading, error, refresh }}>
|
||
<div className={s.accountPage}>
|
||
<Head><title>{`${title} · 啡跃`}</title></Head>
|
||
<a className={s.skipLink} href="#account-content">跳到正文</a>
|
||
<header className={s.accountHeader}>
|
||
<Link href="/" aria-label="啡跃首页"><Brand /></Link>
|
||
<div><Link href="/">返回首页</Link><ThemeToggle /></div>
|
||
</header>
|
||
<main id="account-content">{children}</main>
|
||
</div>
|
||
</AccountContext.Provider>
|
||
);
|
||
return (
|
||
<AccountContext.Provider value={{ user, loading, error, refresh }}>
|
||
<div className={s.site}>
|
||
<Head>
|
||
<title>{`${title} · 啡跃`}</title>
|
||
<meta
|
||
name="description"
|
||
content="为 Agent 编写、发现和连接可独立使用的指导文档。"
|
||
/>
|
||
</Head>
|
||
<a className={s.skipLink} href="#workspace-content">
|
||
跳到正文
|
||
</a>
|
||
<aside className={s.sidebar}>{navigation()}</aside>
|
||
<dialog ref={dialog} className={s.mobileDialog} aria-label="工作区导航">
|
||
<button
|
||
type="button"
|
||
className={s.mobileClose}
|
||
aria-label="关闭导航"
|
||
onClick={() => dialog.current?.close()}
|
||
>
|
||
<X size={20} />
|
||
</button>
|
||
{navigation(true)}
|
||
</dialog>
|
||
<div className={s.workspace}>
|
||
<header className={s.topbar}>
|
||
<div className={s.breadcrumb}>
|
||
<button
|
||
type="button"
|
||
className={s.mobileMenu}
|
||
aria-label="打开导航"
|
||
onClick={() => dialog.current?.showModal()}
|
||
>
|
||
<Menu size={20} />
|
||
</button>
|
||
<span className={s.breadcrumbSpace}>工作空间</span>
|
||
<ChevronRight size={14} />
|
||
<strong>{title}</strong>
|
||
</div>
|
||
<div className={s.topbarActions}><ThemeToggle/>
|
||
<Link className={s.topbarProtocol} href="/protocol">
|
||
<span className={s.statusDot} />
|
||
Linked Guidance 0.1
|
||
</Link>
|
||
{router.pathname !== "/write" && (
|
||
<Link className={s.primaryButton} href="/write">
|
||
<Plus size={16} />
|
||
创建文档
|
||
</Link>
|
||
)}
|
||
{!user && !loading && (
|
||
<Link className={s.topbarLogin} href="/account">
|
||
<LogIn size={16} />
|
||
<span>登录</span>
|
||
</Link>
|
||
)}
|
||
</div>
|
||
</header>
|
||
<main className={s.content} id="workspace-content">
|
||
{children}
|
||
</main>
|
||
<footer className={s.workspaceFooter}>
|
||
<span>啡跃 · 让知识按需连接</span>
|
||
<Link href="/protocol">
|
||
文档协议 <ArrowUpRight size={12} />
|
||
</Link>
|
||
</footer>
|
||
</div>
|
||
</div>
|
||
</AccountContext.Provider>
|
||
);
|
||
}
|