mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-02 14:22:28 +08:00
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
108
components/language-toggle.tsx
Normal file
108
components/language-toggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user