319 lines
11 KiB
TypeScript
319 lines
11 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import type { LucideIcon } from "lucide-react";
|
|
import {
|
|
Bell,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
LayoutDashboard,
|
|
ListTodo,
|
|
LogOut,
|
|
Menu,
|
|
Moon,
|
|
Settings,
|
|
Sparkles,
|
|
Sun,
|
|
X
|
|
} from "lucide-react";
|
|
import { Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
|
import { Button } from "@/components/ui/button";
|
|
import { cn } from "@/lib/utils";
|
|
import { EmailLoginPage } from "@/pages/email-login-page";
|
|
import { OAuthCallbackPage } from "@/pages/oauth-callback-page";
|
|
import { TodoShellPage } from "@/pages/todo-shell-page";
|
|
import { revokeRefreshToken, type EmailLoginResult } from "@/services/auth-api";
|
|
import {
|
|
clearSession,
|
|
loadSession,
|
|
saveSession,
|
|
type WebSession
|
|
} from "@/services/session-storage";
|
|
import {
|
|
applyThemeMode,
|
|
loadThemeMode,
|
|
saveThemeMode,
|
|
type ThemeMode
|
|
} from "@/services/theme-storage";
|
|
|
|
type SidebarItem = {
|
|
key: string;
|
|
label: string;
|
|
icon: LucideIcon;
|
|
};
|
|
|
|
const SIDEBAR_ITEMS: SidebarItem[] = [
|
|
{ key: "dashboard", label: "概览面板", icon: LayoutDashboard },
|
|
{ key: "todo", label: "待办事项", icon: ListTodo },
|
|
{ key: "ai", label: "AI 建议", icon: Sparkles },
|
|
{ key: "notice", label: "提醒中心", icon: Bell },
|
|
{ key: "settings", label: "系统设置", icon: Settings }
|
|
];
|
|
|
|
function toWebSession(payload: EmailLoginResult): WebSession {
|
|
return {
|
|
accessToken: payload.accessToken,
|
|
refreshToken: payload.refreshToken,
|
|
user: {
|
|
id: payload.user.id,
|
|
email: payload.user.email
|
|
}
|
|
};
|
|
}
|
|
|
|
function App() {
|
|
const [session, setSession] = useState<WebSession | null>(() => loadSession());
|
|
const [loggingOut, setLoggingOut] = useState(false);
|
|
const [themeMode, setThemeMode] = useState<ThemeMode>(() => loadThemeMode());
|
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
|
|
const isAuthPage =
|
|
location.pathname === "/login/email" || location.pathname.startsWith("/auth/callback/");
|
|
|
|
useEffect(() => {
|
|
applyThemeMode(themeMode);
|
|
saveThemeMode(themeMode);
|
|
}, [themeMode]);
|
|
|
|
async function handleLogout(): Promise<void> {
|
|
if (!session || loggingOut) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setLoggingOut(true);
|
|
await revokeRefreshToken(session.refreshToken);
|
|
} catch {
|
|
// 无论接口成功与否,都要清理本地会话,避免页面卡在登录态。
|
|
} finally {
|
|
clearSession();
|
|
setSession(null);
|
|
setLoggingOut(false);
|
|
setMobileSidebarOpen(false);
|
|
navigate("/login/email", { replace: true });
|
|
}
|
|
}
|
|
|
|
function handleToggleTheme(): void {
|
|
setThemeMode((currentTheme) => (currentTheme === "dark" ? "light" : "dark"));
|
|
}
|
|
|
|
function handleLoginSuccess(payload: EmailLoginResult): void {
|
|
const nextSession = toWebSession(payload);
|
|
saveSession(nextSession);
|
|
setSession(nextSession);
|
|
setMobileSidebarOpen(false);
|
|
navigate("/", { replace: true });
|
|
}
|
|
|
|
function handleBootstrapSession(nextSession: WebSession): void {
|
|
setSession(nextSession);
|
|
setMobileSidebarOpen(false);
|
|
}
|
|
|
|
function renderSidebarContent(options: { collapsed: boolean; mobile: boolean }) {
|
|
const { collapsed, mobile } = options;
|
|
|
|
return (
|
|
<div className="flex h-full min-h-0 flex-col">
|
|
{mobile ? (
|
|
<div className="flex h-14 shrink-0 items-center justify-end border-b border-border/70 px-3">
|
|
<Button
|
|
type="button"
|
|
size="icon-sm"
|
|
variant="ghost"
|
|
className="text-muted-foreground"
|
|
onClick={() => setMobileSidebarOpen(false)}
|
|
aria-label="关闭侧边栏"
|
|
>
|
|
<X className="size-4" />
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
|
<nav className="space-y-1">
|
|
{SIDEBAR_ITEMS.map((item) => {
|
|
const ItemIcon = item.icon;
|
|
return (
|
|
<button
|
|
key={item.key}
|
|
type="button"
|
|
className={cn(
|
|
"group flex w-full items-center rounded-xl border border-transparent px-3 py-2.5 text-left transition-colors",
|
|
"gap-3 hover:border-primary/25 hover:bg-primary/10"
|
|
)}
|
|
>
|
|
<ItemIcon className="size-5 shrink-0 text-primary" />
|
|
{collapsed ? null : (
|
|
<>
|
|
<span className="text-sm whitespace-nowrap text-foreground">
|
|
{item.label}
|
|
</span>
|
|
<span className="ml-auto whitespace-nowrap rounded-full border border-border bg-card px-2 py-0.5 text-[10px] text-muted-foreground">
|
|
即将上线
|
|
</span>
|
|
</>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</nav>
|
|
</div>
|
|
|
|
<div className="shrink-0 space-y-2 border-t border-border/70 p-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="w-full justify-start gap-2 border-primary/25 px-3 text-primary hover:bg-primary/10"
|
|
onClick={handleToggleTheme}
|
|
>
|
|
{themeMode === "dark" ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
|
{collapsed ? null : (
|
|
<span className="whitespace-nowrap">
|
|
{themeMode === "dark" ? "浅色模式" : "深色模式"}
|
|
</span>
|
|
)}
|
|
</Button>
|
|
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="w-full justify-start gap-2 border-primary/25 px-3 text-primary hover:bg-primary/10"
|
|
onClick={handleLogout}
|
|
disabled={!session || loggingOut}
|
|
>
|
|
<LogOut className="size-4" />
|
|
{collapsed ? null : (
|
|
<span className="whitespace-nowrap">{loggingOut ? "退出中..." : "退出登录"}</span>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (isAuthPage) {
|
|
return (
|
|
<div className="min-h-dvh bg-background text-foreground md:min-h-screen">
|
|
<main className="flex min-h-dvh items-center justify-center px-4 py-8 md:min-h-screen md:px-6">
|
|
<div className="w-full max-w-md">
|
|
<Routes>
|
|
<Route
|
|
path="/login/email"
|
|
element={<EmailLoginPage onLoginSuccess={handleLoginSuccess} />}
|
|
/>
|
|
<Route
|
|
path="/auth/callback/:provider"
|
|
element={<OAuthCallbackPage onBootstrapSession={handleBootstrapSession} />}
|
|
/>
|
|
<Route path="*" element={<Navigate to={session ? "/" : "/login/email"} replace />} />
|
|
</Routes>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="h-dvh overflow-hidden bg-background text-foreground md:h-screen">
|
|
<header className="relative z-50 shrink-0 border-b border-border/70 bg-background/80 backdrop-blur-xl">
|
|
<div className="flex h-16 items-center justify-between px-4 md:px-6">
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
type="button"
|
|
className="text-primary md:hidden"
|
|
onClick={() => setMobileSidebarOpen(true)}
|
|
aria-label="打开侧边栏"
|
|
>
|
|
<Menu className="size-12" />
|
|
</button>
|
|
<img
|
|
src="/favicon.png"
|
|
alt="TodoList"
|
|
className="h-9 w-9 shrink-0 rounded-xl shadow-sm"
|
|
/>
|
|
<span className="text-base font-semibold tracking-tight text-foreground">TodoList</span>
|
|
</div>
|
|
<span className="hidden max-w-[280px] truncate text-sm text-muted-foreground md:block">
|
|
{session ? session.user.email : "未登录"}
|
|
</span>
|
|
</div>
|
|
</header>
|
|
|
|
{mobileSidebarOpen ? (
|
|
<button
|
|
type="button"
|
|
className="fixed inset-x-0 bottom-0 top-16 z-30 bg-black/40 backdrop-blur-[2px] md:hidden"
|
|
aria-label="关闭侧边栏遮罩"
|
|
onClick={() => setMobileSidebarOpen(false)}
|
|
/>
|
|
) : null}
|
|
|
|
<aside
|
|
className={cn(
|
|
"fixed bottom-0 left-0 top-16 z-40 w-72 border-r border-border/80 bg-card/95 backdrop-blur-xl transition-transform duration-300 md:hidden",
|
|
mobileSidebarOpen ? "translate-x-0" : "-translate-x-full"
|
|
)}
|
|
>
|
|
{renderSidebarContent({ collapsed: false, mobile: true })}
|
|
</aside>
|
|
|
|
<div className="flex h-[calc(100dvh-4rem)] min-h-0 md:h-[calc(100vh-4rem)]">
|
|
<aside
|
|
className={cn(
|
|
"relative hidden h-full border-r border-border/80 bg-card/88 backdrop-blur-xl transition-[width] duration-300 md:flex md:flex-col",
|
|
sidebarCollapsed ? "md:w-14" : "md:w-72"
|
|
)}
|
|
>
|
|
{renderSidebarContent({ collapsed: sidebarCollapsed, mobile: false })}
|
|
<Button
|
|
type="button"
|
|
size="icon-sm"
|
|
variant="outline"
|
|
className={cn(
|
|
"absolute left-full top-1/2 z-20 -ml-px h-14 w-6 -translate-y-1/2 rounded-none border border-border/80",
|
|
"bg-card/88 text-muted-foreground backdrop-blur-xl transition-colors duration-200 hover:bg-muted/80 hover:text-foreground",
|
|
"focus-visible:ring-2 focus-visible:ring-ring/45 focus-visible:ring-offset-0"
|
|
)}
|
|
onClick={() => setSidebarCollapsed((current) => !current)}
|
|
aria-label={sidebarCollapsed ? "展开侧边栏" : "收起侧边栏"}
|
|
>
|
|
{sidebarCollapsed ? (
|
|
<ChevronRight className="size-4" />
|
|
) : (
|
|
<ChevronLeft className="size-4" />
|
|
)}
|
|
</Button>
|
|
</aside>
|
|
|
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
|
<main className="min-h-0 flex-1 overflow-y-auto px-4 py-6 md:px-6 md:py-8">
|
|
<div className="mx-auto w-full max-w-6xl">
|
|
<Routes>
|
|
<Route
|
|
path="/"
|
|
element={
|
|
session ? (
|
|
<TodoShellPage session={session} />
|
|
) : (
|
|
<Navigate to="/login/email" replace />
|
|
)
|
|
}
|
|
/>
|
|
<Route
|
|
path="*"
|
|
element={<Navigate to={session ? "/" : "/login/email"} replace />}
|
|
/>
|
|
</Routes>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default App;
|