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:
@@ -23,7 +23,9 @@
|
|||||||
- `frontend/components/` — UI,包括协调器与子组件。
|
- `frontend/components/` — UI,包括协调器与子组件。
|
||||||
|
|
||||||
- `frontend/components/ClipboardApp.tsx` — 顶层 UI 协调器,集成 5 个业务 hooks(useWebRTCConnection/useFileTransferHandler/useRoomManager/usePageSetup/useClipboardAppMessages),处理全局拖拽事件和双标签页(发送/接收)管理。
|
- `frontend/components/ClipboardApp.tsx` — 顶层 UI 协调器,集成 5 个业务 hooks(useWebRTCConnection/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(懒加载包装器)、YouTubePlayer(YouTube 播放器)。
|
- `frontend/components/common/` — 通用组件,包含 clipboard_btn(读写剪贴板按钮)、AutoPopupDialog(自动弹出对话框)、LazyLoadWrapper(懒加载包装器)、YouTubePlayer(YouTube 播放器)。
|
||||||
- `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 动画按钮。
|
||||||
|
|||||||
@@ -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`)。
|
||||||
|
|
||||||
### 前端组件架构特化
|
### 前端组件架构特化
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user