Merge branch 'feature/add-more-themes' of github.com:xinyang20/next-ai-draw-io into feature/add-more-themes

This commit is contained in:
Xinyang Gao-PC
2026-01-12 22:19:38 +08:00
93 changed files with 8058 additions and 1741 deletions
@@ -134,6 +134,7 @@ export const ModelSelectorLogo = ({
}
return (
// biome-ignore lint/performance/noImgElement: External URL from models.dev
<img
{...props}
alt={`${provider} logo`}
+114 -4
View File
@@ -4,6 +4,7 @@ import {
Download,
History,
Image as ImageIcon,
Link,
Loader2,
Send,
} from "lucide-react"
@@ -18,11 +19,14 @@ import { SaveDialog } from "@/components/save-dialog"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { UrlInputDialog } from "@/components/url-input-dialog"
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 { STORAGE_KEYS } from "@/lib/storage"
import type { FlattenedModel } from "@/lib/types/model-config"
import { extractUrlContent, type UrlData } from "@/lib/url-utils"
import { FilePreviewList } from "./file-preview-list"
const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
@@ -144,6 +148,8 @@ interface ChatInputProps {
File,
{ text: string; charCount: number; isExtracting: boolean }
>
urlData?: Map<string, UrlData>
onUrlChange?: (data: Map<string, UrlData>) => void
sessionId?: string
error?: Error | null
@@ -163,6 +169,8 @@ export function ChatInput({
files = [],
onFileChange = () => {},
pdfData = new Map(),
urlData,
onUrlChange,
sessionId,
error = null,
models = [],
@@ -183,6 +191,9 @@ export function ChatInput({
const fileInputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false)
const [showHistory, setShowHistory] = useState(false)
const [showUrlDialog, setShowUrlDialog] = useState(false)
const [isExtractingUrl, setIsExtractingUrl] = useState(false)
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
const isDisabled =
(status === "streaming" || status === "submitted") && !error
@@ -199,13 +210,36 @@ export function ChatInput({
adjustTextareaHeight()
}, [input, adjustTextareaHeight])
// Load send shortcut preference from localStorage and listen for changes
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEYS.sendShortcut)
if (stored) setSendShortcut(stored)
const handleChange = (e: CustomEvent<string>) =>
setSendShortcut(e.detail)
window.addEventListener(
"sendShortcutChange",
handleChange as EventListener,
)
return () =>
window.removeEventListener(
"sendShortcutChange",
handleChange as EventListener,
)
}, [])
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange(e)
adjustTextareaHeight()
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
const shouldSend =
sendShortcut === "enter"
? e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey
: (e.metaKey || e.ctrlKey) && e.key === "Enter"
if (shouldSend) {
e.preventDefault()
const form = e.currentTarget.closest("form")
if (form && input.trim() && !isDisabled) {
@@ -312,6 +346,50 @@ export function ChatInput({
}
}
const handleUrlExtract = async (url: string) => {
if (!onUrlChange) return
setIsExtractingUrl(true)
try {
const existing = urlData
? new Map(urlData)
: new Map<string, UrlData>()
existing.set(url, {
url,
title: url,
content: "",
charCount: 0,
isExtracting: true,
})
onUrlChange(existing)
const data = await extractUrlContent(url)
const newUrlData = new Map(existing)
newUrlData.set(url, data)
onUrlChange(newUrlData)
setShowUrlDialog(false)
} catch (error) {
// Remove the URL from the data map on error
const newUrlData = urlData
? new Map(urlData)
: new Map<string, UrlData>()
newUrlData.delete(url)
onUrlChange(newUrlData)
showErrorToast(
<span className="text-muted-foreground">
{error instanceof Error
? error.message
: "Failed to extract URL content"}
</span>,
)
} finally {
setIsExtractingUrl(false)
}
}
return (
<form
onSubmit={onSubmit}
@@ -324,13 +402,23 @@ export function ChatInput({
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* File previews */}
{files.length > 0 && (
{/* File & URL previews */}
{(files.length > 0 || (urlData && urlData.size > 0)) && (
<div className="mb-3">
<FilePreviewList
files={files}
onRemoveFile={handleRemoveFile}
pdfData={pdfData}
urlData={urlData}
onRemoveUrl={
onUrlChange
? (url) => {
const next = new Map(urlData)
next.delete(url)
onUrlChange(next)
}
: undefined
}
/>
</div>
)}
@@ -344,7 +432,7 @@ export function ChatInput({
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"
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 scrollbar-thin"
/>
<div className="flex items-center justify-end gap-1 px-3 py-2 border-t border-border/50">
@@ -385,6 +473,20 @@ export function ChatInput({
<ImageIcon className="h-4 w-4" />
</ButtonWithTooltip>
{onUrlChange && (
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowUrlDialog(true)}
disabled={isDisabled}
tooltipContent={dict.chat.ExtractURL}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<Link className="h-4 w-4" />
</ButtonWithTooltip>
)}
<input
type="file"
ref={fileInputRef}
@@ -443,6 +545,14 @@ export function ChatInput({
.toISOString()
.slice(0, 10)}`}
/>
{onUrlChange && (
<UrlInputDialog
open={showUrlDialog}
onOpenChange={setShowUrlDialog}
onSubmit={handleUrlExtract}
isExtracting={isExtractingUrl}
/>
)}
</form>
)
}
+1 -2
View File
@@ -38,7 +38,6 @@ import {
replaceNodes,
validateAndFixXml,
} from "@/lib/utils"
import ExamplePanel from "./chat-example-panel"
// Helper to extract complete operations from streaming input
function getCompleteOperations(
@@ -1115,7 +1114,7 @@ export function ChatMessageDisplay({
)}
</button>
{isExpanded && (
<div className="px-3 py-2 border-t border-border/40 max-h-48 overflow-y-auto bg-muted/30">
<div className="px-3 py-2 border-t border-border/40 max-h-48 overflow-y-auto bg-muted/30 scrollbar-thin">
<pre className="text-xs whitespace-pre-wrap text-foreground/80">
{
section.content
+21 -3
View File
@@ -34,6 +34,7 @@ import { findCachedResponse } from "@/lib/cached-responses"
import { formatMessage } from "@/lib/i18n/utils"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
import { sanitizeMessages } from "@/lib/session-storage"
import type { UrlData } from "@/lib/url-utils"
import { type FileData, useFileProcessor } from "@/lib/use-file-processor"
import { useQuotaManager } from "@/lib/use-quota-manager"
import { cn, formatXML, isRealDiagram } from "@/lib/utils"
@@ -69,7 +70,6 @@ interface ChatPanelProps {
darkMode: boolean
onToggleDarkMode: () => void
isMobile?: boolean
onCloseProtectionChange?: (enabled: boolean) => void
}
// Constants for tool states
@@ -110,7 +110,6 @@ export default function ChatPanel({
darkMode,
onToggleDarkMode,
isMobile = false,
onCloseProtectionChange,
}: ChatPanelProps) {
const {
loadDiagram: onDisplayChart,
@@ -158,6 +157,7 @@ export default function ChatPanel({
// File processing using extracted hook
const { files, pdfData, handleFileChange, setFiles } = useFileProcessor()
const [urlData, setUrlData] = useState<Map<string, UrlData>>(new Map())
const [showSettingsDialog, setShowSettingsDialog] = useState(false)
const [showModelConfigDialog, setShowModelConfigDialog] = useState(false)
@@ -710,6 +710,8 @@ export default function ChatPanel({
input,
files,
pdfData,
undefined,
urlData,
)
setMessages([
@@ -735,6 +737,7 @@ export default function ChatPanel({
setInput("")
sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
setFiles([])
setUrlData(new Map())
return
}
}
@@ -755,6 +758,7 @@ export default function ChatPanel({
files,
pdfData,
parts,
urlData,
)
// Add the combined text as the first part
@@ -779,6 +783,7 @@ export default function ChatPanel({
setInput("")
sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
setFiles([])
setUrlData(new Map())
} catch (error) {
console.error("Error fetching chart data:", error)
}
@@ -854,6 +859,7 @@ export default function ChatPanel({
clearDiagram()
setDiagramHistory([])
handleFileChange([]) // Use handleFileChange to also clear pdfData
setUrlData(new Map())
const newSessionId = `session-${Date.now()}-${Math.random()
.toString(36)
.slice(2, 9)}`
@@ -972,6 +978,7 @@ export default function ChatPanel({
files: File[],
pdfData: Map<File, FileData>,
imageParts?: any[],
urlDataParam?: Map<string, UrlData>,
): Promise<string> => {
let userText = baseText
@@ -1002,6 +1009,14 @@ export default function ChatPanel({
}
}
if (urlDataParam) {
for (const [url, data] of urlDataParam) {
if (data.content) {
userText += `\n\n[URL: ${url}]\nTitle: ${data.title}\n\n${data.content}`
}
}
}
return userText
}
@@ -1185,6 +1200,7 @@ export default function ChatPanel({
status === "streaming" || status === "submitted"
}
className="hover:bg-accent disabled:opacity-50 disabled:cursor-not-allowed"
data-testid="new-chat-button"
>
<MessageSquarePlus
className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`}
@@ -1197,6 +1213,7 @@ export default function ChatPanel({
size="icon"
onClick={() => setShowSettingsDialog(true)}
className="hover:bg-accent"
data-testid="settings-button"
>
<Settings
className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`}
@@ -1262,6 +1279,8 @@ export default function ChatPanel({
files={files}
onFileChange={handleFileChange}
pdfData={pdfData}
urlData={urlData}
onUrlChange={setUrlData}
sessionId={sessionId}
error={error}
models={modelConfig.models}
@@ -1275,7 +1294,6 @@ export default function ChatPanel({
<SettingsDialog
open={showSettingsDialog}
onOpenChange={setShowSettingsDialog}
onCloseProtectionChange={onCloseProtectionChange}
drawioUi={drawioUi}
onDrawioUiChange={onDrawioUiChange}
darkMode={darkMode}
+62 -2
View File
@@ -1,6 +1,6 @@
"use client"
import { FileCode, FileText, Loader2, X } from "lucide-react"
import { FileCode, FileText, Link, Loader2, X } from "lucide-react"
import Image from "next/image"
import { useEffect, useRef, useState } from "react"
import { useDictionary } from "@/hooks/use-dictionary"
@@ -20,12 +20,19 @@ interface FilePreviewListProps {
File,
{ text: string; charCount: number; isExtracting: boolean }
>
urlData?: Map<
string,
{ url: string; title: string; charCount: number; isExtracting: boolean }
>
onRemoveUrl?: (url: string) => void
}
export function FilePreviewList({
files,
onRemoveFile,
pdfData = new Map(),
urlData,
onRemoveUrl,
}: FilePreviewListProps) {
const dict = useDictionary()
const [selectedImage, setSelectedImage] = useState<string | null>(null)
@@ -77,7 +84,7 @@ export function FilePreviewList({
}
}, [imageUrls, selectedImage])
if (files.length === 0) return null
if (files.length === 0 && (!urlData || urlData.size === 0)) return null
return (
<>
@@ -152,6 +159,59 @@ export function FilePreviewList({
</div>
)
})}
{/* URL previews */}
{urlData && urlData.size > 0 && (
<div className="flex flex-wrap gap-2">
{Array.from(urlData.entries()).map(
([url, data], index) => (
<div
key={url + index}
className="relative group"
>
<div className="w-20 h-20 border rounded-md overflow-hidden bg-muted">
<div className="flex flex-col items-center justify-center h-full p-1">
{data.isExtracting ? (
<>
<Loader2 className="h-6 w-6 text-blue-500 mb-1 animate-spin" />
<span className="text-[10px] text-muted-foreground">
{dict.file.reading}
</span>
</>
) : (
<>
<Link className="h-6 w-6 text-blue-500 mb-1" />
<span className="text-xs text-center truncate w-full px-1">
{data.title.length > 10
? `${data.title.slice(0, 7)}...`
: data.title}
</span>
{data.charCount && (
<span className="text-[10px] text-green-600 font-medium">
{formatCharCount(
data.charCount,
)}{" "}
{dict.file.chars}
</span>
)}
</>
)}
</div>
</div>
{onRemoveUrl && (
<button
type="button"
onClick={() => onRemoveUrl(url)}
className="absolute -top-2 -right-2 bg-destructive rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity"
aria-label={dict.file.removeFile}
>
<X className="h-3 w-3" />
</button>
)}
</div>
),
)}
</div>
)}
</div>
{/* Image Modal/Lightbox */}
{selectedImage && (
+1 -1
View File
@@ -43,7 +43,7 @@ export function HistoryDialog({
return (
<Dialog open={showHistory} onOpenChange={onToggleHistory}>
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto scrollbar-thin">
<DialogHeader>
<DialogTitle>{dict.history.title}</DialogTitle>
<DialogDescription>
+25 -19
View File
@@ -79,6 +79,7 @@ const PROVIDER_LOGO_MAP: Record<string, string> = {
gateway: "vercel",
edgeone: "tencent-cloud",
doubao: "bytedance",
modelscope: "modelscope",
}
// Provider logo component
@@ -102,6 +103,7 @@ function ProviderLogo({
const logoName = PROVIDER_LOGO_MAP[provider] || provider
return (
// biome-ignore lint/performance/noImgElement: External URL from models.dev
<img
alt={`${provider} logo`}
className={cn("size-4 dark:invert", className)}
@@ -273,7 +275,7 @@ export function ModelConfigDialog({
// Validate all models
const handleValidate = useCallback(async () => {
if (!selectedProvider) return
if (!selectedProvider || !selectedProviderId) return
// Check credentials based on provider type
const isBedrock = selectedProvider.provider === "bedrock"
@@ -331,14 +333,14 @@ export function ModelConfigDialog({
const data = await response.json()
if (data.valid) {
updateModel(selectedProviderId!, model.id, {
updateModel(selectedProviderId, model.id, {
validated: true,
validationError: undefined,
})
} else {
allValid = false
errorCount++
updateModel(selectedProviderId!, model.id, {
updateModel(selectedProviderId, model.id, {
validated: false,
validationError: data.error || "Validation failed",
})
@@ -346,7 +348,7 @@ export function ModelConfigDialog({
} catch {
allValid = false
errorCount++
updateModel(selectedProviderId!, model.id, {
updateModel(selectedProviderId, model.id, {
validated: false,
validationError: "Network error",
})
@@ -357,7 +359,7 @@ export function ModelConfigDialog({
if (allValid) {
setValidationStatus("success")
updateProvider(selectedProviderId!, { validated: true })
updateProvider(selectedProviderId, { validated: true })
// Reset to idle after showing success briefly (with cleanup)
if (validationResetTimeoutRef.current) {
clearTimeout(validationResetTimeoutRef.current)
@@ -1298,20 +1300,24 @@ export function ModelConfigDialog({
null,
)
}
updateModel(
selectedProviderId!,
model.id,
{
modelId:
e
.target
.value,
validated:
undefined,
validationError:
undefined,
},
)
if (
selectedProviderId
) {
updateModel(
selectedProviderId,
model.id,
{
modelId:
e
.target
.value,
validated:
undefined,
validationError:
undefined,
},
)
}
}}
onKeyDown={(
e,
+1
View File
@@ -50,6 +50,7 @@ const PROVIDER_LOGO_MAP: Record<string, string> = {
gateway: "vercel",
edgeone: "tencent-cloud",
doubao: "bytedance",
modelscope: "modelscope",
}
// Group models by providerLabel (handles duplicate providers)
+144 -26
View File
@@ -3,6 +3,7 @@
import { Github, Info, Moon, Sun, Tag } from "lucide-react"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { Suspense, useEffect, useState } from "react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import {
Dialog,
@@ -25,6 +26,7 @@ import { useDictionary } from "@/hooks/use-dictionary"
import { getApiEndpoint } from "@/lib/base-path"
import { DRAWIO_THEMES, type DrawioTheme } from "@/lib/drawio-themes"
import { i18n, type Locale } from "@/lib/i18n/config"
import { STORAGE_KEYS } from "@/lib/storage"
// Reusable setting item component for consistent layout
function SettingItem({
@@ -82,7 +84,6 @@ interface SettingsDialogProps {
}
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
export const STORAGE_CLOSE_PROTECTION_KEY = "next-ai-draw-io-close-protection"
const STORAGE_ACCESS_CODE_REQUIRED_KEY = "next-ai-draw-io-access-code-required"
function getStoredAccessCodeRequired(): boolean | null {
@@ -95,7 +96,6 @@ function getStoredAccessCodeRequired(): boolean | null {
function SettingsContent({
open,
onOpenChange,
onCloseProtectionChange,
drawioUi,
onDrawioUiChange,
darkMode,
@@ -108,13 +108,18 @@ function SettingsContent({
const pathname = usePathname() || "/"
const search = useSearchParams()
const [accessCode, setAccessCode] = useState("")
const [closeProtection, setCloseProtection] = useState(true)
const [isVerifying, setIsVerifying] = useState(false)
const [error, setError] = useState("")
const [accessCodeRequired, setAccessCodeRequired] = useState(
() => getStoredAccessCodeRequired() ?? false,
)
const [currentLang, setCurrentLang] = useState("en")
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
// Proxy settings state (Electron only)
const [httpProxy, setHttpProxy] = useState("")
const [httpsProxy, setHttpsProxy] = useState("")
const [isApplyingProxy, setIsApplyingProxy] = useState(false)
useEffect(() => {
// Only fetch if not cached in localStorage
@@ -156,13 +161,20 @@ function SettingsContent({
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
setAccessCode(storedCode)
const storedCloseProtection = localStorage.getItem(
STORAGE_CLOSE_PROTECTION_KEY,
const storedSendShortcut = localStorage.getItem(
STORAGE_KEYS.sendShortcut,
)
// Default to true if not set
setCloseProtection(storedCloseProtection !== "false")
setSendShortcut(storedSendShortcut || "ctrl-enter")
setError("")
// Load proxy settings (Electron only)
if (window.electronAPI?.getProxy) {
window.electronAPI.getProxy().then((config) => {
setHttpProxy(config.httpProxy || "")
setHttpsProxy(config.httpsProxy || "")
})
}
}
}, [open])
@@ -221,6 +233,46 @@ function SettingsContent({
}
}
const handleApplyProxy = async () => {
if (!window.electronAPI?.setProxy) return
// Validate proxy URLs (must start with http:// or https://)
const validateProxyUrl = (url: string): boolean => {
if (!url) return true // Empty is OK
return url.startsWith("http://") || url.startsWith("https://")
}
const trimmedHttp = httpProxy.trim()
const trimmedHttps = httpsProxy.trim()
if (trimmedHttp && !validateProxyUrl(trimmedHttp)) {
toast.error("HTTP Proxy must start with http:// or https://")
return
}
if (trimmedHttps && !validateProxyUrl(trimmedHttps)) {
toast.error("HTTPS Proxy must start with http:// or https://")
return
}
setIsApplyingProxy(true)
try {
const result = await window.electronAPI.setProxy({
httpProxy: trimmedHttp || undefined,
httpsProxy: trimmedHttps || undefined,
})
if (result.success) {
toast.success(dict.settings.proxyApplied)
} else {
toast.error(result.error || "Failed to apply proxy settings")
}
} catch {
toast.error("Failed to apply proxy settings")
} finally {
setIsApplyingProxy(false)
}
}
return (
<DialogContent className="sm:max-w-lg p-0 gap-0">
{/* Header */}
@@ -357,25 +409,6 @@ function SettingsContent({
</Select>
</SettingItem>
{/* Close Protection */}
<SettingItem
label={dict.settings.closeProtection}
description={dict.settings.closeProtectionDescription}
>
<Switch
id="close-protection"
checked={closeProtection}
onCheckedChange={(checked) => {
setCloseProtection(checked)
localStorage.setItem(
STORAGE_CLOSE_PROTECTION_KEY,
checked.toString(),
)
onCloseProtectionChange?.(checked)
}}
/>
</SettingItem>
{/* Diagram Style */}
<SettingItem
label={dict.settings.diagramStyle}
@@ -394,6 +427,91 @@ function SettingsContent({
</span>
</div>
</SettingItem>
{/* Send Shortcut */}
<SettingItem
label={dict.settings.sendShortcut}
description={dict.settings.sendShortcutDescription}
>
<Select
value={sendShortcut}
onValueChange={(value) => {
setSendShortcut(value)
localStorage.setItem(
STORAGE_KEYS.sendShortcut,
value,
)
window.dispatchEvent(
new CustomEvent("sendShortcutChange", {
detail: value,
}),
)
}}
>
<SelectTrigger
id="send-shortcut-select"
className="w-[170px] h-9 rounded-xl"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="enter">
{dict.settings.enterToSend}
</SelectItem>
<SelectItem value="ctrl-enter">
{dict.settings.ctrlEnterToSend}
</SelectItem>
</SelectContent>
</Select>
</SettingItem>
{/* Proxy Settings - Electron only */}
{typeof window !== "undefined" &&
window.electronAPI?.isElectron && (
<div className="py-4 space-y-3">
<div className="space-y-0.5">
<Label className="text-sm font-medium">
{dict.settings.proxy}
</Label>
<p className="text-xs text-muted-foreground">
{dict.settings.proxyDescription}
</p>
</div>
<div className="space-y-2">
<Input
id="http-proxy"
type="text"
value={httpProxy}
onChange={(e) =>
setHttpProxy(e.target.value)
}
placeholder={`${dict.settings.httpProxy}: http://proxy:8080`}
className="h-9"
/>
<Input
id="https-proxy"
type="text"
value={httpsProxy}
onChange={(e) =>
setHttpsProxy(e.target.value)
}
placeholder={`${dict.settings.httpsProxy}: http://proxy:8080`}
className="h-9"
/>
</div>
<Button
onClick={handleApplyProxy}
disabled={isApplyingProxy}
className="h-9 px-4 rounded-xl w-full"
>
{isApplyingProxy
? "..."
: dict.settings.applyProxy}
</Button>
</div>
)}
</div>
</div>
-92
View File
@@ -1,92 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-[data-slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+116
View File
@@ -0,0 +1,116 @@
"use client"
import { Link, Loader2 } from "lucide-react"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { useDictionary } from "@/hooks/use-dictionary"
interface UrlInputDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: (url: string) => void
isExtracting: boolean
}
export function UrlInputDialog({
open,
onOpenChange,
onSubmit,
isExtracting,
}: UrlInputDialogProps) {
const dict = useDictionary()
const [url, setUrl] = useState("")
const [error, setError] = useState("")
const handleSubmit = () => {
setError("")
if (!url.trim()) {
setError(dict.url.enterUrl)
return
}
try {
new URL(url)
} catch {
setError(dict.url.invalidFormat)
return
}
onSubmit(url.trim())
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && !isExtracting) {
e.preventDefault()
handleSubmit()
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{dict.url.title}</DialogTitle>
<DialogDescription>
{dict.url.description}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Input
value={url}
onChange={(e) => {
setUrl(e.target.value)
setError("")
}}
onKeyDown={handleKeyDown}
placeholder="https://example.com/article"
disabled={isExtracting}
autoFocus
/>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isExtracting}
>
{dict.url.Cancel}
</Button>
<Button
onClick={handleSubmit}
disabled={isExtracting || !url.trim()}
>
{isExtracting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{dict.url.Extracting}
</>
) : (
<>
<Link className="mr-2 h-4 w-4" />
{dict.url.extract}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}