kinaiok
Initial deployment setup for Hugging Face Spaces
5ef6e9d
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>
{/* Right Panel */}
<div className="lg:col-span-8 space-y-3">
{/* Token error banner */}
{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>
);
}