| import { useState, useRef, useCallback } from "react"; |
| import { Link } from "wouter"; |
| import { useForm } from "react-hook-form"; |
| import { zodResolver } from "@hookform/resolvers/zod"; |
| import { z } from "zod"; |
| import { motion, AnimatePresence } from "framer-motion"; |
| import { |
| Download, Sparkles, Loader2, RefreshCw, ChevronDown, ChevronUp, |
| CheckCircle, XCircle, Clock, Server, Upload, X, ImagePlus, LogIn, AlertTriangle, Lock, Globe, |
| } from "lucide-react"; |
| import { useQueryClient } from "@tanstack/react-query"; |
| import { useAuth } from "@/contexts/AuthContext"; |
|
|
| import { useGenerateImage, getGetImageHistoryQueryKey } from "@workspace/api-client-react"; |
| import type { ApiDebugInfo } from "@workspace/api-client-react"; |
|
|
| import { Button } from "@/components/ui/button"; |
| import { Textarea } from "@/components/ui/textarea"; |
| import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; |
| import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; |
| import { Badge } from "@/components/ui/badge"; |
| import { useToast } from "@/hooks/use-toast"; |
| import { useLang } from "@/contexts/LanguageContext"; |
| import { AuthModal } from "@/components/AuthModal"; |
|
|
| const NANO_BANANA_MODELS = new Set(["nano-banana-pro", "nano-banana-2"]); |
|
|
| const formSchema = z.object({ |
| prompt: z.string().min(1), |
| style: z.string().optional(), |
| aspectRatio: z.string().optional(), |
| model: z.string().optional(), |
| resolution: z.string().optional(), |
| }); |
|
|
| type FormValues = z.infer<typeof formSchema>; |
|
|
| const MODEL_KEYS = ["grok", "meta", "imagen-pro", "imagen-4", "imagen-flash", "nano-banana-pro", "nano-banana-2"] as const; |
| const STYLE_KEYS = ["none", "realistic", "anime", "artistic", "cartoon", "sketch", "oil_painting", "watercolor", "digital_art"] as const; |
| const STYLE_EMOJIS: Record<string, string> = { |
| none: "🚫", realistic: "📷", anime: "🎌", artistic: "🎨", cartoon: "🖼️", |
| sketch: "✏️", oil_painting: "🖌️", watercolor: "💧", digital_art: "💻", |
| }; |
| const ASPECT_RATIO_KEYS = ["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3"] as const; |
|
|
| function JsonBlock({ data }: { data: unknown }) { |
| return ( |
| <pre className="text-xs font-mono text-emerald-300 bg-black/40 rounded-lg p-3 overflow-x-auto whitespace-pre-wrap break-all leading-5 max-h-48 overflow-y-auto"> |
| {JSON.stringify(data, null, 2)} |
| </pre> |
| ); |
| } |
|
|
| function ApiDebugPanel({ debug }: { debug: ApiDebugInfo }) { |
| const { t } = useLang(); |
| const [openSection, setOpenSection] = useState<string | null>("info"); |
| const toggle = (key: string) => setOpenSection(prev => prev === key ? null : key); |
| const statusOk = debug.responseStatus >= 200 && debug.responseStatus < 300; |
|
|
| return ( |
| <motion.div |
| initial={{ opacity: 0, y: 16 }} |
| animate={{ opacity: 1, y: 0 }} |
| className="mt-6 rounded-xl border border-violet-500/30 bg-[#0e0a1a] overflow-hidden" |
| > |
| <div className="flex items-center gap-3 px-4 py-3 border-b border-violet-500/20 bg-violet-950/30"> |
| <Server className="w-4 h-4 text-violet-400" /> |
| <span className="text-sm font-semibold text-violet-300">{t.debugTitle}</span> |
| <div className="ml-auto flex items-center gap-2"> |
| {debug.usedFallback ? ( |
| <Badge variant="destructive" className="text-xs">{t.debugFallback}</Badge> |
| ) : ( |
| <Badge className="text-xs bg-emerald-600/20 text-emerald-400 border-emerald-500/30">{t.debugReal}</Badge> |
| )} |
| <div className="flex items-center gap-1 text-xs text-muted-foreground"> |
| <Clock className="w-3 h-3" /> |
| <span>{debug.durationMs} {t.debugMs}</span> |
| </div> |
| </div> |
| </div> |
| |
| {[ |
| { key: "info", label: t.debugSectionInfo, content: ( |
| <div className="px-4 pb-3 space-y-2 text-xs"> |
| <div className="flex items-start gap-2"> |
| <span className="text-muted-foreground w-20 shrink-0">{t.debugEndpoint}</span> |
| <span className="text-violet-200 font-mono break-all">{debug.requestUrl}</span> |
| </div> |
| <div className="flex items-center gap-2"> |
| <span className="text-muted-foreground w-20 shrink-0">{t.debugMethod}</span> |
| <Badge className="text-xs bg-blue-600/20 text-blue-400 border-blue-500/30">{debug.requestMethod}</Badge> |
| </div> |
| <div className="flex items-center gap-2"> |
| <span className="text-muted-foreground w-20 shrink-0">{t.debugStatus}</span> |
| <span className={`flex items-center gap-1 font-semibold ${statusOk ? "text-emerald-400" : "text-red-400"}`}> |
| {statusOk ? <CheckCircle className="w-3.5 h-3.5" /> : <XCircle className="w-3.5 h-3.5" />} |
| {debug.responseStatus === 0 ? t.debugNoResponse : debug.responseStatus} |
| </span> |
| </div> |
| <div className="flex items-center gap-2"> |
| <span className="text-muted-foreground w-20 shrink-0">{t.debugDuration}</span> |
| <span className="text-amber-400">{debug.durationMs} {t.debugMs}</span> |
| </div> |
| {debug.usedFallback && debug.fallbackReason && ( |
| <div className="flex items-start gap-2"> |
| <span className="text-muted-foreground w-20 shrink-0">{t.debugReason}</span> |
| <span className="text-red-400 break-all">{debug.fallbackReason}</span> |
| </div> |
| )} |
| </div> |
| )}, |
| { key: "request", label: t.debugSectionRequest, content: ( |
| <div className="px-4 pb-3 space-y-2"> |
| <p className="text-xs text-muted-foreground mb-1">{t.debugHeaders}</p> |
| <JsonBlock data={debug.requestHeaders} /> |
| <p className="text-xs text-muted-foreground mb-1 mt-2">{t.debugBody}</p> |
| <JsonBlock data={debug.requestBody} /> |
| </div> |
| )}, |
| { key: "response", label: t.debugSectionResponse, content: ( |
| <div className="px-4 pb-3"> |
| <JsonBlock data={debug.responseBody} /> |
| </div> |
| )}, |
| ].map(({ key, label, content }, idx, arr) => ( |
| <div key={key} className={idx < arr.length - 1 ? "border-b border-violet-500/10" : ""}> |
| <button |
| onClick={() => toggle(key)} |
| className="w-full flex items-center justify-between px-4 py-2.5 hover:bg-violet-900/20 transition-colors" |
| > |
| <span className="text-xs font-medium text-violet-300 uppercase tracking-wider">{label}</span> |
| {openSection === key ? <ChevronUp className="w-3.5 h-3.5 text-muted-foreground" /> : <ChevronDown className="w-3.5 h-3.5 text-muted-foreground" />} |
| </button> |
| <AnimatePresence> |
| {openSection === key && ( |
| <motion.div |
| initial={{ height: 0, opacity: 0 }} |
| animate={{ height: "auto", opacity: 1 }} |
| exit={{ height: 0, opacity: 0 }} |
| className="overflow-hidden" |
| > |
| {content} |
| </motion.div> |
| )} |
| </AnimatePresence> |
| </div> |
| ))} |
| </motion.div> |
| ); |
| } |
|
|
| export function Home() { |
| const { toast } = useToast(); |
| const { t } = useLang(); |
| const queryClient = useQueryClient(); |
| const { isSignedIn, isAdmin } = useAuth(); |
| const [authModalOpen, setAuthModalOpen] = useState(false); |
| const [generatedImageUrl, setGeneratedImageUrl] = useState<string | null>(null); |
| const [apiDebug, setApiDebug] = useState<ApiDebugInfo | null>(null); |
| const [tokenError, setTokenError] = useState<{ expired: boolean; message: string } | null>(null); |
| const [referenceImage, setReferenceImage] = useState<{ base64: string; mime: string; preview: string } | null>(null); |
| const [isPrivate, setIsPrivate] = useState(false); |
| const fileInputRef = useRef<HTMLInputElement>(null); |
|
|
| const { mutate: generateImage, isPending } = useGenerateImage(); |
|
|
| const form = useForm<FormValues>({ |
| resolver: zodResolver(formSchema), |
| defaultValues: { |
| prompt: "", |
| style: "realistic", |
| aspectRatio: "1:1", |
| model: "grok", |
| }, |
| }); |
|
|
| const handleFileUpload = useCallback((file: File) => { |
| if (!file.type.startsWith("image/")) { |
| toast({ variant: "destructive", title: t.errorFormatTitle, description: t.errorFormatDesc }); |
| return; |
| } |
| const reader = new FileReader(); |
| reader.onload = (e) => { |
| const dataUrl = e.target?.result as string; |
| const base64 = dataUrl.split(",")[1]; |
| setReferenceImage({ base64, mime: file.type, preview: dataUrl }); |
| }; |
| reader.readAsDataURL(file); |
| }, [toast, t]); |
|
|
| const handleDrop = useCallback((e: React.DragEvent) => { |
| e.preventDefault(); |
| const file = e.dataTransfer.files[0]; |
| if (file) handleFileUpload(file); |
| }, [handleFileUpload]); |
|
|
| function onSubmit(values: FormValues) { |
| if (!isSignedIn) { |
| setAuthModalOpen(true); |
| return; |
| } |
| setGeneratedImageUrl(null); |
| setApiDebug(null); |
| setTokenError(null); |
| const resolution = values.resolution === "none" || !values.resolution ? undefined : values.resolution; |
| generateImage( |
| { |
| data: { |
| ...values as any, |
| resolution, |
| referenceImageBase64: referenceImage?.base64, |
| referenceImageMime: referenceImage?.mime, |
| isPrivate, |
| }, |
| }, |
| { |
| onSuccess: (data: any) => { |
| setGeneratedImageUrl(data.imageUrl); |
| setApiDebug(data.apiDebug); |
| queryClient.invalidateQueries({ queryKey: getGetImageHistoryQueryKey() }); |
| if (data.tokenExpired) { |
| setTokenError({ expired: true, message: data.error || t.tokenExpiredFallback }); |
| } else if (data.error) { |
| setTokenError({ expired: false, message: data.error }); |
| } |
| }, |
| onError: () => { |
| toast({ variant: "destructive", title: t.errorGenTitle, description: t.errorGenDesc }); |
| }, |
| } |
| ); |
| } |
|
|
| const handleDownload = () => { |
| if (!generatedImageUrl) return; |
| const a = document.createElement("a"); |
| a.href = generatedImageUrl; |
| a.download = "generated-image.jpg"; |
| document.body.appendChild(a); |
| a.click(); |
| document.body.removeChild(a); |
| }; |
|
|
| const selectedModel = form.watch("model") as string; |
| const selectedModelInfo = t.models[selectedModel as keyof typeof t.models]; |
|
|
| return ( |
| <div className="container mx-auto px-4 py-8 max-w-7xl"> |
| <div className="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start"> |
| {/* Left Form Panel */} |
| <div className="lg:col-span-4 space-y-5 bg-card p-6 rounded-xl border border-border shadow-sm"> |
| <div> |
| <h1 className="text-2xl font-bold mb-1">{t.canvasTitle}</h1> |
| <p className="text-sm text-muted-foreground">{t.canvasSubtitle}</p> |
| </div> |
| |
| <Form {...form}> |
| <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> |
| {/* Prompt */} |
| <FormField |
| control={form.control} |
| name="prompt" |
| render={({ field }) => ( |
| <FormItem> |
| <FormLabel>{t.promptLabel}</FormLabel> |
| <FormControl> |
| <Textarea |
| placeholder={t.promptPlaceholder} |
| className="resize-none h-28 focus-visible:ring-primary" |
| {...field} |
| /> |
| </FormControl> |
| <FormMessage /> |
| </FormItem> |
| )} |
| /> |
| |
| {/* Model Selection */} |
| <FormField |
| control={form.control} |
| name="model" |
| render={({ field }) => ( |
| <FormItem> |
| <FormLabel>{t.modelLabel}</FormLabel> |
| <Select onValueChange={field.onChange} defaultValue={field.value}> |
| <FormControl> |
| <SelectTrigger> |
| <SelectValue placeholder={t.modelPlaceholder} /> |
| </SelectTrigger> |
| </FormControl> |
| <SelectContent> |
| {MODEL_KEYS.map((key) => { |
| const m = t.models[key]; |
| return ( |
| <SelectItem key={key} value={key}> |
| <div className="flex items-center gap-2"> |
| <span>{m.label}</span> |
| {m.badge && ( |
| <Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 border-primary/40 text-primary"> |
| {m.badge} |
| </Badge> |
| )} |
| </div> |
| </SelectItem> |
| ); |
| })} |
| </SelectContent> |
| </Select> |
| {selectedModelInfo && ( |
| <p className="text-xs text-muted-foreground mt-1">{selectedModelInfo.desc}</p> |
| )} |
| <FormMessage /> |
| </FormItem> |
| )} |
| /> |
| |
| {/* Style + Ratio */} |
| <div className="grid grid-cols-2 gap-3"> |
| <FormField |
| control={form.control} |
| name="style" |
| render={({ field }) => ( |
| <FormItem> |
| <FormLabel>{t.styleLabel}</FormLabel> |
| <Select onValueChange={field.onChange} defaultValue={field.value}> |
| <FormControl> |
| <SelectTrigger> |
| <SelectValue placeholder={t.stylePlaceholder} /> |
| </SelectTrigger> |
| </FormControl> |
| <SelectContent> |
| {STYLE_KEYS.map((key) => ( |
| <SelectItem key={key} value={key}> |
| {STYLE_EMOJIS[key]} {t.styles[key]} |
| </SelectItem> |
| ))} |
| </SelectContent> |
| </Select> |
| <FormMessage /> |
| </FormItem> |
| )} |
| /> |
| |
| <FormField |
| control={form.control} |
| name="aspectRatio" |
| render={({ field }) => ( |
| <FormItem> |
| <FormLabel>{t.ratioLabel}</FormLabel> |
| <Select onValueChange={field.onChange} defaultValue={field.value}> |
| <FormControl> |
| <SelectTrigger> |
| <SelectValue placeholder={t.ratioPlaceholder} /> |
| </SelectTrigger> |
| </FormControl> |
| <SelectContent> |
| {ASPECT_RATIO_KEYS.map((key) => ( |
| <SelectItem key={key} value={key}> |
| {key} <span className="text-muted-foreground text-xs">({t.ratios[key]})</span> |
| </SelectItem> |
| ))} |
| </SelectContent> |
| </Select> |
| <FormMessage /> |
| </FormItem> |
| )} |
| /> |
| </div> |
| |
| {/* Private toggle */} |
| <div className="flex items-center justify-between py-2 px-3 rounded-lg bg-secondary/40 border border-border/50"> |
| <div className="flex items-center gap-2"> |
| {isPrivate ? <Lock className="w-4 h-4 text-amber-400" /> : <Globe className="w-4 h-4 text-muted-foreground" />} |
| <div> |
| <p className="text-sm font-medium">{isPrivate ? (t.privateLabel ?? "私人記錄") : (t.publicLabel ?? "公開記錄")}</p> |
| <p className="text-xs text-muted-foreground">{isPrivate ? (t.privateDesc ?? "僅您本人可見") : (t.publicDesc ?? "所有人可見")}</p> |
| </div> |
| </div> |
| <button |
| type="button" |
| onClick={() => setIsPrivate(!isPrivate)} |
| className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none ${isPrivate ? "bg-amber-500" : "bg-muted"}`} |
| > |
| <span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${isPrivate ? "translate-x-6" : "translate-x-1"}`} /> |
| </button> |
| </div> |
| |
| {/* Resolution — only for Nano Banana models */} |
| {NANO_BANANA_MODELS.has(selectedModel) && ( |
| <FormField |
| control={form.control} |
| name="resolution" |
| render={({ field }) => ( |
| <FormItem> |
| <FormLabel className="flex items-center gap-1.5"> |
| {t.resolutionLabel} |
| <Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 border-amber-400/40 text-amber-400">Nano Banana</Badge> |
| </FormLabel> |
| <Select onValueChange={field.onChange} value={field.value || ""}> |
| <FormControl> |
| <SelectTrigger> |
| <SelectValue placeholder={t.resolutionPlaceholder} /> |
| </SelectTrigger> |
| </FormControl> |
| <SelectContent> |
| <SelectItem value="none"> |
| <span className="text-muted-foreground">— {t.resolutionPlaceholder} —</span> |
| </SelectItem> |
| <SelectItem value="1K"> |
| <div className="flex items-center gap-2"> |
| <span className="font-semibold text-violet-300">1K</span> |
| <span className="text-xs text-muted-foreground">{t.resolutionDesc1K.replace("1K — ", "")}</span> |
| </div> |
| </SelectItem> |
| <SelectItem value="2K"> |
| <div className="flex items-center gap-2"> |
| <span className="font-semibold text-violet-300">2K</span> |
| <span className="text-xs text-muted-foreground">{t.resolutionDesc2K.replace("2K — ", "")}</span> |
| </div> |
| </SelectItem> |
| <SelectItem value="4K"> |
| <div className="flex items-center gap-2"> |
| <span className="font-semibold text-amber-400">4K</span> |
| <span className="text-xs text-muted-foreground">{t.resolutionDesc4K.replace("4K — ", "")}</span> |
| </div> |
| </SelectItem> |
| </SelectContent> |
| </Select> |
| <FormMessage /> |
| </FormItem> |
| )} |
| /> |
| )} |
| |
| {/* Reference Image Upload */} |
| <div> |
| <div className="text-sm font-medium mb-2 flex items-center gap-1.5"> |
| <ImagePlus className="w-4 h-4" /> |
| <span>{t.refImageLabel}</span> |
| <Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 border-violet-400/40 text-violet-400">{t.refImageBadge}</Badge> |
| </div> |
| |
| {referenceImage ? ( |
| <div className="relative rounded-lg overflow-hidden border border-border group"> |
| <img src={referenceImage.preview} alt="reference" className="w-full h-32 object-cover" /> |
| <div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"> |
| <Button |
| type="button" |
| variant="destructive" |
| size="sm" |
| onClick={() => setReferenceImage(null)} |
| className="gap-1.5" |
| > |
| <X className="w-3.5 h-3.5" /> |
| {t.refImageRemove} |
| </Button> |
| </div> |
| </div> |
| ) : ( |
| <div |
| className="border-2 border-dashed border-border rounded-lg p-4 text-center cursor-pointer hover:border-primary/50 hover:bg-primary/5 transition-colors" |
| onClick={() => fileInputRef.current?.click()} |
| onDrop={handleDrop} |
| onDragOver={(e) => e.preventDefault()} |
| > |
| <Upload className="w-6 h-6 mx-auto mb-2 text-muted-foreground" /> |
| <p className="text-xs text-muted-foreground">{t.refImageDrop}</p> |
| <p className="text-[11px] text-muted-foreground/60 mt-0.5">{t.refImageFormats}</p> |
| </div> |
| )} |
| <input |
| ref={fileInputRef} |
| type="file" |
| accept="image/*" |
| className="hidden" |
| onChange={(e) => { |
| const file = e.target.files?.[0]; |
| if (file) handleFileUpload(file); |
| }} |
| /> |
| </div> |
| |
| {isSignedIn ? ( |
| <Button |
| type="submit" |
| className="w-full h-12 text-lg font-medium shadow-lg shadow-primary/20 transition-all hover:shadow-primary/40" |
| disabled={isPending} |
| > |
| {isPending ? ( |
| <><Loader2 className="mr-2 h-5 w-5 animate-spin" />{t.btnGenerating}</> |
| ) : ( |
| <><Sparkles className="mr-2 h-5 w-5" />{referenceImage ? t.btnImg2Img : t.btnGenerate}</> |
| )} |
| </Button> |
| ) : ( |
| <Button |
| type="submit" |
| variant="outline" |
| className="w-full h-12 text-lg font-medium border-primary/40 text-primary hover:bg-primary/10 hover:border-primary/70 transition-all" |
| > |
| <LogIn className="mr-2 h-5 w-5" /> |
| {t.navSignIn} |
| </Button> |
| )} |
| </form> |
| </Form> |
| </div> |
|
|
| {} |
| <div className="lg:col-span-8 space-y-3"> |
| {} |
| {tokenError && ( |
| <div className={`flex items-start gap-3 p-4 rounded-xl border text-sm ${ |
| tokenError.expired |
| ? "bg-red-500/10 border-red-500/30 text-red-300" |
| : "bg-amber-500/10 border-amber-500/30 text-amber-300" |
| }`}> |
| <AlertTriangle className="w-5 h-5 flex-shrink-0 mt-0.5" /> |
| <div className="flex-1 min-w-0"> |
| {tokenError.expired ? ( |
| <> |
| <p className="font-semibold">{t.tokenExpiredTitle}</p> |
| <p className="mt-1 text-xs opacity-80">{t.tokenExpiredDesc}{" "} |
| {isAdmin |
| ? <Link href="/admin" className="underline font-medium">{t.tokenExpiredLink}</Link> |
| : <span>{t.tokenExpiredLink}</span> |
| } |
| {" "}{t.tokenExpiredSuffix} |
| </p> |
| </> |
| ) : ( |
| <> |
| <p className="font-semibold">{t.tokenErrorTitle}</p> |
| <p className="mt-1 text-xs opacity-80">{tokenError.message}</p> |
| </> |
| )} |
| </div> |
| <button onClick={() => setTokenError(null)} className="opacity-60 hover:opacity-100 flex-shrink-0"> |
| <X className="w-4 h-4" /> |
| </button> |
| </div> |
| )} |
| <div className="relative w-full aspect-square md:aspect-[16/9] lg:aspect-auto lg:h-[480px] bg-secondary/50 rounded-xl border border-border/50 overflow-hidden flex flex-col items-center justify-center"> |
| <AnimatePresence mode="wait"> |
| {isPending ? ( |
| <motion.div |
| key="loading" |
| initial={{ opacity: 0 }} |
| animate={{ opacity: 1 }} |
| exit={{ opacity: 0 }} |
| className="absolute inset-0 flex flex-col items-center justify-center bg-background/50 backdrop-blur-sm z-10" |
| > |
| <div className="relative"> |
| <div className="w-24 h-24 rounded-full border-4 border-primary/20 border-t-primary animate-spin" /> |
| <div className="absolute inset-0 flex items-center justify-center"> |
| <RefreshCw className="w-8 h-8 text-primary animate-pulse" /> |
| </div> |
| </div> |
| <p className="mt-6 text-lg font-medium text-primary animate-pulse tracking-widest"> |
| {t.loadingTitle} |
| </p> |
| <p className="mt-2 text-sm text-muted-foreground"> |
| {selectedModelInfo?.label} — {t.loadingSubtitle} |
| </p> |
| </motion.div> |
| ) : generatedImageUrl ? ( |
| <motion.div |
| key="result" |
| initial={{ opacity: 0, scale: 0.95 }} |
| animate={{ opacity: 1, scale: 1 }} |
| className="relative w-full h-full group" |
| > |
| <img |
| src={generatedImageUrl} |
| alt="Generated artwork" |
| className="w-full h-full object-contain" |
| /> |
| <div className="absolute bottom-6 right-6 opacity-0 group-hover:opacity-100 transition-opacity"> |
| <Button size="icon" className="h-12 w-12 rounded-full shadow-xl" onClick={handleDownload}> |
| <Download className="h-5 w-5" /> |
| </Button> |
| </div> |
| </motion.div> |
| ) : ( |
| <motion.div |
| key="empty" |
| initial={{ opacity: 0 }} |
| animate={{ opacity: 1 }} |
| className="text-center text-muted-foreground" |
| > |
| <div className="w-20 h-20 mx-auto mb-6 rounded-2xl bg-secondary flex items-center justify-center border border-border"> |
| <Sparkles className="w-10 h-10 opacity-50" /> |
| </div> |
| <p className="text-lg">{t.emptyTitle}</p> |
| <p className="text-sm mt-1 opacity-60">{t.emptySubtitle}</p> |
| </motion.div> |
| )} |
| </AnimatePresence> |
| </div> |
|
|
| {apiDebug && <ApiDebugPanel debug={apiDebug} />} |
| </div> |
| </div> |
| <AuthModal open={authModalOpen} onOpenChange={setAuthModalOpen} /> |
| </div> |
| ); |
| } |
|
|