import { AppShell, Group, Menu, Stack, Text, Title, UnstyledButton, } from "@mantine/core"; import { Outlet, Link, createRootRoute, useLocation, useNavigate } from "@tanstack/react-router"; import { Check, ChevronDown, LogOut, Monitor, Moon, Settings, Sun, Wifi, WifiOff } from "lucide-react"; import { useEffect, useState, type ReactNode } from "react"; import { useAppStore, type ThemeMode } from "../store"; import { logout } from "../features/auth/auth"; import { uploadClipboard } from "../features/clipboard/clipboard"; import { onNativeClipboard } from "../net/desktop"; import { startHub } from "../features/transfer/hub"; import { resyncPush } from "../net/push"; import { refreshICEServers, stopICEServerRefresh } from "../features/transfer/iceServers"; import { t, type Locale } from "../i18n"; import { Callout, IconButton, Tooltip } from "../ui/primitives"; import { Wordmark } from "../ui/brand"; export const Route = createRootRoute({ component: RootLayout }); function RootLayout() { const user = useAppStore((s) => s.user); const selfDeviceName = useAppStore((s) => s.selfDeviceName); const sseConnected = useAppStore((s) => s.sseConnected); const sseReconnecting = useAppStore((s) => s.sseReconnecting); const locale = useAppStore((s) => s.locale); const setLocale = useAppStore((s) => s.setLocale); const theme = useAppStore((s) => s.theme); const setTheme = useAppStore((s) => s.setTheme); const navigate = useNavigate(); const location = useLocation(); // 登录 / 首次设置 / OAuth 回调使用全屏 AuthShell(由各路由自渲染), // 这里只渲染 ,不包 AppShell,从而隐去顶栏。 const fullscreen = isAuthRoute(location.pathname); // SSE 在登录后立即开启;user.id 或 selfDeviceName 任一变化即重连 // (改名后会自动以新设备名重新注册)。 useEffect(() => { if (!user || !selfDeviceName) { return; } const ctrl = new AbortController(); startHub(ctrl.signal); // 预取 Cloudflare TURN 凭据(或 STUN 回退),首次 WebRTC 同步可用。 void refreshICEServers(); // 已开启推送的浏览器:登录后把订阅重新登记到当前用户名下(自愈换用户 / // endpoint 轮换 / locale 变更);未开启或桌面端为 no-op。 void resyncPush(); // 桌面:本机复制 → 上传云端(复用 uploadClipboard 链路;浏览器里为 no-op)。 // 上传失败静默——不打断用户,下次复制再试。 const offNativeClipboard = onNativeClipboard((text) => { void uploadClipboard(text).catch(() => { /* ignore */ }); }); return () => { ctrl.abort(); stopICEServerRefresh(); offNativeClipboard(); }; }, [ user?.id, selfDeviceName ]); const handleLogout = () => { logout(); navigate({ to: "/login", search: { dev_user: undefined } }); }; if (fullscreen) { return ; } return ( <Wordmark /> {user && ( {user.name} {user.name} {t("nav.thisDeviceLabel")} {selfDeviceName} {sseConnected ? `● ${t("app.connected")}` : `○ ${t("app.connecting")}`} {t("nav.theme")} {/* System 列于最上 — 默认跟随系统,最常用。 */} } current={theme} onSelect={setTheme} /> } current={theme} onSelect={setTheme} /> } current={theme} onSelect={setTheme} /> } onClick={() => navigate({ to: "/settings" })} > {t("nav.settings")} {t("nav.language")} } onClick={handleLogout} > {t("nav.signOut")} )} {sseReconnecting && (
} title={t("app.disconnectedTitle")} > {t("app.disconnectedHint")}
)}
); } // Header 上的快捷按钮:单击在三态间循环 system → light → dark → system。 // 图标随当前模式切换;hover 显示 tooltip 提示下一步会切到什么。 function ThemeQuickToggle(props: { theme: ThemeMode; onChange: (m: ThemeMode) => void }) { const { theme, onChange } = props; const next: ThemeMode = theme === "system" ? "light" : theme === "light" ? "dark" : "system"; const icon = theme === "light" ? : theme === "dark" ? : ; const label = `${t("nav.themeToggle")}(${t(`nav.theme.${theme}` as const)} → ${t(`nav.theme.${next}` as const)})`; return ( onChange(next)} > {icon} ); } function ThemeItem(props: { mode: ThemeMode; label: string; icon: ReactNode; current: ThemeMode; onSelect: (m: ThemeMode) => void; }) { const { mode, label, icon, current, onSelect } = props; const active = mode === current; return ( : } rightSection={icon} onClick={() => { if (!active) { onSelect(mode); } }} > {label} ); } function LocaleItem(props: { locale: Locale; label: string; current: Locale; onSelect: (l: Locale) => void; }) { const { locale, label, current, onSelect } = props; const active = locale === current; return ( : } onClick={() => { if (!active) { onSelect(locale); } }} > {label} ); } function ConnectionIcon(props: { connected: boolean }) { const { connected } = props; const color = connected ? "var(--state-online)" : "var(--text-muted)"; const label = connected ? t("app.connected") : t("app.connecting"); return ( {connected ? : } ); } // 这些路由自己撑满 viewport,root layout 不再套 AppShell。 function isAuthRoute(pathname: string): boolean { return pathname === "/login" || pathname === "/setup" || pathname.startsWith("/oauth/"); } function initial(name: string): string { const trimmed = name.trim(); if (!trimmed) { return "?"; } // 取首个码点(兼容 emoji / 多字节中文姓名)。 const first = Array.from(trimmed)[0]; return first.toUpperCase(); } // 头像:优先 Casdoor 提供的图(user.avatar)。URL 缺失或加载失败时回退到 // 字母方块 —— 浅色主题白底黑字、深色主题黑底白字(与 Dracula 调色对比最强)。 function UserAvatar(props: { name: string; avatarUrl?: string; size?: number }) { const { name, avatarUrl, size = 28 } = props; const [ broken, setBroken ] = useState(false); const fontSize = Math.round(size * 0.42); const baseStyle: React.CSSProperties = { width: size, height: size, borderRadius: "50%", display: "inline-flex", alignItems: "center", justifyContent: "center", overflow: "hidden", flexShrink: 0, userSelect: "none", }; if (avatarUrl && !broken) { return ( setBroken(true)} style={{ ...baseStyle, objectFit: "cover" }} /> ); } // 字母方块:颜色由语义令牌驱动,主题切换时自然反转。 const letterStyle: React.CSSProperties = { ...baseStyle, background: "var(--avatar-bg)", color: "var(--avatar-fg)", fontFamily: "var(--font-sans)", fontWeight: 600, fontSize, lineHeight: 1, letterSpacing: 0, }; return {initial(name)}; }