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
+30
View File
@@ -14,6 +14,7 @@ import { RetrieveTabPanel } from "./ClipboardApp/RetrieveTabPanel";
import FullScreenDropZone from "./ClipboardApp/FullScreenDropZone";
import { traverseFileTree } from "@/lib/fileUtils";
import { useFileTransferStore } from "@/stores/fileTransferStore";
import { getCachedId } from "@/lib/roomIdCache";
const ClipboardApp = () => {
const { shareMessage, retrieveMessage, putMessageInMs } =
@@ -37,6 +38,9 @@ const ClipboardApp = () => {
setIsDragging,
setRetrieveRoomIdInput,
setActiveTab,
// for auto-join on receiver side
isReceiverInRoom,
retrieveRoomIdInput,
} = useFileTransferStore();
const richTextToPlainText = useRichTextToPlainText();
@@ -144,6 +148,32 @@ const ClipboardApp = () => {
};
}, [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) {
return (
<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";
dblClickWindowMs?: number; // default 400ms
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({
@@ -75,6 +79,8 @@ export default function CachedIdActionButton({
size = "default",
dblClickWindowMs = 400,
saveModeDurationMs = 3000,
onUseCached,
disabled = false,
}: Props) {
const [hasCachedId, setHasCachedId] = useState<boolean>(false);
const [showSaveOverride, setShowSaveOverride] = useState<boolean>(false);
@@ -128,6 +134,8 @@ export default function CachedIdActionButton({
const cached = getCachedId();
if (cached) {
setInputValue(cached);
// Notify caller after applying cached value
onUseCached?.(cached);
}
}
clickCountRef.current = 0;
@@ -161,6 +169,7 @@ export default function CachedIdActionButton({
isShareEnd,
dblClickWindowMs,
saveModeDurationMs,
onUseCached,
]);
return (
@@ -177,7 +186,9 @@ export default function CachedIdActionButton({
variant={variant}
size={size}
onClick={handleClick}
disabled={isSaveMode ? !isSaveEnabled : !hasCachedId}
disabled={
disabled || (isSaveMode ? !isSaveEnabled : !hasCachedId)
}
>
{isSaveMode
? messages.text.ClipboardApp.html.saveId_dis
@@ -156,7 +156,7 @@ export function RetrieveTabPanel({
</div>
{retrievedContent && (
<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>
<div className="flex justify-start">
@@ -197,6 +197,11 @@ export function SendTabPanel({
setInputValue={setInputFieldValue}
putMessageInMs={putMessageInMs}
isShareEnd={true}
disabled={isSenderInRoom}
onUseCached={(id) => {
// Immediately join as sender after applying cached ID
joinRoom(true, id.trim());
}}
/>
<Button
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 LanguageSwitcher from "@/components/LanguageSwitcher";
import { Messages } from "@/types/messages";
import ThemeToggle from "@/components/web/ThemeToggle";
/**
* Props interface for the Header component
@@ -91,6 +92,7 @@ const Header = ({ messages, lang }: HeaderProps) => {
<Github className="h-5 w-5" />
</Link>
</Button>
<ThemeToggle />
<LanguageSwitcher />
</div>
</div>
@@ -98,6 +100,7 @@ const Header = ({ messages, lang }: HeaderProps) => {
{/* Mobile menu controls */}
<div className="md:hidden flex items-center space-x-2">
<LanguageSwitcher />
<ThemeToggle />
<Button asChild variant="ghost" size="icon">
<Link
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>
);
}