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 (
{user && (
)}
{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)};
}