Add i18n support, language toggle UI, and translate Settings dialog (#334)

* i18n support added

* fix: align i18n implementation with Next.js 16 guide

- Rename middleware.ts to proxy.ts (Next.js 16 convention)
- Fix params type to Promise<{lang: string}> for layout/metadata
- Add 'server-only' directive and dynamic imports to dictionaries.ts
- Add hasLocale type guard and notFound() for invalid locales
- Wrap LanguageToggle in Suspense for useSearchParams
- Fix dictionary key mismatch (learnmore -> learnMore)
- Improve Chinese translations per Gemini review:
  - loading ellipsis, new -> 新建, styledMode -> 精致
  - goodResponse/badResponse -> 有帮助/无帮助
  - closeProtection -> 关闭确认, fileExceeds phrasing
- Improve Japanese translations per Gemini review:
  - closeProtection -> ページ離脱確認
  - invalidAccessCode phrasing, appendDiagram -> に追加
  - styledMode -> スタイル付き

---------

Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
This commit is contained in:
Biki Kalita
2025-12-20 20:18:54 +05:30
committed by GitHub
parent f087b54ee4
commit 378bef435e
26 changed files with 1235 additions and 307 deletions

View File

@@ -8,6 +8,7 @@ import {
Terminal,
Zap,
} from "lucide-react"
import { useDictionary } from "@/hooks/use-dictionary"
interface ExampleCardProps {
icon: React.ReactNode
@@ -24,6 +25,8 @@ function ExampleCard({
onClick,
isNew,
}: ExampleCardProps) {
const dict = useDictionary()
return (
<button
onClick={onClick}
@@ -50,7 +53,7 @@ function ExampleCard({
</h3>
{isNew && (
<span className="px-1.5 py-0.5 text-[10px] font-semibold bg-primary text-primary-foreground rounded">
NEW
{dict.common.new}
</span>
)}
</div>
@@ -70,6 +73,8 @@ export default function ExamplePanel({
setInput: (input: string) => void
setFiles: (files: File[]) => void
}) {
const dict = useDictionary()
const handleReplicateFlowchart = async () => {
setInput("Replicate this flowchart.")
@@ -79,7 +84,7 @@ export default function ExamplePanel({
const file = new File([blob], "example.png", { type: "image/png" })
setFiles([file])
} catch (error) {
console.error("Error loading example image:", error)
console.error(dict.errors.failedToLoadExample, error)
}
}
@@ -94,7 +99,7 @@ export default function ExamplePanel({
})
setFiles([file])
} catch (error) {
console.error("Error loading architecture image:", error)
console.error(dict.errors.failedToLoadExample, error)
}
}
@@ -109,7 +114,7 @@ export default function ExamplePanel({
})
setFiles([file])
} catch (error) {
console.error("Error loading text file:", error)
console.error(dict.errors.failedToLoadExample, error)
}
}
@@ -129,14 +134,14 @@ export default function ExamplePanel({
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground group-hover:text-purple-500 transition-colors">
MCP Server
{dict.examples.mcpServer}
</span>
<span className="px-1.5 py-0.5 text-[10px] font-semibold bg-purple-500 text-white rounded">
PREVIEW
{dict.examples.preview}
</span>
</div>
<p className="text-xs text-muted-foreground">
Use in Claude Desktop, VS Code & Cursor
{dict.examples.mcpDescription}
</p>
</div>
</div>
@@ -145,33 +150,32 @@ export default function ExamplePanel({
{/* Welcome section */}
<div className="text-center mb-6">
<h2 className="text-lg font-semibold text-foreground mb-2">
Create diagrams with AI
{dict.examples.title}
</h2>
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
Describe what you want to create or upload an image to
replicate
{dict.examples.subtitle}
</p>
</div>
{/* Examples grid */}
<div className="space-y-3">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1">
Quick Examples
{dict.examples.quickExamples}
</p>
<div className="grid gap-2">
<ExampleCard
icon={<FileText className="w-4 h-4 text-primary" />}
title="Paper to Diagram"
description="Upload .pdf, .txt, .md, .json, .csv, .py, .js, .ts and more"
title={dict.examples.paperToDiagram}
description={dict.examples.paperDescription}
onClick={handlePdfExample}
isNew
/>
<ExampleCard
icon={<Zap className="w-4 h-4 text-primary" />}
title="Animated Diagram"
description="Draw a transformer architecture with animated connectors"
title={dict.examples.animatedDiagram}
description={dict.examples.animatedDescription}
onClick={() => {
setInput(
"Give me a **animated connector** diagram of transformer's architecture",
@@ -182,22 +186,22 @@ export default function ExamplePanel({
<ExampleCard
icon={<Cloud className="w-4 h-4 text-primary" />}
title="AWS Architecture"
description="Create a cloud architecture diagram with AWS icons"
title={dict.examples.awsArchitecture}
description={dict.examples.awsDescription}
onClick={handleReplicateArchitecture}
/>
<ExampleCard
icon={<GitBranch className="w-4 h-4 text-primary" />}
title="Replicate Flowchart"
description="Upload and replicate an existing flowchart"
title={dict.examples.replicateFlowchart}
description={dict.examples.replicateDescription}
onClick={handleReplicateFlowchart}
/>
<ExampleCard
icon={<Palette className="w-4 h-4 text-primary" />}
title="Creative Drawing"
description="Draw something fun and creative"
title={dict.examples.creativeDrawing}
description={dict.examples.creativeDescription}
onClick={() => {
setInput("Draw a cat for me")
setFiles([])
@@ -206,7 +210,7 @@ export default function ExamplePanel({
</div>
<p className="text-[11px] text-muted-foreground/60 text-center mt-4">
Examples are cached for instant response
{dict.examples.cachedNote}
</p>
</div>
</div>

View File

@@ -25,6 +25,8 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip"
import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
import { FilePreviewList } from "./file-preview-list"
@@ -58,6 +60,7 @@ interface ValidationResult {
function validateFiles(
newFiles: File[],
existingCount: number,
dict: any,
): ValidationResult {
const errors: string[] = []
const validFiles: File[] = []
@@ -65,17 +68,23 @@ function validateFiles(
const availableSlots = MAX_FILES - existingCount
if (availableSlots <= 0) {
errors.push(`Maximum ${MAX_FILES} files allowed`)
errors.push(formatMessage(dict.errors.maxFiles, { max: MAX_FILES }))
return { validFiles, errors }
}
for (const file of newFiles) {
if (validFiles.length >= availableSlots) {
errors.push(`Only ${availableSlots} more file(s) allowed`)
errors.push(
formatMessage(dict.errors.onlyMoreAllowed, {
slots: availableSlots,
}),
)
break
}
if (!isValidFileType(file)) {
errors.push(`"${file.name}" is not a supported file type`)
errors.push(
formatMessage(dict.errors.unsupportedType, { name: file.name }),
)
continue
}
// Only check size for images (PDFs/text files are extracted client-side, so file size doesn't matter)
@@ -83,7 +92,11 @@ function validateFiles(
if (!isExtractedFile && file.size > MAX_IMAGE_SIZE) {
const maxSizeMB = MAX_IMAGE_SIZE / 1024 / 1024
errors.push(
`"${file.name}" is ${formatFileSize(file.size)} (exceeds ${maxSizeMB}MB)`,
formatMessage(dict.errors.fileExceeds, {
name: file.name,
size: formatFileSize(file.size),
max: maxSizeMB,
}),
)
} else {
validFiles.push(file)
@@ -93,7 +106,7 @@ function validateFiles(
return { validFiles, errors }
}
function showValidationErrors(errors: string[]) {
function showValidationErrors(errors: string[], dict: any) {
if (errors.length === 0) return
if (errors.length === 1) {
@@ -104,14 +117,20 @@ function showValidationErrors(errors: string[]) {
showErrorToast(
<div className="flex flex-col gap-1">
<span className="font-medium">
{errors.length} files rejected:
{formatMessage(dict.errors.filesRejected, {
count: errors.length,
})}
</span>
<ul className="text-muted-foreground text-xs list-disc list-inside">
{errors.slice(0, 3).map((err) => (
<li key={err}>{err}</li>
))}
{errors.length > 3 && (
<li>...and {errors.length - 3} more</li>
<li>
{formatMessage(dict.errors.andMore, {
count: errors.length - 3,
})}
</li>
)}
</ul>
</div>,
@@ -155,6 +174,7 @@ export function ChatInput({
minimalStyle = false,
onMinimalStyleChange = () => {},
}: ChatInputProps) {
const dict = useDictionary()
const {
diagramHistory,
saveDiagramToFile,
@@ -165,7 +185,6 @@ export function ChatInput({
const fileInputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false)
const [showClearDialog, setShowClearDialog] = useState(false)
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
const isDisabled =
(status === "streaming" || status === "submitted") && !error
@@ -177,7 +196,6 @@ export function ChatInput({
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`
}
}, [])
// Handle programmatic input changes (e.g., setInput("") after form submission)
useEffect(() => {
adjustTextareaHeight()
@@ -224,8 +242,9 @@ export function ChatInput({
const { validFiles, errors } = validateFiles(
imageFiles,
files.length,
dict,
)
showValidationErrors(errors)
showValidationErrors(errors, dict)
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles])
}
@@ -234,12 +253,16 @@ export function ChatInput({
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newFiles = Array.from(e.target.files || [])
const { validFiles, errors } = validateFiles(newFiles, files.length)
showValidationErrors(errors)
const { validFiles, errors } = validateFiles(
newFiles,
files.length,
dict,
)
showValidationErrors(errors, dict)
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles])
}
// Reset input so same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
@@ -283,8 +306,9 @@ export function ChatInput({
const { validFiles, errors } = validateFiles(
supportedFiles,
files.length,
dict,
)
showValidationErrors(errors)
showValidationErrors(errors, dict)
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles])
}
@@ -317,8 +341,6 @@ export function ChatInput({
/>
</div>
)}
{/* Input container */}
<div className="relative rounded-2xl border border-border bg-background shadow-sm focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary/50 transition-all duration-200">
<Textarea
ref={textareaRef}
@@ -326,22 +348,20 @@ export function ChatInput({
onChange={handleChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder="Describe your diagram or upload a file..."
placeholder={dict.chat.placeholder}
disabled={isDisabled}
aria-label="Chat input"
className="min-h-[60px] max-h-[200px] resize-none border-0 bg-transparent px-4 py-3 text-sm focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground/60"
/>
{/* Action bar */}
<div className="flex items-center justify-between px-3 py-2 border-t border-border/50">
{/* Left actions */}
<div className="flex items-center gap-1 overflow-x-hidden">
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowClearDialog(true)}
tooltipContent="Clear conversation"
tooltipContent={dict.chat.clearConversation}
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="h-4 w-4" />
@@ -375,17 +395,18 @@ export function ChatInput({
: "text-muted-foreground"
}`}
>
{minimalStyle ? "Minimal" : "Styled"}
{minimalStyle
? dict.chat.minimalStyle
: dict.chat.styledMode}
</label>
</div>
</TooltipTrigger>
<TooltipContent side="top">
Use minimal for faster generation (no colors)
{dict.chat.minimalTooltip}
</TooltipContent>
</Tooltip>
</div>
{/* Right actions */}
<div className="flex items-center gap-1 overflow-hidden justify-end">
<ButtonWithTooltip
type="button"
@@ -393,7 +414,7 @@ export function ChatInput({
size="sm"
onClick={() => onToggleHistory(true)}
disabled={isDisabled || diagramHistory.length === 0}
tooltipContent="Diagram history"
tooltipContent={dict.chat.diagramHistory}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<History className="h-4 w-4" />
@@ -405,7 +426,7 @@ export function ChatInput({
size="sm"
onClick={() => setShowSaveDialog(true)}
disabled={isDisabled}
tooltipContent="Save diagram"
tooltipContent={dict.chat.saveDiagram}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<Download className="h-4 w-4" />
@@ -428,7 +449,7 @@ export function ChatInput({
size="sm"
onClick={triggerFileInput}
disabled={isDisabled}
tooltipContent="Upload file (image, PDF, text)"
tooltipContent={dict.chat.uploadFile}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<ImageIcon className="h-4 w-4" />
@@ -452,7 +473,7 @@ export function ChatInput({
size="sm"
className="h-8 px-4 rounded-xl font-medium shadow-sm"
aria-label={
isDisabled ? "Sending..." : "Send message"
isDisabled ? dict.chat.sending : dict.chat.send
}
>
{isDisabled ? (
@@ -460,7 +481,7 @@ export function ChatInput({
) : (
<>
<Send className="h-4 w-4 mr-1.5" />
Send
{dict.chat.send}
</>
)}
</Button>

View File

@@ -21,6 +21,7 @@ import { ChatInput } from "@/components/chat-input"
import { ResetWarningModal } from "@/components/reset-warning-modal"
import { SettingsDialog } from "@/components/settings-dialog"
import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { getAIConfig } from "@/lib/ai-config"
import { findCachedResponse } from "@/lib/cached-responses"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
@@ -28,6 +29,7 @@ import { type FileData, useFileProcessor } from "@/lib/use-file-processor"
import { useQuotaManager } from "@/lib/use-quota-manager"
import { formatXML, isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
import { ChatMessageDisplay } from "./chat-message-display"
import LanguageToggle from "./language-toggle"
// localStorage keys for persistence
const STORAGE_MESSAGES_KEY = "next-ai-draw-io-messages"
@@ -111,6 +113,8 @@ export default function ChatPanel({
clearDiagram,
} = useDiagram()
const dict = useDictionary()
const onFetchChart = (saveToHistory = true) => {
return Promise.race([
new Promise<string>((resolve) => {
@@ -1271,9 +1275,9 @@ Continue from EXACTLY where you stopped.`,
</Link>
)}
</div>
<div className="flex items-center gap-1 justify-end overflow-x-hidden">
<div className="flex items-center gap-1 justify-end overflow-visible">
<ButtonWithTooltip
tooltipContent="Start fresh chat"
tooltipContent={dict.nav.newChat}
variant="ghost"
size="icon"
onClick={() => setShowNewChatDialog(true)}
@@ -1295,7 +1299,7 @@ Continue from EXACTLY where you stopped.`,
/>
</a>
<ButtonWithTooltip
tooltipContent="Settings"
tooltipContent={dict.nav.settings}
variant="ghost"
size="icon"
onClick={() => setShowSettingsDialog(true)}
@@ -1305,17 +1309,20 @@ Continue from EXACTLY where you stopped.`,
className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`}
/>
</ButtonWithTooltip>
{!isMobile && (
<ButtonWithTooltip
tooltipContent="Hide chat panel (Ctrl+B)"
variant="ghost"
size="icon"
onClick={onToggleVisibility}
className="hover:bg-accent"
>
<PanelRightClose className="h-5 w-5 text-muted-foreground" />
</ButtonWithTooltip>
)}
<div className="hidden sm:flex items-center gap-2">
<LanguageToggle />
{!isMobile && (
<ButtonWithTooltip
tooltipContent={dict.nav.hidePanel}
variant="ghost"
size="icon"
className="hover:bg-accent"
onClick={onToggleVisibility}
>
<PanelRightClose className="h-5 w-5 text-muted-foreground" />
</ButtonWithTooltip>
)}
</div>
</div>
</div>
</header>

View File

@@ -3,6 +3,7 @@
import { FileCode, FileText, Loader2, X } from "lucide-react"
import Image from "next/image"
import { useEffect, useRef, useState } from "react"
import { useDictionary } from "@/hooks/use-dictionary"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
function formatCharCount(count: number): string {
@@ -26,10 +27,10 @@ export function FilePreviewList({
onRemoveFile,
pdfData = new Map(),
}: FilePreviewListProps) {
const dict = useDictionary()
const [selectedImage, setSelectedImage] = useState<string | null>(null)
const [imageUrls, setImageUrls] = useState<Map<File, string>>(new Map())
const imageUrlsRef = useRef<Map<File, string>>(new Map())
// Create and cleanup object URLs when files change
useEffect(() => {
const currentUrls = imageUrlsRef.current
@@ -46,7 +47,6 @@ export function FilePreviewList({
}
}
})
// Revoke URLs for files that are no longer in the list
currentUrls.forEach((url, file) => {
if (!newUrls.has(file)) {
@@ -57,7 +57,6 @@ export function FilePreviewList({
imageUrlsRef.current = newUrls
setImageUrls(newUrls)
}, [files])
// Cleanup all URLs on unmount only
useEffect(() => {
return () => {
@@ -68,7 +67,6 @@ export function FilePreviewList({
imageUrlsRef.current = new Map()
}
}, [])
// Clear selected image if its URL was revoked
useEffect(() => {
if (
@@ -126,14 +124,14 @@ export function FilePreviewList({
</span>
{pdfInfo?.isExtracting ? (
<span className="text-[10px] text-muted-foreground">
Reading...
{dict.file.reading}
</span>
) : pdfInfo?.charCount ? (
<span className="text-[10px] text-green-600 font-medium">
{formatCharCount(
pdfInfo.charCount,
)}{" "}
chars
{dict.file.chars}
</span>
) : null}
</div>
@@ -147,7 +145,7 @@ export function FilePreviewList({
type="button"
onClick={() => onRemoveFile(file)}
className="absolute -top-2 -right-2 bg-destructive rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity"
aria-label="Remove file"
aria-label={dict.file.removeFile}
>
<X className="h-3 w-3" />
</button>
@@ -155,7 +153,6 @@ export function FilePreviewList({
)
})}
</div>
{/* Image Modal/Lightbox */}
{selectedImage && (
<div
@@ -165,7 +162,7 @@ export function FilePreviewList({
<button
className="absolute top-4 right-4 z-10 bg-white rounded-full p-2 hover:bg-gray-200 transition-colors"
onClick={() => setSelectedImage(null)}
aria-label="Close"
aria-label={dict.common.close}
>
<X className="h-6 w-6" />
</button>

View File

@@ -12,6 +12,8 @@ import {
DialogTitle,
} from "@/components/ui/dialog"
import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
interface HistoryDialogProps {
showHistory: boolean
@@ -22,6 +24,7 @@ export function HistoryDialog({
showHistory,
onToggleHistory,
}: HistoryDialogProps) {
const dict = useDictionary()
const { loadDiagram: onDisplayChart, diagramHistory } = useDiagram()
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
@@ -42,18 +45,15 @@ export function HistoryDialog({
<Dialog open={showHistory} onOpenChange={onToggleHistory}>
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Diagram History</DialogTitle>
<DialogTitle>{dict.history.title}</DialogTitle>
<DialogDescription>
Here saved each diagram before AI modification.
<br />
Click on a diagram to restore it
{dict.history.description}
</DialogDescription>
</DialogHeader>
{diagramHistory.length === 0 ? (
<div className="text-center p-4 text-gray-500">
No history available yet. Send messages to create
diagram history.
{dict.history.noHistory}
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 py-4">
@@ -70,14 +70,14 @@ export function HistoryDialog({
<div className="aspect-video bg-white rounded overflow-hidden flex items-center justify-center">
<Image
src={item.svg}
alt={`Diagram version ${index + 1}`}
alt={`${dict.history.version} ${index + 1}`}
width={200}
height={100}
className="object-contain w-full h-full p-1"
/>
</div>
<div className="text-xs text-center mt-1 text-gray-500">
Version {index + 1}
{dict.history.version} {index + 1}
</div>
</div>
))}
@@ -88,21 +88,23 @@ export function HistoryDialog({
{selectedIndex !== null ? (
<>
<div className="flex-1 text-sm text-muted-foreground">
Restore to Version {selectedIndex + 1}?
{formatMessage(dict.history.restoreTo, {
version: selectedIndex + 1,
})}
</div>
<Button
variant="outline"
onClick={() => setSelectedIndex(null)}
>
Cancel
{dict.common.cancel}
</Button>
<Button onClick={handleConfirmRestore}>
Confirm
{dict.common.confirm}
</Button>
</>
) : (
<Button variant="outline" onClick={handleClose}>
Close
{dict.common.close}
</Button>
)}
</DialogFooter>

View File

@@ -0,0 +1,108 @@
"use client"
import { Globe } from "lucide-react"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { Suspense, useEffect, useRef, useState } from "react"
import { i18n, type Locale } from "@/lib/i18n/config"
const LABELS: Record<string, string> = {
en: "EN",
zh: "中文",
ja: "日本語",
}
function LanguageToggleInner({ className = "" }: { className?: string }) {
const router = useRouter()
const pathname = usePathname() || "/"
const search = useSearchParams()
const [open, setOpen] = useState(false)
const [value, setValue] = useState<Locale>(i18n.defaultLocale)
const ref = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const seg = pathname.split("/").filter(Boolean)
const first = seg[0]
if (first && i18n.locales.includes(first as Locale))
setValue(first as Locale)
else setValue(i18n.defaultLocale)
}, [pathname])
useEffect(() => {
function onDoc(e: MouseEvent) {
if (!ref.current) return
if (!ref.current.contains(e.target as Node)) setOpen(false)
}
if (open) document.addEventListener("mousedown", onDoc)
return () => document.removeEventListener("mousedown", onDoc)
}, [open])
const changeLocale = (lang: string) => {
const parts = pathname.split("/")
if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {
parts[1] = lang
} else {
parts.splice(1, 0, lang)
}
const newPath = parts.join("/") || "/"
const searchStr = search?.toString() ? `?${search.toString()}` : ""
setOpen(false)
router.push(newPath + searchStr)
}
return (
<div className={`relative inline-flex ${className}`} ref={ref}>
<button
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((s) => !s)}
className="p-2 rounded-full hover:bg-accent/20 transition-colors text-muted-foreground"
aria-label="Change language"
>
<Globe className="w-5 h-5" />
</button>
{open && (
<div className="absolute right-0 top-full mt-2 w-40 bg-popover dark:bg-popover text-popover-foreground rounded-xl shadow-md border border-border/30 overflow-hidden z-50">
<div className="grid gap-0 divide-y divide-border/30">
{i18n.locales.map((loc) => (
<button
key={loc}
onClick={() => changeLocale(loc)}
className={`flex items-center gap-2 px-4 py-2 text-sm w-full text-left hover:bg-accent/10 transition-colors ${value === loc ? "bg-accent/10 font-semibold" : ""}`}
>
<span className="flex-1">
{LABELS[loc] ?? loc}
</span>
{value === loc && (
<span className="text-xs opacity-70">
</span>
)}
</button>
))}
</div>
</div>
)}
</div>
)
}
export default function LanguageToggle({
className = "",
}: {
className?: string
}) {
return (
<Suspense
fallback={
<button
className="p-2 rounded-full text-muted-foreground opacity-50"
disabled
>
<Globe className="w-5 h-5" />
</button>
}
>
<LanguageToggleInner className={className} />
</Suspense>
)
}

View File

@@ -4,6 +4,8 @@ import { Coffee, X } from "lucide-react"
import Link from "next/link"
import type React from "react"
import { FaGithub } from "react-icons/fa"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
interface QuotaLimitToastProps {
type?: "request" | "token"
@@ -18,9 +20,11 @@ export function QuotaLimitToast({
limit,
onDismiss,
}: QuotaLimitToastProps) {
const dict = useDictionary()
const isTokenLimit = type === "token"
const formatNumber = (n: number) =>
n >= 1000 ? `${(n / 1000).toFixed(1)}k` : n.toString()
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault()
@@ -44,7 +48,6 @@ export function QuotaLimitToast({
>
<X className="w-4 h-4" />
</button>
{/* Title row with icon */}
<div className="flex items-center gap-2.5 mb-3 pr-6">
<div className="flex-shrink-0 w-8 h-8 rounded-lg bg-accent flex items-center justify-center">
@@ -55,40 +58,26 @@ export function QuotaLimitToast({
</div>
<h3 className="font-semibold text-foreground text-sm">
{isTokenLimit
? "Daily Token Limit Reached"
: "Daily Quota Reached"}
? dict.quota.tokenLimit
: dict.quota.dailyLimit}
</h3>
<span className="px-2 py-0.5 text-xs font-medium rounded-md bg-muted text-muted-foreground">
{isTokenLimit
? `${formatNumber(used)}/${formatNumber(limit)} tokens`
: `${used}/${limit}`}
{formatMessage(dict.quota.usedOf, {
used: formatNumber(used),
limit: formatNumber(limit),
})}
</span>
</div>
{/* Message */}
<div className="text-sm text-muted-foreground leading-relaxed mb-4 space-y-2">
<p>
Oops you've reached the daily{" "}
{isTokenLimit ? "token" : "API"} limit for this demo! As an
indie developer covering all the API costs myself, I have to
set these limits to keep things sustainable.{" "}
<Link
href="/about"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-amber-600 font-medium hover:text-amber-700 hover:underline"
>
Learn more
</Link>
{isTokenLimit
? dict.quota.messageToken
: dict.quota.messageApi}
</p>
<p>
<strong>Tip:</strong> You can use your own API key (click
the Settings icon) or self-host the project to bypass these
limits.
</p>
<p>Your limit resets tomorrow. Thanks for understanding!</p>
</div>
<p dangerouslySetInnerHTML={{ __html: dict.quota.tip }} />
<p>{dict.quota.reset}</p>
</div>{" "}
{/* Action buttons */}
<div className="flex items-center gap-2">
<a
@@ -98,7 +87,7 @@ export function QuotaLimitToast({
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
>
<FaGithub className="w-3.5 h-3.5" />
Self-host
{dict.quota.selfHost}
</a>
<a
href="https://github.com/sponsors/DayuanJiang"
@@ -107,7 +96,7 @@ export function QuotaLimitToast({
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border text-foreground hover:bg-muted transition-colors"
>
<Coffee className="w-3.5 h-3.5" />
Sponsor
{dict.quota.sponsor}
</a>
</div>
</div>

View File

@@ -9,6 +9,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { useDictionary } from "@/hooks/use-dictionary"
interface ResetWarningModalProps {
open: boolean
@@ -21,14 +22,15 @@ export function ResetWarningModal({
onOpenChange,
onClear,
}: ResetWarningModalProps) {
const dict = useDictionary()
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Clear Everything?</DialogTitle>
<DialogTitle>{dict.dialogs.clearTitle}</DialogTitle>
<DialogDescription>
This will clear the current conversation and reset the
diagram. This action cannot be undone.
{dict.dialogs.clearDescription}
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -36,10 +38,10 @@ export function ResetWarningModal({
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
{dict.common.cancel}
</Button>
<Button variant="destructive" onClick={onClear}>
Clear Everything
{dict.dialogs.clearEverything}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -18,19 +18,10 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { useDictionary } from "@/hooks/use-dictionary"
export type ExportFormat = "drawio" | "png" | "svg"
const FORMAT_OPTIONS: {
value: ExportFormat
label: string
extension: string
}[] = [
{ value: "drawio", label: "Draw.io XML", extension: ".drawio" },
{ value: "png", label: "PNG Image", extension: ".png" },
{ value: "svg", label: "SVG Image", extension: ".svg" },
]
interface SaveDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
@@ -44,6 +35,7 @@ export function SaveDialog({
onSave,
defaultFilename,
}: SaveDialogProps) {
const dict = useDictionary()
const [filename, setFilename] = useState(defaultFilename)
const [format, setFormat] = useState<ExportFormat>("drawio")
@@ -66,20 +58,40 @@ export function SaveDialog({
}
}
const FORMAT_OPTIONS = [
{
value: "drawio" as const,
label: dict.save.formats.drawio,
extension: ".drawio",
},
{
value: "png" as const,
label: dict.save.formats.png,
extension: ".png",
},
{
value: "svg" as const,
label: dict.save.formats.svg,
extension: ".svg",
},
]
const currentFormat = FORMAT_OPTIONS.find((f) => f.value === format)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Save Diagram</DialogTitle>
<DialogTitle>{dict.save.title}</DialogTitle>
<DialogDescription>
Choose a format and filename to save your diagram.
{dict.save.description}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Format</label>
<label className="text-sm font-medium">
{dict.save.format}
</label>
<Select
value={format}
onValueChange={(v) => setFormat(v as ExportFormat)}
@@ -100,13 +112,15 @@ export function SaveDialog({
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Filename</label>
<label className="text-sm font-medium">
{dict.save.filename}
</label>
<div className="flex items-stretch">
<Input
value={filename}
onChange={(e) => setFilename(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter filename"
placeholder={dict.save.filenamePlaceholder}
autoFocus
onFocus={(e) => e.target.select()}
className="rounded-r-none border-r-0 focus-visible:z-10"
@@ -122,9 +136,9 @@ export function SaveDialog({
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
{dict.common.cancel}
</Button>
<Button onClick={handleSave}>Save</Button>
<Button onClick={handleSave}>{dict.common.save}</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -20,6 +20,7 @@ import {
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { useDictionary } from "@/hooks/use-dictionary"
interface SettingsDialogProps {
open: boolean
@@ -55,6 +56,7 @@ export function SettingsDialog({
darkMode,
onToggleDarkMode,
}: SettingsDialogProps) {
const dict = useDictionary()
const [accessCode, setAccessCode] = useState("")
const [closeProtection, setCloseProtection] = useState(true)
const [isVerifying, setIsVerifying] = useState(false)
@@ -129,14 +131,14 @@ export function SettingsDialog({
const data = await response.json()
if (!data.valid) {
setError(data.message || "Invalid access code")
setError(data.message || dict.errors.invalidAccessCode)
return
}
localStorage.setItem(STORAGE_ACCESS_CODE_KEY, accessCode.trim())
onOpenChange(false)
} catch {
setError("Failed to verify access code")
setError(dict.errors.networkError)
} finally {
setIsVerifying(false)
}
@@ -153,15 +155,17 @@ export function SettingsDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Settings</DialogTitle>
<DialogTitle>{dict.settings.title}</DialogTitle>
<DialogDescription>
Configure your application settings.
{dict.settings.description}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{accessCodeRequired && (
<div className="space-y-2">
<Label htmlFor="access-code">Access Code</Label>
<Label htmlFor="access-code">
{dict.settings.accessCode}
</Label>
<div className="flex gap-2">
<Input
id="access-code"
@@ -171,18 +175,20 @@ export function SettingsDialog({
setAccessCode(e.target.value)
}
onKeyDown={handleKeyDown}
placeholder="Enter access code"
placeholder={
dict.settings.accessCodePlaceholder
}
autoComplete="off"
/>
<Button
onClick={handleSave}
disabled={isVerifying || !accessCode.trim()}
>
{isVerifying ? "..." : "Save"}
{isVerifying ? "..." : dict.common.save}
</Button>
</div>
<p className="text-[0.8rem] text-muted-foreground">
Required to use this application.
{dict.settings.accessCodeDescription}
</p>
{error && (
<p className="text-[0.8rem] text-destructive">
@@ -192,15 +198,15 @@ export function SettingsDialog({
</div>
)}
<div className="space-y-2">
<Label>AI Provider Settings</Label>
<Label>{dict.settings.aiProvider}</Label>
<p className="text-[0.8rem] text-muted-foreground">
Use your own API key to bypass usage limits. Your
key is stored locally in your browser and is never
stored on the server.
{dict.settings.aiProviderDescription}
</p>
<div className="space-y-3 pt-2">
<div className="space-y-2">
<Label htmlFor="ai-provider">Provider</Label>
<Label htmlFor="ai-provider">
{dict.settings.provider}
</Label>
<Select
value={provider || "default"}
onValueChange={(value) => {
@@ -214,32 +220,36 @@ export function SettingsDialog({
}}
>
<SelectTrigger id="ai-provider">
<SelectValue placeholder="Use Server Default" />
<SelectValue
placeholder={
dict.settings.useServerDefault
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="default">
Use Server Default
{dict.settings.useServerDefault}
</SelectItem>
<SelectItem value="openai">
OpenAI
{dict.providers.openai}
</SelectItem>
<SelectItem value="anthropic">
Anthropic
{dict.providers.anthropic}
</SelectItem>
<SelectItem value="google">
Google
{dict.providers.google}
</SelectItem>
<SelectItem value="azure">
Azure OpenAI
{dict.providers.azure}
</SelectItem>
<SelectItem value="openrouter">
OpenRouter
{dict.providers.openrouter}
</SelectItem>
<SelectItem value="deepseek">
DeepSeek
{dict.providers.deepseek}
</SelectItem>
<SelectItem value="siliconflow">
SiliconFlow
{dict.providers.siliconflow}
</SelectItem>
</SelectContent>
</Select>
@@ -248,7 +258,7 @@ export function SettingsDialog({
<>
<div className="space-y-2">
<Label htmlFor="ai-model">
Model ID
{dict.settings.modelId}
</Label>
<Input
id="ai-model"
@@ -270,13 +280,14 @@ export function SettingsDialog({
: provider ===
"deepseek"
? "e.g., deepseek-chat"
: "Model ID"
: dict.settings
.modelId
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ai-api-key">
API Key
{dict.settings.apiKey}
</Label>
<Input
id="ai-api-key"
@@ -289,11 +300,13 @@ export function SettingsDialog({
e.target.value,
)
}}
placeholder="Your API key"
placeholder={
dict.settings.apiKeyPlaceholder
}
autoComplete="off"
/>
<p className="text-[0.8rem] text-muted-foreground">
Overrides{" "}
{dict.settings.overrides}{" "}
{provider === "openai"
? "OPENAI_API_KEY"
: provider === "anthropic"
@@ -316,7 +329,7 @@ export function SettingsDialog({
</div>
<div className="space-y-2">
<Label htmlFor="ai-base-url">
Base URL (optional)
{dict.settings.baseUrl}
</Label>
<Input
id="ai-base-url"
@@ -333,7 +346,8 @@ export function SettingsDialog({
? "https://api.anthropic.com/v1"
: provider === "siliconflow"
? "https://api.siliconflow.com/v1"
: "Custom endpoint URL"
: dict.settings
.customEndpoint
}
/>
</div>
@@ -360,7 +374,7 @@ export function SettingsDialog({
setModelId("")
}}
>
Clear Settings
{dict.settings.clearSettings}
</Button>
</>
)}
@@ -369,9 +383,11 @@ export function SettingsDialog({
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="theme-toggle">Theme</Label>
<Label htmlFor="theme-toggle">
{dict.settings.theme}
</Label>
<p className="text-[0.8rem] text-muted-foreground">
Dark/Light mode for interface and DrawIO canvas.
{dict.settings.themeDescription}
</p>
</div>
<Button
@@ -390,10 +406,14 @@ export function SettingsDialog({
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="drawio-ui">DrawIO Style</Label>
<Label htmlFor="drawio-ui">
{dict.settings.drawioStyle}
</Label>
<p className="text-[0.8rem] text-muted-foreground">
Canvas style:{" "}
{drawioUi === "min" ? "Minimal" : "Sketch"}
{dict.settings.drawioStyleDescription}{" "}
{drawioUi === "min"
? dict.settings.minimal
: dict.settings.sketch}
</p>
</div>
<Button
@@ -402,18 +422,20 @@ export function SettingsDialog({
size="sm"
onClick={onToggleDrawioUi}
>
Switch to{" "}
{drawioUi === "min" ? "Sketch" : "Minimal"}
{dict.settings.switchTo}{" "}
{drawioUi === "min"
? dict.settings.sketch
: dict.settings.minimal}
</Button>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="close-protection">
Close Protection
{dict.settings.closeProtection}
</Label>
<p className="text-[0.8rem] text-muted-foreground">
Show confirmation when leaving the page.
{dict.settings.closeProtectionDescription}
</p>
</div>
<Switch