feat:light/dark mode switch (#138)

Summary

- Adds browser theme detection on first visit using
prefers-color-scheme media query
- Renames localStorage key from dark-mode to
next-ai-draw-io-dark-mode for consistency with other keys
- Uses STORAGE_DIAGRAM_XML_KEY constant instead of hardcoded
string in diagram-context.tsx

Changes

app/page.tsx:
- On first visit (no saved preference), detect browser's color
scheme preference
- Update localStorage key to follow project naming convention
(next-ai-draw-io-*)

contexts/diagram-context.tsx:
- Import STORAGE_DIAGRAM_XML_KEY from chat-panel.tsx
- Replace hardcoded "next-ai-draw-io-diagram-xml" with the
constant
This commit is contained in:
try2love
2025-12-10 08:21:15 +08:00
committed by GitHub
parent 67b0adf211
commit 5da4ef67ec
7 changed files with 148 additions and 122 deletions

View File

@@ -106,7 +106,7 @@ export default function RootLayout({
} }
return ( return (
<html lang="en"> <html lang="en" suppressHydrationWarning>
<head> <head>
<script <script
type="application/ld+json" type="application/ld+json"

View File

@@ -15,32 +15,59 @@ const drawioBaseUrl =
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net" process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net"
export default function Home() { export default function Home() {
const { drawioRef, handleDiagramExport, onDrawioLoad } = useDiagram() const { drawioRef, handleDiagramExport, onDrawioLoad, resetDrawioReady } =
useDiagram()
const [isMobile, setIsMobile] = useState(false) const [isMobile, setIsMobile] = useState(false)
const [isChatVisible, setIsChatVisible] = useState(true) const [isChatVisible, setIsChatVisible] = useState(true)
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min") const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min")
const [isThemeLoaded, setIsThemeLoaded] = useState(false) const [darkMode, setDarkMode] = useState(false)
const [isLoaded, setIsLoaded] = useState(false)
// Load theme from localStorage after mount to avoid hydration mismatch
useEffect(() => {
const saved = localStorage.getItem("drawio-theme")
if (saved === "min" || saved === "sketch") {
setDrawioUi(saved)
}
setIsThemeLoaded(true)
}, [])
const [closeProtection, setCloseProtection] = useState(false) const [closeProtection, setCloseProtection] = useState(false)
// Load close protection setting from localStorage after mount
useEffect(() => {
const saved = localStorage.getItem(STORAGE_CLOSE_PROTECTION_KEY)
// Default to false since auto-save handles persistence
if (saved === "true") {
setCloseProtection(true)
}
}, [])
const chatPanelRef = useRef<ImperativePanelHandle>(null) const chatPanelRef = useRef<ImperativePanelHandle>(null)
// Load preferences from localStorage after mount
useEffect(() => {
const savedUi = localStorage.getItem("drawio-theme")
if (savedUi === "min" || savedUi === "sketch") {
setDrawioUi(savedUi)
}
const savedDarkMode = localStorage.getItem("next-ai-draw-io-dark-mode")
if (savedDarkMode !== null) {
// Use saved preference
const isDark = savedDarkMode === "true"
setDarkMode(isDark)
document.documentElement.classList.toggle("dark", isDark)
} else {
// First visit: match browser preference
const prefersDark = window.matchMedia(
"(prefers-color-scheme: dark)",
).matches
setDarkMode(prefersDark)
document.documentElement.classList.toggle("dark", prefersDark)
}
const savedCloseProtection = localStorage.getItem(
STORAGE_CLOSE_PROTECTION_KEY,
)
if (savedCloseProtection === "true") {
setCloseProtection(true)
}
setIsLoaded(true)
}, [])
const toggleDarkMode = () => {
const newValue = !darkMode
setDarkMode(newValue)
localStorage.setItem("next-ai-draw-io-dark-mode", String(newValue))
document.documentElement.classList.toggle("dark", newValue)
// Reset so onDrawioLoad fires again after remount
resetDrawioReady()
}
// Check mobile
useEffect(() => { useEffect(() => {
const checkMobile = () => { const checkMobile = () => {
setIsMobile(window.innerWidth < 768) setIsMobile(window.innerWidth < 768)
@@ -64,6 +91,7 @@ export default function Home() {
} }
} }
// Keyboard shortcut for toggling chat panel
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key === "b") { if ((event.ctrlKey || event.metaKey) && event.key === "b") {
@@ -77,7 +105,6 @@ export default function Home() {
}, []) }, [])
// Show confirmation dialog when user tries to leave the page // Show confirmation dialog when user tries to leave the page
// This helps prevent accidental navigation from browser back gestures
useEffect(() => { useEffect(() => {
if (!closeProtection) return if (!closeProtection) return
@@ -105,10 +132,10 @@ export default function Home() {
isMobile ? "p-1" : "p-2" isMobile ? "p-1" : "p-2"
}`} }`}
> >
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 bg-white"> <div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30">
{isThemeLoaded ? ( {isLoaded ? (
<DrawIoEmbed <DrawIoEmbed
key={drawioUi} key={`${drawioUi}-${darkMode}`}
ref={drawioRef} ref={drawioRef}
onExport={handleDiagramExport} onExport={handleDiagramExport}
onLoad={onDrawioLoad} onLoad={onDrawioLoad}
@@ -119,10 +146,11 @@ export default function Home() {
libraries: false, libraries: false,
saveAndExit: false, saveAndExit: false,
noExitBtn: true, noExitBtn: true,
dark: darkMode,
}} }}
/> />
) : ( ) : (
<div className="h-full w-full flex items-center justify-center"> <div className="h-full w-full flex items-center justify-center bg-background">
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" /> <div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" />
</div> </div>
)} )}
@@ -149,11 +177,14 @@ export default function Home() {
onToggleVisibility={toggleChatPanel} onToggleVisibility={toggleChatPanel}
drawioUi={drawioUi} drawioUi={drawioUi}
onToggleDrawioUi={() => { onToggleDrawioUi={() => {
const newTheme = const newUi =
drawioUi === "min" ? "sketch" : "min" drawioUi === "min" ? "sketch" : "min"
localStorage.setItem("drawio-theme", newTheme) localStorage.setItem("drawio-theme", newUi)
setDrawioUi(newTheme) setDrawioUi(newUi)
resetDrawioReady()
}} }}
darkMode={darkMode}
onToggleDarkMode={toggleDarkMode}
isMobile={isMobile} isMobile={isMobile}
onCloseProtectionChange={setCloseProtection} onCloseProtectionChange={setCloseProtection}
/> />

View File

@@ -4,9 +4,7 @@ import {
Download, Download,
History, History,
Image as ImageIcon, Image as ImageIcon,
LayoutGrid,
Loader2, Loader2,
PenTool,
Send, Send,
Trash2, Trash2,
} from "lucide-react" } from "lucide-react"
@@ -19,14 +17,6 @@ import { HistoryDialog } from "@/components/history-dialog"
import { ResetWarningModal } from "@/components/reset-warning-modal" import { ResetWarningModal } from "@/components/reset-warning-modal"
import { SaveDialog } from "@/components/save-dialog" import { SaveDialog } from "@/components/save-dialog"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { useDiagram } from "@/contexts/diagram-context" import { useDiagram } from "@/contexts/diagram-context"
import { FilePreviewList } from "./file-preview-list" import { FilePreviewList } from "./file-preview-list"
@@ -123,8 +113,6 @@ interface ChatInputProps {
onToggleHistory?: (show: boolean) => void onToggleHistory?: (show: boolean) => void
sessionId?: string sessionId?: string
error?: Error | null error?: Error | null
drawioUi?: "min" | "sketch"
onToggleDrawioUi?: () => void
} }
export function ChatInput({ export function ChatInput({
@@ -139,8 +127,6 @@ export function ChatInput({
onToggleHistory = () => {}, onToggleHistory = () => {},
sessionId, sessionId,
error = null, error = null,
drawioUi = "min",
onToggleDrawioUi = () => {},
}: ChatInputProps) { }: ChatInputProps) {
const { diagramHistory, saveDiagramToFile } = useDiagram() const { diagramHistory, saveDiagramToFile } = useDiagram()
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
@@ -148,7 +134,6 @@ export function ChatInput({
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
const [showClearDialog, setShowClearDialog] = useState(false) const [showClearDialog, setShowClearDialog] = useState(false)
const [showSaveDialog, setShowSaveDialog] = useState(false) const [showSaveDialog, setShowSaveDialog] = useState(false)
const [showThemeWarning, setShowThemeWarning] = useState(false)
// Allow retry when there's an error (even if status is still "streaming" or "submitted") // Allow retry when there's an error (even if status is still "streaming" or "submitted")
const isDisabled = const isDisabled =
@@ -337,60 +322,6 @@ export function ChatInput({
showHistory={showHistory} showHistory={showHistory}
onToggleHistory={onToggleHistory} onToggleHistory={onToggleHistory}
/> />
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowThemeWarning(true)}
tooltipContent={
drawioUi === "min"
? "Switch to Sketch theme"
: "Switch to Minimal theme"
}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
{drawioUi === "min" ? (
<PenTool className="h-4 w-4" />
) : (
<LayoutGrid className="h-4 w-4" />
)}
</ButtonWithTooltip>
<Dialog
open={showThemeWarning}
onOpenChange={setShowThemeWarning}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Switch Theme?</DialogTitle>
<DialogDescription>
Switching themes will reload the diagram
editor and clear any unsaved changes.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() =>
setShowThemeWarning(false)
}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
onClearChat()
onToggleDrawioUi()
setShowThemeWarning(false)
}}
>
Switch Theme
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
{/* Right actions */} {/* Right actions */}

View File

@@ -135,6 +135,7 @@ export function ChatMessageDisplay({
const copyMessageToClipboard = async (messageId: string, text: string) => { const copyMessageToClipboard = async (messageId: string, text: string) => {
try { try {
await navigator.clipboard.writeText(text) await navigator.clipboard.writeText(text)
setCopiedMessageId(messageId) setCopiedMessageId(messageId)
setTimeout(() => setCopiedMessageId(null), 2000) setTimeout(() => setCopiedMessageId(null), 2000)
} catch (err) { } catch (err) {

View File

@@ -34,7 +34,7 @@ import {
const STORAGE_MESSAGES_KEY = "next-ai-draw-io-messages" const STORAGE_MESSAGES_KEY = "next-ai-draw-io-messages"
const STORAGE_XML_SNAPSHOTS_KEY = "next-ai-draw-io-xml-snapshots" const STORAGE_XML_SNAPSHOTS_KEY = "next-ai-draw-io-xml-snapshots"
const STORAGE_SESSION_ID_KEY = "next-ai-draw-io-session-id" const STORAGE_SESSION_ID_KEY = "next-ai-draw-io-session-id"
const STORAGE_DIAGRAM_XML_KEY = "next-ai-draw-io-diagram-xml" export const STORAGE_DIAGRAM_XML_KEY = "next-ai-draw-io-diagram-xml"
const STORAGE_REQUEST_COUNT_KEY = "next-ai-draw-io-request-count" const STORAGE_REQUEST_COUNT_KEY = "next-ai-draw-io-request-count"
const STORAGE_REQUEST_DATE_KEY = "next-ai-draw-io-request-date" const STORAGE_REQUEST_DATE_KEY = "next-ai-draw-io-request-date"
const STORAGE_TOKEN_COUNT_KEY = "next-ai-draw-io-token-count" const STORAGE_TOKEN_COUNT_KEY = "next-ai-draw-io-token-count"
@@ -52,6 +52,8 @@ interface ChatPanelProps {
onToggleVisibility: () => void onToggleVisibility: () => void
drawioUi: "min" | "sketch" drawioUi: "min" | "sketch"
onToggleDrawioUi: () => void onToggleDrawioUi: () => void
darkMode: boolean
onToggleDarkMode: () => void
isMobile?: boolean isMobile?: boolean
onCloseProtectionChange?: (enabled: boolean) => void onCloseProtectionChange?: (enabled: boolean) => void
} }
@@ -61,6 +63,8 @@ export default function ChatPanel({
onToggleVisibility, onToggleVisibility,
drawioUi, drawioUi,
onToggleDrawioUi, onToggleDrawioUi,
darkMode,
onToggleDarkMode,
isMobile = false, isMobile = false,
onCloseProtectionChange, onCloseProtectionChange,
}: ChatPanelProps) { }: ChatPanelProps) {
@@ -582,13 +586,13 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
const hasDiagramRestoredRef = useRef(false) const hasDiagramRestoredRef = useRef(false)
const [canSaveDiagram, setCanSaveDiagram] = useState(false) const [canSaveDiagram, setCanSaveDiagram] = useState(false)
useEffect(() => { useEffect(() => {
console.log( // Reset restore flag when DrawIO is not ready (e.g., theme/UI change remounts it)
"[ChatPanel] isDrawioReady:", if (!isDrawioReady) {
isDrawioReady, hasDiagramRestoredRef.current = false
"hasDiagramRestored:", setCanSaveDiagram(false)
hasDiagramRestoredRef.current, return
) }
if (!isDrawioReady || hasDiagramRestoredRef.current) return if (hasDiagramRestoredRef.current) return
hasDiagramRestoredRef.current = true hasDiagramRestoredRef.current = true
try { try {
@@ -629,6 +633,14 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
} }
}, [messages]) }, [messages])
// Save diagram XML to localStorage whenever it changes
useEffect(() => {
if (!canSaveDiagram) return
if (chartXML && chartXML.length > 300) {
localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, chartXML)
}
}, [chartXML, canSaveDiagram])
// Save XML snapshots to localStorage whenever they change // Save XML snapshots to localStorage whenever they change
const saveXmlSnapshots = useCallback(() => { const saveXmlSnapshots = useCallback(() => {
try { try {
@@ -650,20 +662,6 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
localStorage.setItem(STORAGE_SESSION_ID_KEY, sessionId) localStorage.setItem(STORAGE_SESSION_ID_KEY, sessionId)
}, [sessionId]) }, [sessionId])
// Save current diagram XML to localStorage whenever it changes
// Only save after initial restore is complete and if it's not an empty diagram
useEffect(() => {
if (!canSaveDiagram) return
// Don't save empty diagrams (check for minimal content)
if (chartXML && chartXML.length > 300) {
console.log(
"[ChatPanel] Saving diagram to localStorage, length:",
chartXML.length,
)
localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, chartXML)
}
}, [chartXML, canSaveDiagram])
useEffect(() => { useEffect(() => {
if (messagesEndRef.current) { if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: "smooth" }) messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
@@ -1204,8 +1202,6 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
onToggleHistory={setShowHistory} onToggleHistory={setShowHistory}
sessionId={sessionId} sessionId={sessionId}
error={error} error={error}
drawioUi={drawioUi}
onToggleDrawioUi={onToggleDrawioUi}
/> />
</footer> </footer>
@@ -1213,6 +1209,10 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
open={showSettingsDialog} open={showSettingsDialog}
onOpenChange={setShowSettingsDialog} onOpenChange={setShowSettingsDialog}
onCloseProtectionChange={onCloseProtectionChange} onCloseProtectionChange={onCloseProtectionChange}
drawioUi={drawioUi}
onToggleDrawioUi={onToggleDrawioUi}
darkMode={darkMode}
onToggleDarkMode={onToggleDarkMode}
/> />
</div> </div>
) )

View File

@@ -1,5 +1,6 @@
"use client" "use client"
import { Moon, Sun } from "lucide-react"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
@@ -24,6 +25,10 @@ interface SettingsDialogProps {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
onCloseProtectionChange?: (enabled: boolean) => void onCloseProtectionChange?: (enabled: boolean) => void
drawioUi: "min" | "sketch"
onToggleDrawioUi: () => void
darkMode: boolean
onToggleDarkMode: () => void
} }
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code" export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
@@ -45,6 +50,10 @@ export function SettingsDialog({
open, open,
onOpenChange, onOpenChange,
onCloseProtectionChange, onCloseProtectionChange,
drawioUi,
onToggleDrawioUi,
darkMode,
onToggleDarkMode,
}: SettingsDialogProps) { }: SettingsDialogProps) {
const [accessCode, setAccessCode] = useState("") const [accessCode, setAccessCode] = useState("")
const [closeProtection, setCloseProtection] = useState(true) const [closeProtection, setCloseProtection] = useState(true)
@@ -357,6 +366,47 @@ export function SettingsDialog({
)} )}
</div> </div>
</div> </div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="theme-toggle">Theme</Label>
<p className="text-[0.8rem] text-muted-foreground">
Dark/Light mode for interface and DrawIO canvas.
</p>
</div>
<Button
id="theme-toggle"
variant="outline"
size="icon"
onClick={onToggleDarkMode}
>
{darkMode ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
</Button>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="drawio-ui">DrawIO Style</Label>
<p className="text-[0.8rem] text-muted-foreground">
Canvas style:{" "}
{drawioUi === "min" ? "Minimal" : "Sketch"}
</p>
</div>
<Button
id="drawio-ui"
variant="outline"
size="sm"
onClick={onToggleDrawioUi}
>
Switch to{" "}
{drawioUi === "min" ? "Sketch" : "Minimal"}
</Button>
</div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label htmlFor="close-protection"> <Label htmlFor="close-protection">

View File

@@ -3,6 +3,7 @@
import type React from "react" import type React from "react"
import { createContext, useContext, useRef, useState } from "react" import { createContext, useContext, useRef, useState } from "react"
import type { DrawIoEmbedRef } from "react-drawio" import type { DrawIoEmbedRef } from "react-drawio"
import { STORAGE_DIAGRAM_XML_KEY } from "@/components/chat-panel"
import type { ExportFormat } from "@/components/save-dialog" import type { ExportFormat } from "@/components/save-dialog"
import { extractDiagramXML, validateMxCellStructure } from "../lib/utils" import { extractDiagramXML, validateMxCellStructure } from "../lib/utils"
@@ -24,6 +25,7 @@ interface DiagramContextType {
) => void ) => void
isDrawioReady: boolean isDrawioReady: boolean
onDrawioLoad: () => void onDrawioLoad: () => void
resetDrawioReady: () => void
} }
const DiagramContext = createContext<DiagramContextType | undefined>(undefined) const DiagramContext = createContext<DiagramContextType | undefined>(undefined)
@@ -45,9 +47,16 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
// Only set ready state once to prevent infinite loops // Only set ready state once to prevent infinite loops
if (hasCalledOnLoadRef.current) return if (hasCalledOnLoadRef.current) return
hasCalledOnLoadRef.current = true hasCalledOnLoadRef.current = true
console.log("[DiagramContext] DrawIO loaded, setting ready state") // console.log("[DiagramContext] DrawIO loaded, setting ready state")
setIsDrawioReady(true) setIsDrawioReady(true)
} }
const resetDrawioReady = () => {
// console.log("[DiagramContext] Resetting DrawIO ready state")
hasCalledOnLoadRef.current = false
setIsDrawioReady(false)
}
// Track if we're expecting an export for file save (stores raw export data) // Track if we're expecting an export for file save (stores raw export data)
const saveResolverRef = useRef<{ const saveResolverRef = useRef<{
resolver: ((data: string) => void) | null resolver: ((data: string) => void) | null
@@ -171,6 +180,9 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
fileContent = xmlContent fileContent = xmlContent
mimeType = "application/xml" mimeType = "application/xml"
extension = ".drawio" extension = ".drawio"
// Save to localStorage when user manually saves
localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, xmlContent)
} else if (format === "png") { } else if (format === "png") {
// PNG data comes as base64 data URL // PNG data comes as base64 data URL
fileContent = exportData fileContent = exportData
@@ -251,6 +263,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
saveDiagramToFile, saveDiagramToFile,
isDrawioReady, isDrawioReady,
onDrawioLoad, onDrawioLoad,
resetDrawioReady,
}} }}
> >
{children} {children}