Files
probably-rendering-one-more…/apps/waterfall-wiki/src/components/GuidanceShell.tsx
T
codex-bot a45d43f5f7
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 1m13s
creative workspace review checks / verify (pull_request) Failing after 20m8s
fix(auth): separate account page from workspace navigation
2026-11-01 20:40:01 +08:00

278 lines
8.3 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 { 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>
);
}