feat(ux): cached roomId auto-join + theme toggle

- Receiver: auto-fill and join on Retrieve tab when input empty, not in room, no URL roomId, and cachedId exists (ClipboardApp + roomIdCache)
  - Sender: “Use cached ID” now immediately joins the room (add onUseCached + disabled to CachedIdActionButton; wire in SendTabPanel)
  - UI: add ThemeToggle and integrate into Header (desktop and mobile)
  - Styles: replace hardcoded white with design tokens in Retrieve panel (bg-card/text-card-foreground) for dark mode
  - Docs: update AI playbook flows and code-map
This commit is contained in:
david_bai
2025-11-25 12:24:28 +08:00
parent 10f236dc8d
commit 723a1ea086
8 changed files with 87 additions and 2 deletions
+3
View File
@@ -23,7 +23,9 @@
- `frontend/components/` — UI,包括协调器与子组件。 - `frontend/components/` — UI,包括协调器与子组件。
- `frontend/components/ClipboardApp.tsx` — 顶层 UI 协调器,集成 5 个业务 hooksuseWebRTCConnection/useFileTransferHandler/useRoomManager/usePageSetup/useClipboardAppMessages),处理全局拖拽事件和双标签页(发送/接收)管理。 - `frontend/components/ClipboardApp.tsx` — 顶层 UI 协调器,集成 5 个业务 hooksuseWebRTCConnection/useFileTransferHandler/useRoomManager/usePageSetup/useClipboardAppMessages),处理全局拖拽事件和双标签页(发送/接收)管理。
- 体验增强:切到接收端(retrieve)且满足“未在房间、URL 无 roomId、输入为空、存在缓存ID”时自动填充并加入房间(读取 `frontend/lib/roomIdCache.ts`)。
- `frontend/components/ClipboardApp/SendTabPanel.tsx` — 发送面板,集成富文本编辑器、文件上传、房间 ID 生成(支持 4 位数字/UUID 两种模式)、分享链接生成。 - `frontend/components/ClipboardApp/SendTabPanel.tsx` — 发送面板,集成富文本编辑器、文件上传、房间 ID 生成(支持 4 位数字/UUID 两种模式)、分享链接生成。
- 体验增强:点击“使用缓存ID”将立即触发加入房间(sender 侧),减少一次手动点击。
- `frontend/components/ClipboardApp/RetrieveTabPanel.tsx` — 接收面板,处理房间加入、文件接收、目录选择(File System Access API)、富文本内容显示。 - `frontend/components/ClipboardApp/RetrieveTabPanel.tsx` — 接收面板,处理房间加入、文件接收、目录选择(File System Access API)、富文本内容显示。
- `frontend/components/ClipboardApp/FileListDisplay.tsx` — 文件列表显示组件,支持文件/文件夹分组显示、进度跟踪、多浏览器下载策略(Chrome 自动下载/其他浏览器手动保存)、下载计数统计。 - `frontend/components/ClipboardApp/FileListDisplay.tsx` — 文件列表显示组件,支持文件/文件夹分组显示、进度跟踪、多浏览器下载策略(Chrome 自动下载/其他浏览器手动保存)、下载计数统计。
- `frontend/components/ClipboardApp/FullScreenDropZone.tsx` — 全屏拖拽提示组件,文件拖拽时的视觉反馈。 - `frontend/components/ClipboardApp/FullScreenDropZone.tsx` — 全屏拖拽提示组件,文件拖拽时的视觉反馈。
@@ -32,6 +34,7 @@
- `frontend/components/blog/` — 博客相关组件,包含 TableOfContents(支持中文目录生成和滚动跟踪)、Mermaid 图表渲染、MDXComponents、ArticleListItem 文章列表。 - `frontend/components/blog/` — 博客相关组件,包含 TableOfContents(支持中文目录生成和滚动跟踪)、Mermaid 图表渲染、MDXComponents、ArticleListItem 文章列表。
- `frontend/components/common/` — 通用组件,包含 clipboard_btn(读写剪贴板按钮)、AutoPopupDialog(自动弹出对话框)、LazyLoadWrapper(懒加载包装器)、YouTubePlayerYouTube 播放器)。 - `frontend/components/common/` — 通用组件,包含 clipboard_btn(读写剪贴板按钮)、AutoPopupDialog(自动弹出对话框)、LazyLoadWrapper(懒加载包装器)、YouTubePlayerYouTube 播放器)。
- `frontend/components/web/` — 网站页面组件,包含 Header(响应式导航和多语言支持)、Footer(版权和语言链接)、FAQSection(可配置 FAQ 展示)、HowItWorks(步骤说明和视频演示)、SystemDiagram(系统架构图)、KeyFeatures(功能特性展示)、theme-provider 主题提供者。 - `frontend/components/web/` — 网站页面组件,包含 Header(响应式导航和多语言支持)、Footer(版权和语言链接)、FAQSection(可配置 FAQ 展示)、HowItWorks(步骤说明和视频演示)、SystemDiagram(系统架构图)、KeyFeatures(功能特性展示)、theme-provider 主题提供者。
- `frontend/components/web/ThemeToggle.tsx` — 主题切换按钮(单按钮 Light/Dark 切换),集成于 Header(桌面与移动)。
- `frontend/components/seo/JsonLd.tsx` — SEO 结构化数据组件,支持多类型 JSON-LD 数据生成。 - `frontend/components/seo/JsonLd.tsx` — SEO 结构化数据组件,支持多类型 JSON-LD 数据生成。
- `frontend/components/LanguageSwitcher.tsx` — 语言切换器。 - `frontend/components/LanguageSwitcher.tsx` — 语言切换器。
- `frontend/components/ui/*` — 基础 UI 原子组件(基于 Radix UI 和 shadcn/ui),包含 Button(多变体按钮)、Accordion(手风琴)、Dialog(模态对话框)、Card(卡片)、Tooltip(工具提示)、Select、Input、Textarea、Checkbox、DropdownMenu、Toast 通知系统和 AnimatedButton 动画按钮。 - `frontend/components/ui/*` — 基础 UI 原子组件(基于 Radix UI 和 shadcn/ui),包含 Button(多变体按钮)、Accordion(手风琴)、Dialog(模态对话框)、Card(卡片)、Tooltip(工具提示)、Select、Input、Textarea、Checkbox、DropdownMenu、Toast 通知系统和 AnimatedButton 动画按钮。
+3
View File
@@ -248,6 +248,9 @@ Core Services (webrtcService) + Store (fileTransferStore)
7. **剪贴板兼容性**useClipboardActions 支持现代 navigator.clipboard API 和 document.execCommand 降级方案 7. **剪贴板兼容性**useClipboardActions 支持现代 navigator.clipboard API 和 document.execCommand 降级方案
8. **富文本安全处理**useRichTextToPlainText 服务端渲染安全,客户端 DOM 转换处理块级元素 8. **富文本安全处理**useRichTextToPlainText 服务端渲染安全,客户端 DOM 转换处理块级元素
9. **站内导航不中断(同一标签页)**:依赖 `frontend/stores/fileTransferStore.ts`Zustand 单例)与 `frontend/lib/webrtcService.ts`(服务单例)。App Router 页面切换不打断传输且保留已选择/已接收内容。注意不要在路由切换副作用中调用 `webrtcService.leaveRoom()` 或重置 Store;刷新/新标签不在保证范围内。 9. **站内导航不中断(同一标签页)**:依赖 `frontend/stores/fileTransferStore.ts`Zustand 单例)与 `frontend/lib/webrtcService.ts`(服务单例)。App Router 页面切换不打断传输且保留已选择/已接收内容。注意不要在路由切换副作用中调用 `webrtcService.leaveRoom()` 或重置 Store;刷新/新标签不在保证范围内。
10. **切到接收端自动加入(缓存ID**:当用户切换到接收端、未在房间、URL 无 `roomId`、输入框为空且本地存在缓存 ID 时,自动填充并直接调用加入房间以提升体验。入口:`frontend/components/ClipboardApp.tsx`(监听 `activeTab` 变化,读取 `frontend/lib/roomIdCache.ts`)。
11. **发送端“使用缓存ID”即刻加入**:发送端在 `SendTabPanel` 点击“使用缓存ID”后会立即调用加入房间(而非仅填充输入框)。入口:`frontend/components/ClipboardApp/CachedIdActionButton.tsx``onUseCached` 回调)+ `frontend/components/ClipboardApp/SendTabPanel.tsx`
12. **深色主题切换**:提供单按钮 Light/Dark 切换,入口:`frontend/components/web/ThemeToggle.tsx`;集成位置:`frontend/components/web/Header.tsx`(桌面与移动);局部样式从硬编码颜色迁移为设计令牌(例如接收面板使用 `bg-card text-card-foreground`)。
### 前端组件架构特化 ### 前端组件架构特化
+30
View File
@@ -14,6 +14,7 @@ import { RetrieveTabPanel } from "./ClipboardApp/RetrieveTabPanel";
import FullScreenDropZone from "./ClipboardApp/FullScreenDropZone"; import FullScreenDropZone from "./ClipboardApp/FullScreenDropZone";
import { traverseFileTree } from "@/lib/fileUtils"; import { traverseFileTree } from "@/lib/fileUtils";
import { useFileTransferStore } from "@/stores/fileTransferStore"; import { useFileTransferStore } from "@/stores/fileTransferStore";
import { getCachedId } from "@/lib/roomIdCache";
const ClipboardApp = () => { const ClipboardApp = () => {
const { shareMessage, retrieveMessage, putMessageInMs } = const { shareMessage, retrieveMessage, putMessageInMs } =
@@ -37,6 +38,9 @@ const ClipboardApp = () => {
setIsDragging, setIsDragging,
setRetrieveRoomIdInput, setRetrieveRoomIdInput,
setActiveTab, setActiveTab,
// for auto-join on receiver side
isReceiverInRoom,
retrieveRoomIdInput,
} = useFileTransferStore(); } = useFileTransferStore();
const richTextToPlainText = useRichTextToPlainText(); const richTextToPlainText = useRichTextToPlainText();
@@ -144,6 +148,32 @@ const ClipboardApp = () => {
}; };
}, [activeTab, handleFileDrop, setIsDragging]); }, [activeTab, handleFileDrop, setIsDragging]);
// Auto-join on switching to receiver tab when cached ID exists
useEffect(() => {
if (activeTab !== "retrieve") return;
if (isReceiverInRoom) return;
// Do not auto-join if URL already specifies a roomId (URL 优先)
const params = new URLSearchParams(window.location.search);
if (params.get("roomId")) return;
// Do not override user's existing input
if ((retrieveRoomIdInput || "").trim().length > 0) return;
const cached = getCachedId();
if (!cached || cached.trim().length === 0) return;
// Fill input then join directly to improve UX
setRetrieveRoomIdInput(cached);
joinRoom(false, cached);
}, [
activeTab,
isReceiverInRoom,
retrieveRoomIdInput,
setRetrieveRoomIdInput,
joinRoom,
]);
if (isLoadingMessages || !messages) { if (isLoadingMessages || !messages) {
return ( return (
<div className="container mx-auto px-4 py-8 w-full md:max-w-4xl"> <div className="container mx-auto px-4 py-8 w-full md:max-w-4xl">
@@ -62,6 +62,10 @@ type Props = {
size?: "default" | "sm" | "lg" | "icon"; size?: "default" | "sm" | "lg" | "icon";
dblClickWindowMs?: number; // default 400ms dblClickWindowMs?: number; // default 400ms
saveModeDurationMs?: number; // default 3000ms saveModeDurationMs?: number; // default 3000ms
// Optional: called after a cached ID is applied (single-click "Use cached ID")
onUseCached?: (cachedId: string) => void;
// Optional: external disabled flag
disabled?: boolean;
}; };
export default function CachedIdActionButton({ export default function CachedIdActionButton({
@@ -75,6 +79,8 @@ export default function CachedIdActionButton({
size = "default", size = "default",
dblClickWindowMs = 400, dblClickWindowMs = 400,
saveModeDurationMs = 3000, saveModeDurationMs = 3000,
onUseCached,
disabled = false,
}: Props) { }: Props) {
const [hasCachedId, setHasCachedId] = useState<boolean>(false); const [hasCachedId, setHasCachedId] = useState<boolean>(false);
const [showSaveOverride, setShowSaveOverride] = useState<boolean>(false); const [showSaveOverride, setShowSaveOverride] = useState<boolean>(false);
@@ -128,6 +134,8 @@ export default function CachedIdActionButton({
const cached = getCachedId(); const cached = getCachedId();
if (cached) { if (cached) {
setInputValue(cached); setInputValue(cached);
// Notify caller after applying cached value
onUseCached?.(cached);
} }
} }
clickCountRef.current = 0; clickCountRef.current = 0;
@@ -161,6 +169,7 @@ export default function CachedIdActionButton({
isShareEnd, isShareEnd,
dblClickWindowMs, dblClickWindowMs,
saveModeDurationMs, saveModeDurationMs,
onUseCached,
]); ]);
return ( return (
@@ -177,7 +186,9 @@ export default function CachedIdActionButton({
variant={variant} variant={variant}
size={size} size={size}
onClick={handleClick} onClick={handleClick}
disabled={isSaveMode ? !isSaveEnabled : !hasCachedId} disabled={
disabled || (isSaveMode ? !isSaveEnabled : !hasCachedId)
}
> >
{isSaveMode {isSaveMode
? messages.text.ClipboardApp.html.saveId_dis ? messages.text.ClipboardApp.html.saveId_dis
@@ -156,7 +156,7 @@ export function RetrieveTabPanel({
</div> </div>
{retrievedContent && ( {retrievedContent && (
<div className="my-3 p-3 border rounded-md"> <div className="my-3 p-3 border rounded-md">
<div className="bg-white p-3 rounded border border-gray-200 text-sm leading-relaxed"> <div className="bg-card text-card-foreground p-3 rounded border text-sm leading-relaxed">
<div dangerouslySetInnerHTML={{ __html: retrievedContent }} /> <div dangerouslySetInnerHTML={{ __html: retrievedContent }} />
</div> </div>
<div className="flex justify-start"> <div className="flex justify-start">
@@ -197,6 +197,11 @@ export function SendTabPanel({
setInputValue={setInputFieldValue} setInputValue={setInputFieldValue}
putMessageInMs={putMessageInMs} putMessageInMs={putMessageInMs}
isShareEnd={true} isShareEnd={true}
disabled={isSenderInRoom}
onUseCached={(id) => {
// Immediately join as sender after applying cached ID
joinRoom(true, id.trim());
}}
/> />
<Button <Button
className="w-full sm:w-auto px-4" className="w-full sm:w-auto px-4"
+3
View File
@@ -8,6 +8,7 @@ import Image from "next/image";
import { Menu, X, Github } from "lucide-react"; import { Menu, X, Github } from "lucide-react";
import LanguageSwitcher from "@/components/LanguageSwitcher"; import LanguageSwitcher from "@/components/LanguageSwitcher";
import { Messages } from "@/types/messages"; import { Messages } from "@/types/messages";
import ThemeToggle from "@/components/web/ThemeToggle";
/** /**
* Props interface for the Header component * Props interface for the Header component
@@ -91,6 +92,7 @@ const Header = ({ messages, lang }: HeaderProps) => {
<Github className="h-5 w-5" /> <Github className="h-5 w-5" />
</Link> </Link>
</Button> </Button>
<ThemeToggle />
<LanguageSwitcher /> <LanguageSwitcher />
</div> </div>
</div> </div>
@@ -98,6 +100,7 @@ const Header = ({ messages, lang }: HeaderProps) => {
{/* Mobile menu controls */} {/* Mobile menu controls */}
<div className="md:hidden flex items-center space-x-2"> <div className="md:hidden flex items-center space-x-2">
<LanguageSwitcher /> <LanguageSwitcher />
<ThemeToggle />
<Button asChild variant="ghost" size="icon"> <Button asChild variant="ghost" size="icon">
<Link <Link
href={githubUrl} href={githubUrl}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { Button } from "@/components/ui/button";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
export default function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const isDark = resolvedTheme === "dark";
const toggle = () => setTheme(isDark ? "light" : "dark");
return (
<Button
variant="ghost"
size="icon"
aria-label="Toggle theme"
onClick={toggle}
disabled={!mounted}
>
{isDark ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
<span className="sr-only">Toggle theme</span>
</Button>
);
}