cdrop — 跨 OS 剪贴板与文件传输服务
Commilitia Drop:自托管的跨设备剪贴板同步与点对点文件传输。 - 后端 Go(chi / SQLite WAL / SSE Hub / WebRTC signaling + 状态机 / Relay ring buffer),编译进单个 distroless 镜像(前端 go:embed)。 - 前端 React + TanStack Router + Zustand,自实现 SSE + WebRTC P2P,NAT 受阻时回退服务端中继;聚珍(Juzhen)CJK 综合排版。 - 桌面端 Wails v2(macOS / Windows),瘦客户端复用 web。 - 鉴权 OIDC PKCE(自建 Casdoor 等),refresh_token 信封加密存系统密钥库;iOS Shortcut 用 HS256 scoped token。 架构文档与变更记录见 docs 分支(PROJECT_BRIEF / FRONTEND_DESIGN / CHANGELOG)。 本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。
This commit is contained in:
@@ -0,0 +1,368 @@
|
||||
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 { 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(由各路由自渲染),
|
||||
// 这里只渲染 <Outlet/>,不包 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();
|
||||
// 桌面:本机复制 → 上传云端(复用 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 <Outlet />; }
|
||||
|
||||
return (
|
||||
<AppShell header={{ height: 56 }} padding="md">
|
||||
<AppShell.Header>
|
||||
<Group h="100%" px="md" justify="space-between" wrap="nowrap" gap="xs">
|
||||
<Link
|
||||
to="/"
|
||||
style={{
|
||||
textDecoration: "none",
|
||||
color: "inherit",
|
||||
minWidth: 0,
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
<Title
|
||||
order={4}
|
||||
fw={600}
|
||||
style={{ whiteSpace: "nowrap", fontSize: "var(--fs-22)" }}
|
||||
>
|
||||
<Wordmark />
|
||||
</Title>
|
||||
</Link>
|
||||
{user && (
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<ConnectionIcon connected={sseConnected} />
|
||||
<ThemeQuickToggle theme={theme} onChange={setTheme} />
|
||||
<Menu position="bottom-end" width={240} withArrow shadow="md">
|
||||
<Menu.Target>
|
||||
<UnstyledButton
|
||||
aria-label={t("nav.accountMenu")}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "4px 6px",
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
<UserAvatar
|
||||
name={user.name}
|
||||
avatarUrl={user.avatar}
|
||||
size={28}
|
||||
/>
|
||||
<Text size="sm" visibleFrom="sm" truncate maw={140}>
|
||||
{user.name}
|
||||
</Text>
|
||||
<ChevronDown size={14} />
|
||||
</UnstyledButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Stack gap={2} px="sm" pt="xs" pb={6}>
|
||||
<Text size="sm" fw={500} truncate>{user.name}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("nav.thisDeviceLabel")}
|
||||
<Text span fw={500}>{selfDeviceName}</Text>
|
||||
</Text>
|
||||
<Text
|
||||
size="xs"
|
||||
style={{ color: sseConnected ? "var(--state-online)" : "var(--text-muted)" }}
|
||||
>
|
||||
{sseConnected
|
||||
? `● ${t("app.connected")}`
|
||||
: `○ ${t("app.connecting")}`}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Menu.Divider />
|
||||
<Menu.Label>{t("nav.theme")}</Menu.Label>
|
||||
{/* System 列于最上 — 默认跟随系统,最常用。 */}
|
||||
<ThemeItem
|
||||
mode="system"
|
||||
label={t("nav.theme.system")}
|
||||
icon={<Monitor size={14} />}
|
||||
current={theme}
|
||||
onSelect={setTheme}
|
||||
/>
|
||||
<ThemeItem
|
||||
mode="light"
|
||||
label={t("nav.theme.light")}
|
||||
icon={<Sun size={14} />}
|
||||
current={theme}
|
||||
onSelect={setTheme}
|
||||
/>
|
||||
<ThemeItem
|
||||
mode="dark"
|
||||
label={t("nav.theme.dark")}
|
||||
icon={<Moon size={14} />}
|
||||
current={theme}
|
||||
onSelect={setTheme}
|
||||
/>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<Settings size={14} />}
|
||||
onClick={() => navigate({ to: "/settings" })}
|
||||
>
|
||||
{t("nav.settings")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Label>{t("nav.language")}</Menu.Label>
|
||||
<LocaleItem
|
||||
locale="zh-CN"
|
||||
label="简体中文"
|
||||
current={locale}
|
||||
onSelect={setLocale}
|
||||
/>
|
||||
<LocaleItem
|
||||
locale="zh-TW"
|
||||
label="繁體中文"
|
||||
current={locale}
|
||||
onSelect={setLocale}
|
||||
/>
|
||||
<LocaleItem
|
||||
locale="en-US"
|
||||
label="English"
|
||||
current={locale}
|
||||
onSelect={setLocale}
|
||||
/>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<LogOut size={14} />}
|
||||
onClick={handleLogout}
|
||||
>
|
||||
{t("nav.signOut")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
</AppShell.Header>
|
||||
<AppShell.Main>
|
||||
{sseReconnecting && (
|
||||
<div style={{ marginBottom: "var(--space-4)" }}>
|
||||
<Callout
|
||||
tone="error"
|
||||
icon={<WifiOff size={16} />}
|
||||
title={t("app.disconnectedTitle")}
|
||||
>
|
||||
{t("app.disconnectedHint")}
|
||||
</Callout>
|
||||
</div>
|
||||
)}
|
||||
<Outlet />
|
||||
</AppShell.Main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
// 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" ? <Sun size={16} />
|
||||
: theme === "dark" ? <Moon size={16} />
|
||||
: <Monitor size={16} />;
|
||||
const label = `${t("nav.themeToggle")}(${t(`nav.theme.${theme}` as const)} → ${t(`nav.theme.${next}` as const)})`;
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<IconButton
|
||||
aria-label={label}
|
||||
size="sm"
|
||||
onClick={() => onChange(next)}
|
||||
>
|
||||
{icon}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Menu.Item
|
||||
leftSection={active
|
||||
? <Check size={14} />
|
||||
: <span style={{ display: "inline-block", width: 14 }} />}
|
||||
rightSection={icon}
|
||||
onClick={() => { if (!active) { onSelect(mode); } }}
|
||||
>
|
||||
{label}
|
||||
</Menu.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function LocaleItem(props: {
|
||||
locale: Locale;
|
||||
label: string;
|
||||
current: Locale;
|
||||
onSelect: (l: Locale) => void;
|
||||
})
|
||||
{
|
||||
const { locale, label, current, onSelect } = props;
|
||||
const active = locale === current;
|
||||
return (
|
||||
<Menu.Item
|
||||
leftSection={active
|
||||
? <Check size={14} />
|
||||
: <span style={{ display: "inline-block", width: 14 }} />}
|
||||
onClick={() => { if (!active) { onSelect(locale); } }}
|
||||
>
|
||||
{label}
|
||||
</Menu.Item>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<span
|
||||
aria-label={label}
|
||||
title={label}
|
||||
style={{ display: "inline-flex", alignItems: "center" }}
|
||||
>
|
||||
{connected
|
||||
? <Wifi size={16} color={color} />
|
||||
: <WifiOff size={16} color={color} />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// 这些路由自己撑满 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 (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt=""
|
||||
referrerPolicy="no-referrer"
|
||||
onError={() => 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 <span style={letterStyle} aria-label={name}>{initial(name)}</span>;
|
||||
}
|
||||
Reference in New Issue
Block a user