Spaces:
Sleeping
Sleeping
| "use client"; | |
| import React, { useState, useEffect, useRef } from 'react'; | |
| import { User, Sparkles, Sword, Globe, Palette, Settings, Image as ImageIcon, Wand2, Dices, Save, Download, Upload, Copy, X, LogIn, LogOut, Loader2, Terminal, AlertTriangle, Maximize2 } from 'lucide-react'; | |
| import { oauthLoginUrl, oauthHandleRedirectIfPresent } from "@huggingface/hub"; | |
| const API_URL = process.env.NODE_ENV === "development" ? "http://127.0.0.1:8000" : ""; | |
| const tabs = [ | |
| { id: 'identity', label: 'Identity & Race', icon: User }, | |
| { id: 'appearance', label: 'Physical Traits', icon: Sparkles }, | |
| { id: 'equipment', label: 'Gear & Weapons', icon: Sword }, | |
| { id: 'environment', label: 'Atmosphere', icon: Globe }, | |
| { id: 'style', label: 'Render Style', icon: Palette }, | |
| { id: 'engines', label: 'Configuration', icon: Settings } | |
| ]; | |
| export default function ClientApp() { | |
| const [config, setConfig] = useState<any>(null); | |
| const [activeTab, setActiveTab] = useState('identity'); | |
| const [showLightbox, setShowLightbox] = useState(false); | |
| // Feedback System State | |
| const [toasts, setToasts] = useState<any[]>([]); | |
| const [logs, setLogs] = useState<any[]>([]); | |
| const [showLogs, setShowLogs] = useState(false); | |
| const [maximizedField, setMaximizedField] = useState<'technical' | 'refined' | null>(null); | |
| const addToast = (message: string, type: 'success' | 'error' | 'info' = 'info') => { | |
| const id = Date.now(); | |
| setToasts(prev => [...prev, { id, message, type }]); | |
| addLog(message, type); | |
| setTimeout(() => { | |
| setToasts(prev => prev.filter(t => t.id !== id)); | |
| }, 5000); | |
| }; | |
| const addLog = (message: string, type: 'success' | 'error' | 'info' = 'info') => { | |
| setLogs(prev => [{ | |
| timestamp: new Date().toLocaleTimeString(), | |
| message, | |
| type | |
| }, ...prev].slice(0, 50)); // Keep last 50 | |
| }; | |
| const [characterName, setCharacterName] = useState("Unnamed Hero"); | |
| const [features, setFeatures] = useState<string[]>([]); | |
| const [randomization, setRandomization] = useState<boolean[]>([]); | |
| const [extraInfo, setExtraInfo] = useState<string[]>(Array(5).fill("")); | |
| const [promptOutput, setPromptOutput] = useState(""); | |
| const [refinedOutput, setRefinedOutput] = useState(""); | |
| const [imageSrc, setImageSrc] = useState<string | null>(null); | |
| const [statusMsg, setStatusMsg] = useState(""); | |
| const [refinementBackend, setRefinementBackend] = useState("Gemini (Cloud)"); | |
| const [imageBackend, setImageBackend] = useState("ComfyUI (Local)"); | |
| const [ollamaModel, setOllamaModel] = useState(""); | |
| const [hfTextModel, setHfTextModel] = useState("Qwen/Qwen2.5-72B-Instruct"); | |
| const [hfTextProvider, setHfTextProvider] = useState("auto"); | |
| const [hfImageModel, setHfImageModel] = useState("black-forest-labs/FLUX.1-dev"); | |
| const [hfImageProvider, setHfImageProvider] = useState("auto"); | |
| const [hfToken, setHfToken] = useState<string | null>(null); | |
| const [manualTextEntry, setManualTextEntry] = useState(false); | |
| const [manualImageEntry, setManualImageEntry] = useState(false); | |
| const [isRefining, setIsRefining] = useState(false); | |
| const [isGenerating, setIsGenerating] = useState(false); | |
| // Restore Settings from LocalStorage & Handle OAuth Redirect | |
| useEffect(() => { | |
| // 1. Handle OAuth Redirect | |
| const handleOAuth = async () => { | |
| try { | |
| // First fetch the client ID provided by Hugging Face to our backend | |
| const configRes = await fetch(`${API_URL}/api/config/oauth`); | |
| const config = await configRes.json(); | |
| const clientId = config.oauth_client_id; | |
| const oauthResult = await oauthHandleRedirectIfPresent(); | |
| if (oauthResult) { | |
| setHfToken(oauthResult.accessToken); | |
| localStorage.setItem('hf_auth_token', oauthResult.accessToken); | |
| // Clear the URL hash | |
| window.history.replaceState({}, document.title, window.location.pathname); | |
| setStatusMsg("Successfully authenticated with Hugging Face!"); | |
| } | |
| } catch (e: any) { | |
| console.error("OAuth error", e); | |
| } | |
| }; | |
| handleOAuth(); | |
| // 2. Restore Token from LocalStorage | |
| const storedToken = localStorage.getItem('hf_auth_token'); | |
| if (storedToken) setHfToken(storedToken); | |
| // 3. Restore Other Settings | |
| const saved = localStorage.getItem('rpg_portrait_settings'); | |
| if (saved) { | |
| try { | |
| const data = JSON.parse(saved); | |
| if (data.refinementBackend) setRefinementBackend(data.refinementBackend); | |
| if (data.imageBackend) setImageBackend(data.imageBackend); | |
| if (data.ollamaModel) setOllamaModel(data.ollamaModel); | |
| if (data.hfTextModel) setHfTextModel(data.hfTextModel); | |
| if (data.hfTextProvider) setHfTextProvider(data.hfTextProvider); | |
| if (data.hfImageModel) setHfImageModel(data.hfImageModel); | |
| if (data.hfImageProvider) setHfImageProvider(data.hfImageProvider); | |
| } catch (e) { | |
| console.error("Failed to restore settings", e); | |
| } | |
| } | |
| }, []); | |
| // Persist Settings to LocalStorage | |
| useEffect(() => { | |
| const settings = { | |
| refinementBackend, | |
| imageBackend, | |
| ollamaModel, | |
| hfTextModel, | |
| hfTextProvider, | |
| hfImageModel, | |
| hfImageProvider | |
| }; | |
| localStorage.setItem('rpg_portrait_settings', JSON.stringify(settings)); | |
| }, [refinementBackend, imageBackend, ollamaModel, hfTextModel, hfTextProvider, hfImageModel, hfImageProvider]); | |
| const fileInputRef = useRef<HTMLInputElement>(null); | |
| useEffect(() => { | |
| fetch(`${API_URL}/api/config`) | |
| .then(res => res.json()) | |
| .then(data => { | |
| setConfig(data); | |
| const initFeatures = data.feature_sequence.map(([cat, sub]: any) => { | |
| const ks = Object.keys(data.features_data[cat]?.[sub] || {}); | |
| return ks.length > 0 ? ks[0] : "None"; | |
| }); | |
| setFeatures(initFeatures); | |
| setRandomization(Array(data.feature_sequence.length).fill(false)); | |
| // Smart Active Engine Defaults | |
| if (data.is_hf_space) { | |
| setImageBackend("Hugging Face (Cloud)"); | |
| setRefinementBackend("Hugging Face (Cloud)"); | |
| } else { | |
| if (!data.comfy_active) { | |
| if (data.hf_active) setImageBackend("Hugging Face (Cloud)"); | |
| else if (data.gemini_active) setImageBackend("Gemini (Cloud)"); | |
| } | |
| if (!data.ollama_models?.length) { | |
| if (data.gemini_active) setRefinementBackend("Gemini (Cloud)"); | |
| else if (data.hf_active) setRefinementBackend("Hugging Face (Cloud)"); | |
| } | |
| } | |
| }) | |
| .catch(err => console.error("Failed to load config", err)); | |
| }, []); | |
| const updatePrompt = async (newFeatures = features, newExtraInfo = extraInfo, newName = characterName) => { | |
| if (!config || newFeatures.length === 0) return; | |
| try { | |
| const res = await fetch(`${API_URL}/api/generate_prompt`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| character_name: newName, | |
| features: newFeatures, | |
| randomization: randomization, | |
| extra_info: newExtraInfo | |
| }) | |
| }); | |
| const data = await res.json(); | |
| setPromptOutput(data.prompt); | |
| } catch (e) { | |
| console.error(e); | |
| } | |
| }; | |
| useEffect(() => { | |
| if (config && features.length > 0) updatePrompt(); | |
| }, [features, extraInfo, characterName]); | |
| const handleFeatureChange = (index: number, val: string) => { | |
| const f = [...features]; | |
| f[index] = val; | |
| setFeatures(f); | |
| }; | |
| const handleExtraInfoChange = (index: number, val: string) => { | |
| const e = [...extraInfo]; | |
| e[index] = val; | |
| setExtraInfo(e); | |
| }; | |
| const handleRandomize = async () => { | |
| try { | |
| setStatusMsg("Randomizing flagged parameters..."); | |
| const res = await fetch(`${API_URL}/api/regenerate_features`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ current_values: features, checkboxes: randomization }) | |
| }); | |
| const data = await res.json(); | |
| setFeatures(data.new_values); | |
| setRefinedOutput(""); // Clear refined narrative when traits are re-rolled | |
| setStatusMsg("Randomization complete."); | |
| } catch (e) { console.error(e); } | |
| }; | |
| const handleGenerateName = async () => { | |
| if (!config) return; | |
| try { | |
| setStatusMsg("Weaving a name from the threads of fate..."); | |
| // find race index | |
| const raceIndex = config?.feature_sequence?.findIndex((seq: any) => seq[1] === "race"); | |
| const race = raceIndex !== -1 && raceIndex !== undefined ? features[raceIndex] : "Human"; | |
| const res = await fetch(`${API_URL}/api/generate_name`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ race }) | |
| }); | |
| if (res.ok) { | |
| const data = await res.json(); | |
| setCharacterName(data.name); | |
| setStatusMsg("Name generation complete."); | |
| } | |
| } catch (e) { console.error(e); } | |
| }; | |
| const applyLoadedData = (data: any) => { | |
| if (!config) return; | |
| setCharacterName(data.name || "Unnamed Hero"); | |
| const loadedFeatures = config.feature_sequence.map((seq: any) => data.features?.[seq[2]] || "None"); | |
| setFeatures(loadedFeatures); | |
| const loadedRandom = config.feature_sequence.map((seq: any) => data.randomization?.[seq[2]] || false); | |
| setRandomization(loadedRandom); | |
| const loadedExtra = config.sections.map((sec: string) => data.extra_info?.[sec.toLowerCase()] || ""); | |
| setExtraInfo(loadedExtra); | |
| setRefinedOutput(""); // Clear refined narrative when new traits are loaded | |
| setStatusMsg(`Loaded ${data.name || "character"} successfully!`); | |
| }; | |
| const handleLoadFile = (e: React.ChangeEvent<HTMLInputElement>) => { | |
| const file = e.target.files?.[0]; | |
| if (!file) return; | |
| const reader = new FileReader(); | |
| reader.onload = (evt) => { | |
| try { | |
| const data = JSON.parse(evt.target?.result as string); | |
| applyLoadedData(data); | |
| } catch (err) { setStatusMsg("Error: Invalid JSON file."); } | |
| }; | |
| reader.readAsText(file); | |
| if (fileInputRef.current) fileInputRef.current.value = ""; | |
| }; | |
| const handleSaveFile = () => { | |
| if (!config) return; | |
| const data: any = { name: characterName, features: {}, randomization: {}, extra_info: {} }; | |
| config.feature_sequence.forEach((seq: any, i: number) => { | |
| data.features[seq[2]] = features[i]; | |
| data.randomization[seq[2]] = randomization[i]; | |
| }); | |
| config.sections.forEach((sec: string, i: number) => { | |
| data.extra_info[sec.toLowerCase()] = extraInfo[i]; | |
| }); | |
| const blob = new Blob([JSON.stringify(data, null, 4)], { type: 'application/json' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = `${characterName.replace(/[^a-z0-9]/gi, '_').toLowerCase() || 'character'}_data.json`; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| setStatusMsg("Character configuration saved!"); | |
| }; | |
| const handleLoadExample = async (filename: string) => { | |
| if (!filename) return; | |
| try { | |
| const res = await fetch(`${API_URL}/api/example/${filename}`); | |
| if(res.ok) { | |
| const data = await res.json(); | |
| applyLoadedData(data); | |
| } else { | |
| setStatusMsg("Failed to load example."); | |
| } | |
| } catch (e) { | |
| setStatusMsg("Failed to load example."); | |
| } | |
| }; | |
| const handleHFLogin = async () => { | |
| try { | |
| const configRes = await fetch(`${API_URL}/api/config/oauth`); | |
| const config = await configRes.json(); | |
| const clientId = config.oauth_client_id; | |
| if (!clientId) { | |
| throw new Error("OAUTH_CLIENT_ID not found. Is your Space configured correctly with hf_oauth: true?"); | |
| } | |
| const url = await oauthLoginUrl({ | |
| clientId: clientId, | |
| scopes: "openid profile inference-api" | |
| }); | |
| window.location.href = url; | |
| } catch (e: any) { | |
| console.error("HF Login Error:", e); | |
| setStatusMsg(`Failed to initiate HF login: ${e.message || String(e)}`); | |
| } | |
| }; | |
| const handleHFLogout = () => { | |
| setHfToken(null); | |
| localStorage.removeItem('hf_auth_token'); | |
| setStatusMsg("Logged out of Hugging Face."); | |
| }; | |
| const handleRefine = async () => { | |
| const activeModel = refinementBackend === "Ollama (Local)" ? (ollamaModel || "llama3") : | |
| (refinementBackend === "Hugging Face (Cloud)" ? hfTextModel : "Gemini-Pro"); | |
| addLog(`Initiating prompt refinement with ${activeModel}...`, "info"); | |
| setIsRefining(true); | |
| try { | |
| const res = await fetch(`${API_URL}/api/refine_prompt`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| prompt: promptOutput, | |
| backend: refinementBackend, | |
| ollama_model: ollamaModel, | |
| hf_text_model: hfTextModel, | |
| hf_text_provider: hfTextProvider, | |
| oauth_token: hfToken, | |
| character_name: characterName | |
| }) | |
| }); | |
| if (res.ok) { | |
| const data = await res.json(); | |
| setRefinedOutput(data.refined_prompt); | |
| addToast("Refinement successful!", "success"); | |
| } else { | |
| const err = await res.json(); | |
| const msg = err.detail || "Refinement failed."; | |
| addToast(msg, "error"); | |
| addLog(`Refinement Error Details: ${JSON.stringify(err)}`, "error"); | |
| } | |
| } catch (e: any) { | |
| addToast("Refinement failed to connect.", "error"); | |
| addLog(`Refinement Exception: ${e.message || String(e)}`, "error"); | |
| } | |
| finally { setIsRefining(false); } | |
| }; | |
| const handleGenerateImage = async () => { | |
| addLog("Synthesizing portrait...", "info"); | |
| setIsGenerating(true); | |
| setImageSrc(null); | |
| try { | |
| const aspectIndex = config.feature_sequence.findIndex((seq: any) => seq[2] === "aspect_ratio"); | |
| const ar = aspectIndex !== -1 ? features[aspectIndex] : "1:1"; | |
| const res = await fetch(`${API_URL}/api/generate_image`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| refined_prompt: refinedOutput, | |
| technical_prompt: promptOutput, | |
| aspect_ratio: ar, | |
| backend: imageBackend, | |
| hf_image_model: hfImageModel, | |
| hf_image_provider: hfImageProvider, | |
| oauth_token: hfToken, | |
| character_name: characterName | |
| }) | |
| }); | |
| if (res.ok) { | |
| const blob = await res.blob(); | |
| setImageSrc(URL.createObjectURL(blob)); | |
| const statusMsg = res.headers.get("X-Status-Msg") || "Synthesis complete!"; | |
| addToast(statusMsg, "success"); | |
| } else { | |
| const err = await res.json(); | |
| const msg = err.detail || "Synthesis failed."; | |
| addToast(msg, "error"); | |
| addLog(`Synthesis Error Details: ${JSON.stringify(err)}`, "error"); | |
| } | |
| } catch (e: any) { | |
| addToast("Synthesis failed to initialize.", "error"); | |
| addLog(`Synthesis Exception: ${e.message || String(e)}`, "error"); | |
| } | |
| finally { setIsGenerating(false); } | |
| }; | |
| const handleDownloadImage = () => { | |
| if (!imageSrc) return; | |
| const a = document.createElement('a'); | |
| a.href = imageSrc; | |
| a.download = `${characterName.replace(/[^a-z0-9]/gi, '_').toLowerCase() || 'portrait'}.png`; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| }; | |
| const handleCopy = (text: string) => { | |
| if(!text) return; | |
| navigator.clipboard.writeText(text); | |
| setStatusMsg("Copied to clipboard!"); | |
| }; | |
| const renderFeatureUI = (cat: string, subcat: string, label: string) => { | |
| const idx = config.feature_sequence.findIndex((seq: any) => seq[0] === cat && seq[1] === subcat); | |
| if (idx === -1) return null; | |
| let mappedIdx = idx; | |
| if (subcat === 'accessory' && label.includes('2')) mappedIdx = idx + 1; | |
| const choices = Object.keys(config.features_data[cat]?.[subcat] || {}); | |
| return ( | |
| <div className="mb-8 relative group" key={`${cat}-${subcat}-${label}`}> | |
| <label className="block text-[10px] uppercase tracking-[0.2em] text-[#DAB062] mb-2 font-bold">{label}</label> | |
| <div className="flex items-center gap-3"> | |
| <select | |
| className="input-alchemist flex-1 appearance-none cursor-pointer" | |
| value={features[mappedIdx] || ""} | |
| onChange={(e) => handleFeatureChange(mappedIdx, e.target.value)} | |
| > | |
| {choices.map(c => <option key={c} value={c}>{c}</option>)} | |
| </select> | |
| <div className="flex flex-col items-center justify-center"> | |
| <button | |
| onClick={() => { | |
| const r = [...randomization]; | |
| r[mappedIdx] = !r[mappedIdx]; | |
| setRandomization(r); | |
| }} | |
| className={`p-2 rounded-md transition-all duration-300 ${randomization[mappedIdx] ? 'bg-[#DAB062] text-[#080604] shadow-[0_0_12px_rgba(218,176,98,0.6)]' : 'bg-[#0a0805] text-[#A89880] hover:text-[#DAB062] border border-[#2D241A] hover:border-[#DAB062]/50'}`} | |
| title="Toggle Randomization" | |
| > | |
| <Dices size={16} /> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| if (!config) return <div className="h-screen w-full flex items-center justify-center font-cinzel text-2xl text-[#DAB062]">Igniting forge...</div>; | |
| return ( | |
| <div className="flex h-screen w-full overflow-hidden text-[#E2D1B3] antialiased"> | |
| {/* Hidden file input for loading JSON */} | |
| <input type="file" accept=".json" className="hidden" ref={fileInputRef} onChange={handleLoadFile} /> | |
| {/* 1. Left Sidebar Navigation */} | |
| <div className="w-[300px] border-r border-[#2D241A] bg-[#0a0805]/95 backdrop-blur-xl flex flex-col relative z-20"> | |
| <div className="p-8 border-b border-[#2D241A] text-center"> | |
| <h1 className="font-cinzel font-bold text-3xl gold-gradient-text drop-shadow-md mb-2">Chronicle</h1> | |
| <p className="text-[#A89880] text-sm uppercase tracking-[0.2em]">Portrait Studio</p> | |
| <p className="text-[#665D4F] text-[10px] font-mono mt-2 opacity-80">v1.0.0</p> | |
| </div> | |
| <nav className="flex-1 px-4 py-8 space-y-2 overflow-y-auto custom-scrollbar"> | |
| <div className="text-[10px] uppercase tracking-widest text-[#665D4F] mb-4 px-4">Creation Aspects</div> | |
| {tabs.map(t => { | |
| const Icon = t.icon; | |
| const isActive = activeTab === t.id; | |
| return ( | |
| <button | |
| key={t.id} | |
| onClick={() => setActiveTab(t.id)} | |
| className={`w-full flex items-center gap-4 px-4 py-3 rounded-lg transition-all duration-300 group | |
| ${isActive ? 'bg-[#18130E] border border-[#3D3224] shadow-[inset_0_0_15px_rgba(218,176,98,0.05)]' : 'border border-transparent hover:bg-[#120E0A]'}`} | |
| > | |
| <Icon size={20} className={isActive ? 'text-[#DAB062]' : 'text-[#665D4F] group-hover:text-[#A89880]'} /> | |
| <span className={`text-sm tracking-wide flex-1 text-left ${isActive ? 'text-[#E2D1B3] font-medium' : 'text-[#8C7F6B] group-hover:text-[#A89880]'}`}> | |
| {t.label} | |
| </span> | |
| </button> | |
| ); | |
| })} | |
| <div className="h-4"></div> | |
| <div className="text-[10px] uppercase tracking-widest text-[#A89880] mb-4 px-4">State Management</div> | |
| <div className="px-2 grid grid-cols-2 gap-2 mb-4"> | |
| <button onClick={() => fileInputRef.current?.click()} className="flex items-center justify-center gap-2 px-3 py-2 border border-[#2D241A] bg-[#0E151A]/50 rounded font-medium text-[#A89880] hover:text-[#DAB062] hover:border-[#DAB062]/50 transition-colors text-xs tracking-wider"> | |
| <Upload size={14} /> Load | |
| </button> | |
| <button onClick={handleSaveFile} className="flex items-center justify-center gap-2 px-3 py-2 border border-[#2D241A] bg-[#0E151A]/50 rounded font-medium text-[#A89880] hover:text-[#DAB062] hover:border-[#DAB062]/50 transition-colors text-xs tracking-wider"> | |
| <Save size={14} /> Save | |
| </button> | |
| </div> | |
| <div className="px-2 mb-6"> | |
| <label className="text-[10px] block uppercase tracking-widest text-[#A89880] mb-2 px-2">Load Example</label> | |
| <select | |
| onChange={(e) => handleLoadExample(e.target.value)} | |
| className="input-alchemist py-2 text-xs" | |
| defaultValue="" | |
| > | |
| <option value="" disabled>Select a Preset...</option> | |
| {config.examples?.map((ex: string) => <option key={ex} value={ex}>{ex.replace('.json', '')}</option>)} | |
| </select> | |
| </div> | |
| <div className="text-[10px] uppercase tracking-widest text-[#A89880] mb-4 px-4 border-t border-[#2D241A] pt-6">Global Actions</div> | |
| <button onClick={handleRandomize} className="w-full mx-2 flex items-center justify-center gap-3 px-4 py-3 border border-[#3A4E59] bg-[#0E151A] rounded-lg text-[#61C2DF] hover:bg-[#121B22] hover:border-[#61C2DF] transition-all group mb-4" style={{ width: 'calc(100% - 1rem)'}}> | |
| <Dices size={18} /> | |
| <span className="text-sm tracking-wide font-medium">Reroll Marked Traits</span> | |
| </button> | |
| <button | |
| onClick={() => setShowLogs(!showLogs)} | |
| className={`w-full mx-2 mb-8 flex items-center justify-center gap-3 px-4 py-2 border rounded-lg transition-all text-[10px] uppercase tracking-[0.2em] font-bold ${showLogs ? 'bg-[#DAB062] text-[#080604] border-[#DAB062]' : 'bg-[#0a0805] text-[#A89880] border-[#2D241A] hover:border-[#DAB062]/50'}`} | |
| style={{ width: 'calc(100% - 1rem)'}} | |
| > | |
| <Terminal size={14} /> | |
| <span>System Status {logs.length > 0 && `(${logs.length})`}</span> | |
| </button> | |
| </nav> | |
| </div> | |
| {/* 2. Main Middle Area (Form Aspects) */} | |
| <div className="flex-1 flex flex-col relative z-10 overflow-y-auto shadow-inner custom-scrollbar bg-[#0f0c08]"> | |
| <div className="sticky top-0 h-12 bg-gradient-to-b from-[#080604] to-transparent z-10 pointer-events-none w-full"></div> | |
| <div className="max-w-4xl mx-auto w-full px-6 pb-24 pt-2"> | |
| {/* App Logo */} | |
| <div className="w-full h-32 mb-6 relative border border-[#2D241A] rounded-xl bg-gradient-to-b from-[#0a0805] to-transparent shadow-inner overflow-hidden flex items-center justify-center"> | |
| <img src="/logo.svg" alt="Chronicle Portrait Studio Logo" className="w-full h-full object-cover drop-shadow-[0_0_15px_rgba(218,176,98,0.3)]" /> | |
| </div> | |
| {/* Section Headers */} | |
| <div className="mb-6 pl-2"> | |
| <h2 className="font-cinzel text-3xl text-[#E2D1B3] mb-2">{tabs.find(t => t.id === activeTab)?.label}</h2> | |
| <div className="w-16 h-1 bg-[#DAB062] rounded-full opacity-50"></div> | |
| </div> | |
| <div className="glass-panel rounded-xl p-6 mb-6 relative overflow-hidden group"> | |
| <div className="absolute top-0 right-0 w-96 h-96 bg-[#DAB062]/5 rounded-full blur-3xl -mr-32 -mt-32 pointer-events-none transition-all duration-700 group-hover:bg-[#DAB062]/10"></div> | |
| <div className="relative z-10 w-full"> | |
| {activeTab === 'engines' && ( | |
| <div className="w-full space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500"> | |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> | |
| <div className="bg-[#0a0805]/80 border border-[#2D241A] rounded-xl p-6 shadow-2xl backdrop-blur-sm"> | |
| <div className="flex items-center gap-3 mb-6"> | |
| <div className="p-2 bg-[#DAB062]/10 rounded-lg"> | |
| <Sparkles className="text-[#DAB062]" size={20} /> | |
| </div> | |
| <h3 className="font-cinzel text-xl text-[#E2D1B3]">Text Intelligence</h3> | |
| </div> | |
| <div className="space-y-5"> | |
| <div> | |
| <label className="text-[10px] block uppercase tracking-widest text-[#A89880] mb-2">Backbone Engine</label> | |
| <select value={refinementBackend} onChange={e => setRefinementBackend(e.target.value)} className="input-alchemist w-full font-cinzel"> | |
| <option value="Gemini (Cloud)" disabled={!config?.gemini_active}>Gemini (Cloud){!config?.gemini_active ? ' (Missing Key)' : ''}</option> | |
| <option value="Hugging Face (Cloud)" disabled={!config?.hf_active && !hfToken}>Hugging Face (Cloud){(!config?.hf_active && !hfToken) ? ' (Missing Token)' : ''}</option> | |
| <option value="Ollama (Local)" disabled={config?.is_hf_space || !config?.ollama_models?.length}>Ollama (Local){config?.is_hf_space ? ' (Local Only)' : (!config?.ollama_models?.length ? ' (Offline)' : '')}</option> | |
| </select> | |
| </div> | |
| {refinementBackend === "Ollama (Local)" && ( | |
| <div className="animate-in fade-in slide-in-from-top-2"> | |
| <label className="text-[10px] block uppercase tracking-widest text-[#A89880] mb-2">Local Model Identifier</label> | |
| <select value={ollamaModel} onChange={e => setOllamaModel(e.target.value)} className="input-alchemist w-full"> | |
| <option value="">Default (llama3)</option> | |
| {config?.ollama_models?.map((m: string) => <option key={m} value={m}>{m}</option>)} | |
| </select> | |
| </div> | |
| )} | |
| {refinementBackend === "Hugging Face (Cloud)" && ( | |
| <div className="animate-in fade-in slide-in-from-top-2"> | |
| <div className="flex items-center justify-between mb-2"> | |
| <label className="text-[10px] block uppercase tracking-widest text-[#A89880]">Refinement Model</label> | |
| <button onClick={() => setManualTextEntry(!manualTextEntry)} className="text-[10px] uppercase text-[#DAB062] hover:underline transition-all"> | |
| {manualTextEntry ? "Standard List" : "Manual Entry"} | |
| </button> | |
| </div> | |
| {manualTextEntry ? ( | |
| <input | |
| type="text" | |
| value={hfTextModel} | |
| onChange={e => setHfTextModel(e.target.value)} | |
| className="input-alchemist w-full" | |
| placeholder="Enter model ID..." | |
| /> | |
| ) : ( | |
| <select | |
| value={hfTextModel} | |
| onChange={e => setHfTextModel(e.target.value)} | |
| className="input-alchemist w-full" | |
| > | |
| {config?.hf_text_models?.map((m: string) => ( | |
| <option key={m} value={m}>{m}</option> | |
| ))} | |
| {config?.hf_text_models && !config.hf_text_models.includes(hfTextModel) && ( | |
| <option value={hfTextModel}>{hfTextModel}</option> | |
| )} | |
| </select> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| <div className="bg-[#0a0805]/80 border border-[#2D241A] rounded-xl p-6 shadow-2xl backdrop-blur-sm"> | |
| <div className="flex items-center gap-3 mb-6"> | |
| <div className="p-2 bg-[#DAB062]/10 rounded-lg"> | |
| <ImageIcon className="text-[#DAB062]" size={20} /> | |
| </div> | |
| <h3 className="font-cinzel text-xl text-[#E2D1B3]">Visual Synthesis</h3> | |
| </div> | |
| <div className="space-y-5"> | |
| <div> | |
| <label className="text-[10px] block uppercase tracking-widest text-[#A89880] mb-2">Generation Core</label> | |
| <select value={imageBackend} onChange={e => setImageBackend(e.target.value)} className="input-alchemist w-full font-cinzel"> | |
| <option value="Gemini (Cloud)" disabled={!config?.gemini_active}>Gemini (Cloud){!config?.gemini_active ? ' (Missing Key)' : ''}</option> | |
| <option value="Hugging Face (Cloud)" disabled={!config?.hf_active && !hfToken}>Hugging Face (Cloud){(!config?.hf_active && !hfToken) ? ' (Missing Token)' : ''}</option> | |
| <option value="ComfyUI (Local)" disabled={config?.is_hf_space || !config?.comfy_active}>ComfyUI (Local){config?.is_hf_space ? ' (Local Only)' : (!config?.comfy_active ? ' (Offline)' : '')}</option> | |
| </select> | |
| </div> | |
| {imageBackend === "Hugging Face (Cloud)" && ( | |
| <div className="space-y-4 animate-in fade-in slide-in-from-top-2"> | |
| <div> | |
| <div className="flex items-center justify-between mb-2"> | |
| <label className="text-[10px] block uppercase tracking-widest text-[#A89880]">Diffusion Model</label> | |
| <button onClick={() => setManualImageEntry(!manualImageEntry)} className="text-[10px] uppercase text-[#DAB062] hover:underline transition-all"> | |
| {manualImageEntry ? "Standard List" : "Manual Entry"} | |
| </button> | |
| </div> | |
| {manualImageEntry ? ( | |
| <input | |
| type="text" | |
| value={hfImageModel} | |
| onChange={e => setHfImageModel(e.target.value)} | |
| className="input-alchemist w-full" | |
| placeholder="Enter model ID..." | |
| /> | |
| ) : ( | |
| <select | |
| value={hfImageModel} | |
| onChange={e => setHfImageModel(e.target.value)} | |
| className="input-alchemist w-full" | |
| > | |
| {config?.hf_image_models?.map((m: string) => ( | |
| <option key={m} value={m}>{m}</option> | |
| ))} | |
| {config?.hf_image_models && !config.hf_image_models.includes(hfImageModel) && ( | |
| <option value={hfImageModel}>{hfImageModel}</option> | |
| )} | |
| </select> | |
| )} | |
| </div> | |
| <div> | |
| <label className="text-[10px] block uppercase tracking-widest text-[#A89880] mb-2">Inference Gateway</label> | |
| <select value={hfImageProvider} onChange={e => setHfImageProvider(e.target.value)} className="input-alchemist w-full"> | |
| <option value="auto">Auto-Managed</option> | |
| <option value="fal-ai">Fal-AI (Fast)</option> | |
| <option value="replicate">Replicate</option> | |
| <option value="hf-inference">Direct HF API</option> | |
| </select> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| <div className="mt-8 border-t border-[#2D241A] pt-8 animate-in fade-in slide-in-from-bottom-2 duration-700 delay-200"> | |
| <div className="bg-[#0a0805]/80 border border-[#2D241A] rounded-xl p-6 shadow-2xl backdrop-blur-sm"> | |
| <div className="flex items-center justify-between mb-6"> | |
| <div className="flex items-center gap-3"> | |
| <div className="p-2 bg-[#DAB062]/10 rounded-lg"> | |
| <User className="text-[#DAB062]" size={20} /> | |
| </div> | |
| <h3 className="font-cinzel text-xl text-[#E2D1B3]">Hugging Face Identity</h3> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <div className={`w-2 h-2 rounded-full ${hfToken ? 'bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.6)]' : (config?.hf_active ? 'bg-yellow-500 shadow-[0_0_8px_rgba(234,179,8,0.6)]' : 'bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.6)]')}`}></div> | |
| <span className="text-[10px] uppercase tracking-widest text-[#A89880]"> | |
| {hfToken ? 'Authenticated' : (config?.hf_active ? 'Developer Mode' : 'Disconnected')} | |
| </span> | |
| </div> | |
| </div> | |
| <p className="text-xs text-[#8C7F6B] mb-6 leading-relaxed"> | |
| {hfToken | |
| ? "You are currently signed in with your Hugging Face account. Your scoped access token is being used for inference requests." | |
| : (config?.hf_active | |
| ? "The studio is using the developer's pre-configured API key (.env). Sign in to use your own account and bypass server limits." | |
| : "No Hugging Face credentials found. Please sign in to enable cloud-based generation.") | |
| } | |
| </p> | |
| {hfToken ? ( | |
| <button onClick={handleHFLogout} className="flex items-center gap-2 px-4 py-2 border border-red-900/30 bg-red-950/10 rounded-lg text-red-400 hover:bg-red-900/20 transition-all text-xs font-medium uppercase tracking-widest"> | |
| <LogOut size={14} /> Sign Out of HF | |
| </button> | |
| ) : ( | |
| <button onClick={handleHFLogin} className="flex items-center gap-2 px-6 py-3 bg-[#DAB062] text-[#0a0805] rounded-xl font-cinzel font-bold text-sm tracking-widest hover:bg-[#F2DAA4] transition-all shadow-lg shadow-yellow-900/10"> | |
| <LogIn size={18} /> Sign in with Hugging Face | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {activeTab === 'identity' && ( | |
| <div className="w-full"> | |
| <div className="mb-6 p-5 bg-[#0a0805] border border-[#2D241A] rounded-xl shadow-inner relative group"> | |
| <div className="flex items-center justify-between mb-3"> | |
| <label className="text-[10px] uppercase tracking-[0.2em] text-[#DAB062] font-bold">Subject Name</label> | |
| <button onClick={handleGenerateName} title="Generate random name based on Race" className="text-[#A89880] hover:text-[#DAB062] transition-colors flex items-center gap-1.5"><Dices size={14} /><span className="text-[10px] uppercase tracking-widest font-medium">Auto</span></button> | |
| </div> | |
| <input type="text" value={characterName} onChange={e => setCharacterName(e.target.value)} className="w-full bg-[#120E0A] border border-[#3D3224] text-[#E2D1B3] font-cinzel text-xl font-semibold rounded-lg px-4 py-3 outline-none focus:border-[#DAB062] focus:ring-1 focus:ring-[#DAB062]/50 transition-all placeholder-[#E2D1B3]/20" placeholder="Enter name..."/> | |
| </div> | |
| <div className="grid grid-cols-2 gap-x-12 gap-y-6"> | |
| {renderFeatureUI('identity', 'race', 'Race')} | |
| {renderFeatureUI('identity', 'class', 'Class')} | |
| {renderFeatureUI('identity', 'gender', 'Gender')} | |
| {renderFeatureUI('identity', 'age', 'Age')} | |
| </div> | |
| <div className="mt-6"> | |
| <label className="block text-xs uppercase tracking-widest text-[#A89880] mb-4">Lore / Background Text</label> | |
| <textarea value={extraInfo[0]} onChange={e => handleExtraInfoChange(0, e.target.value)} className="input-alchemist h-32 resize-none leading-relaxed"></textarea> | |
| </div> | |
| <div className="my-8 border-t border-[#2D241A]/50"></div> | |
| <h3 className="font-cinzel text-2xl text-[#DAB062] mb-8 drop-shadow-sm">Expression & Stance</h3> | |
| <div className="grid grid-cols-2 gap-x-12 gap-y-6"> | |
| {renderFeatureUI('expression_pose', 'expression', 'Expression')} | |
| {renderFeatureUI('expression_pose', 'pose', 'Pose')} | |
| </div> | |
| </div> | |
| )} | |
| {activeTab === 'appearance' && ( | |
| <div className="w-full"> | |
| <div className="grid grid-cols-2 gap-x-12 gap-y-6"> | |
| {renderFeatureUI('appearance', 'hair_color', 'Hair Color')} | |
| {renderFeatureUI('appearance', 'hair_style', 'Hair Style')} | |
| {renderFeatureUI('appearance', 'eye_color', 'Eye Color')} | |
| {renderFeatureUI('appearance', 'build', 'Build')} | |
| {renderFeatureUI('appearance', 'skin_tone', 'Skin Tone')} | |
| {renderFeatureUI('appearance', 'distinguishing_feature', 'Main Feature')} | |
| </div> | |
| <div className="mt-6"> | |
| <label className="block text-xs uppercase tracking-widest text-[#A89880] mb-4">Additional Visuals</label> | |
| <textarea value={extraInfo[1]} onChange={e => handleExtraInfoChange(1, e.target.value)} className="input-alchemist h-32 resize-none leading-relaxed"></textarea> | |
| </div> | |
| </div> | |
| )} | |
| {activeTab === 'equipment' && ( | |
| <div className="w-full"> | |
| <div className="grid grid-cols-2 gap-x-12 gap-y-6"> | |
| {renderFeatureUI('equipment', 'armor', 'Primary Gear')} | |
| {renderFeatureUI('equipment', 'weapon', 'Weapon Variant')} | |
| {renderFeatureUI('equipment', 'accessory', 'Acc. 1')} | |
| {renderFeatureUI('equipment', 'accessory', 'Acc. 2')} | |
| {renderFeatureUI('equipment', 'material', 'Material Finish')} | |
| </div> | |
| <div className="mt-6"> | |
| <label className="block text-xs uppercase tracking-widest text-[#A89880] mb-4">Custom Equipment Description</label> | |
| <textarea value={extraInfo[2]} onChange={e => handleExtraInfoChange(2, e.target.value)} className="input-alchemist h-32 resize-none leading-relaxed"></textarea> | |
| </div> | |
| </div> | |
| )} | |
| {activeTab === 'environment' && ( | |
| <div className="w-full"> | |
| <div className="grid grid-cols-2 gap-x-12 gap-y-6"> | |
| {renderFeatureUI('environment', 'background', 'Backdrop')} | |
| {renderFeatureUI('environment', 'lighting', 'Illumination')} | |
| {renderFeatureUI('environment', 'atmosphere', 'Atmosphere')} | |
| </div> | |
| <div className="mt-6"> | |
| <label className="block text-xs uppercase tracking-widest text-[#A89880] mb-4">Custom Environment Info</label> | |
| <textarea value={extraInfo[3]} onChange={e => handleExtraInfoChange(3, e.target.value)} className="input-alchemist h-32 resize-none leading-relaxed"></textarea> | |
| </div> | |
| </div> | |
| )} | |
| {activeTab === 'style' && ( | |
| <div className="w-full"> | |
| <div className="grid grid-cols-2 gap-x-12 gap-y-6"> | |
| {renderFeatureUI('vfx_style', 'vfx', 'Particle Effects')} | |
| {renderFeatureUI('vfx_style', 'style', 'Illustration Style')} | |
| {renderFeatureUI('vfx_style', 'mood', 'Color Grading/Mood')} | |
| {renderFeatureUI('vfx_style', 'camera', 'Camera Setup')} | |
| {renderFeatureUI('technical', 'aspect_ratio', 'Aspect Ratio')} | |
| </div> | |
| <div className="mt-10"> | |
| <label className="block text-xs uppercase tracking-widest text-[#A89880] mb-4">Additional Technical Prompts</label> | |
| <textarea value={extraInfo[4]} onChange={e => handleExtraInfoChange(4, e.target.value)} className="input-alchemist h-32 resize-none leading-relaxed"></textarea> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| {/* 3. Right Crucible Area (Output & Synthesis) */} | |
| <div className="w-[450px] border-l border-[#2D241A] bg-[#0C0A08] flex flex-col flex-shrink-0 shadow-[-10px_0_30px_rgba(0,0,0,0.5)] z-30"> | |
| {/* Output Image Canvas */} | |
| <div className="h-[45%] p-6 border-b border-[#2D241A] relative flex items-center justify-center overflow-hidden"> | |
| <div className="absolute inset-0 bg-[url('https://www.transparenttextures.com/patterns/dark-matter.png')] opacity-10 pointer-events-none"></div> | |
| {imageSrc ? ( | |
| <div className="relative w-full h-full flex items-center justify-center rounded-lg overflow-hidden group cursor-zoom-in" onClick={() => setShowLightbox(true)}> | |
| <div className="absolute inset-0 border border-[#DAB062] opacity-30 pointer-events-none z-10 rounded-lg group-hover:opacity-60 transition-opacity"></div> | |
| <img src={imageSrc || ""} alt="Portrait" className="max-w-full max-h-full object-contain drop-shadow-[0_0_20px_rgba(218,176,98,0.2)] transition-transform duration-700 ease-in-out group-hover:scale-[1.02]" /> | |
| <div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors pointer-events-none flex items-center justify-center"> | |
| <div className="opacity-0 group-hover:opacity-100 transition-opacity bg-[#0a0805]/80 backdrop-blur-md border border-[#DAB062]/40 p-3 rounded-full text-[#DAB062] shadow-xl"> | |
| <ImageIcon size={24} /> | |
| </div> | |
| </div> | |
| <div className="absolute bottom-3 right-3 flex items-center gap-2"> | |
| <button onClick={(e) => { e.stopPropagation(); handleDownloadImage(); }} title="Download Portrait" className="opacity-0 group-hover:opacity-100 bg-black/60 backdrop-blur-md text-[#E2D1B3] p-2 rounded-md hover:text-[#DAB062] hover:bg-black/80 border border-[#2D241A] transition-all"><Download size={18}/></button> | |
| </div> | |
| </div> | |
| ) : statusMsg && (statusMsg.includes("Error") || statusMsg.includes("Failed")) ? ( | |
| <div className="flex flex-col items-center justify-center text-center px-8 animate-in fade-in zoom-in-95 duration-500"> | |
| <div className="p-4 bg-red-950/20 border border-red-500/30 rounded-full mb-6 relative"> | |
| <X size={48} className="text-red-500/60" /> | |
| <div className="absolute inset-0 bg-red-500/10 blur-xl rounded-full"></div> | |
| </div> | |
| <h4 className="font-cinzel text-red-400 tracking-[0.3em] text-sm mb-3 font-bold uppercase drop-shadow-sm">Synthesis Chamber Fault</h4> | |
| <p className="text-red-300/60 text-xs italic font-medium leading-relaxed max-w-[280px]">"{statusMsg}"</p> | |
| <button onClick={() => setStatusMsg("")} className="mt-6 px-4 py-1.5 border border-red-900/50 rounded-full text-[10px] uppercase tracking-widest text-red-400/50 hover:bg-red-900/10 hover:text-red-400 transition-all">Clear Error</button> | |
| </div> | |
| ) : ( | |
| <div className="flex flex-col items-center justify-center text-center opacity-40"> | |
| <ImageIcon size={56} className="text-[#A89880] mb-4 stroke-1 line-through opacity-20" /> | |
| <p className="font-cinzel text-[#A89880] tracking-widest text-sm italic">Visual Synthesis Pending</p> | |
| </div> | |
| )} | |
| </div> | |
| {/* Action Panel */} | |
| <div className="flex-1 p-6 flex flex-col overflow-y-auto custom-scrollbar"> | |
| <div className="flex-1 flex flex-col group relative shrink-0 min-h-[160px]"> | |
| <div className="flex items-center justify-between mb-4"> | |
| <label className="text-xs uppercase tracking-widest text-[#A89880] font-medium flex items-center gap-2"><Sparkles size={14} className="text-[#61C2DF]" /> Narrative Prompt</label> | |
| <div className="flex items-center gap-2"> | |
| <button onClick={() => setMaximizedField('refined')} title="Zoom In" className="p-1.5 rounded-lg bg-transparent border border-[#1A2E38] hover:bg-[#1A2E38]/50 text-[#A89880] hover:text-[#DAB062] transition-all"><Maximize2 size={14} /></button> | |
| <button onClick={() => handleCopy(refinedOutput || promptOutput)} title="Copy Narrative" className="p-1.5 rounded-lg bg-transparent border border-[#1A2E38] hover:bg-[#1A2E38]/50 text-[#A89880] hover:text-[#61C2DF] transition-all"><Copy size={14} /></button> | |
| </div> | |
| </div> | |
| <textarea | |
| value={refinedOutput} | |
| onChange={e => setRefinedOutput(e.target.value)} | |
| placeholder="Click refine to generate an artistic prompt... or type manually." | |
| className="flex-1 bg-[#12181C] border border-[#1A2E38] rounded-xl p-4 text-sm text-[#E0F2FE] tracking-wide resize-none focus:outline-none focus:border-[#61C2DF] focus:ring-1 focus:ring-[#61C2DF]/30 custom-scrollbar shadow-inner transition-colors group-hover:border-[#223F4D]" | |
| ></textarea> | |
| <button | |
| onClick={handleRefine} | |
| disabled={isRefining} | |
| className={`mt-4 w-full shrink-0 relative overflow-hidden group bg-gradient-to-r from-[#1A3F4D] via-[#2F657D] to-[#1A3F4D] text-[#E0F2FE] font-cinzel font-bold text-sm tracking-widest py-3.5 rounded-xl transition-all ${isRefining ? 'opacity-70 cursor-not-allowed border border-[#61C2DF]/30 shadow-[0_0_15px_rgba(97,194,223,0.3)]' : 'shadow-[0_0_15px_rgba(97,194,223,0.15)] hover:shadow-[0_0_25px_rgba(97,194,223,0.3)]'}`} | |
| > | |
| {!isRefining && <div className="absolute inset-0 bg-white/10 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-1000 ease-out"></div>} | |
| <span className="relative z-10 flex items-center justify-center gap-3"> | |
| {isRefining ? <Loader2 size={18} className="animate-spin" /> : <Sparkles size={18} />} | |
| {isRefining ? 'Refining...' : 'Enhance Narrative'} | |
| </span> | |
| </button> | |
| </div> | |
| <div className="my-4 border-t border-[#2D241A]/30"></div> | |
| <div className="flex flex-col group relative shrink-0"> | |
| <div className="flex items-center justify-between mb-4"> | |
| <label className="text-xs uppercase tracking-widest text-[#A89880] font-medium">Autogenerated Base Prompt</label> | |
| <div className="flex items-center gap-2"> | |
| <button onClick={() => setMaximizedField('technical')} title="Zoom In" className="p-1.5 rounded-lg bg-transparent border border-[#2D241A] hover:bg-[#2D241A]/50 text-[#A89880] hover:text-[#DAB062] transition-all"><Maximize2 size={14} /></button> | |
| <button onClick={() => handleCopy(promptOutput)} title="Copy Base Prompt" className="p-1.5 rounded-lg bg-transparent border border-[#2D241A] hover:bg-[#2D241A]/50 text-[#A89880] hover:text-[#DAB062] transition-all"><Copy size={14} /></button> | |
| </div> | |
| </div> | |
| <textarea readOnly value={promptOutput} className="h-40 bg-[#080604] border border-[#1A2E38] rounded-xl p-4 text-xs text-[#E2D1B3] resize-none custom-scrollbar shadow-inner outline-none leading-relaxed"></textarea> | |
| </div> | |
| <div className={`text-center font-cinzel text-[10px] tracking-[0.2em] my-4 min-h-[20px] transition-all duration-300 ${statusMsg.toLowerCase().includes('error') || statusMsg.toLowerCase().includes('failed') ? 'text-red-400 font-bold animate-pulse' : 'text-[#DAB062] font-medium'}`}> | |
| {statusMsg && ( | |
| <span className="flex items-center justify-center gap-2"> | |
| {statusMsg.toLowerCase().includes('error') || statusMsg.toLowerCase().includes('failed') ? <X size={12}/> : <Sparkles size={12}/>} | |
| {statusMsg} | |
| </span> | |
| )} | |
| </div> | |
| <button | |
| onClick={handleGenerateImage} | |
| disabled={isGenerating} | |
| className={`w-full shrink-0 relative overflow-hidden group bg-gradient-to-r from-[#8C6D3B] via-[#DAB062] to-[#8C6D3B] text-[#0a0805] font-cinzel font-bold text-base tracking-widest py-4 rounded-xl transition-all ${isGenerating ? 'opacity-70 cursor-not-allowed border border-[#DAB062]/50 shadow-[0_0_20px_rgba(218,176,98,0.4)]' : 'shadow-[0_0_20px_rgba(218,176,98,0.2)] hover:shadow-[0_0_30px_rgba(218,176,98,0.4)]'}`} | |
| > | |
| {!isGenerating && <div className="absolute inset-0 bg-white/20 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-1000 ease-out"></div>} | |
| <span className="relative z-10 flex items-center justify-center gap-3"> | |
| {isGenerating ? <Loader2 size={22} className="animate-spin" /> : <Wand2 size={22} />} | |
| {isGenerating ? 'Synthesizing...' : 'Synthesize'} | |
| </span> | |
| </button> | |
| </div> | |
| </div> | |
| {/* FULL SCREEN LIGHTBOX MODAL */} | |
| {showLightbox && imageSrc && ( | |
| <div className="fixed inset-0 z-[100] bg-[#080604]/95 backdrop-blur-2xl flex items-center justify-center p-4 md:p-8 animate-in fade-in duration-300" onClick={() => setShowLightbox(false)}> | |
| <div className="absolute top-8 right-8 z-[110] flex gap-4"> | |
| <button onClick={(e) => { e.stopPropagation(); handleDownloadImage(); }} className="text-[#A89880] hover:text-[#DAB062] transition-colors bg-black/40 p-3 rounded-full border border-[#2D241A] shadow-xl group/btn" title="Download Masterpiece"> | |
| <Download size={24} /> | |
| <span className="absolute right-full mr-4 top-1/2 -translate-y-1/2 px-3 py-1 bg-black/80 text-[#DAB062] text-[10px] uppercase tracking-widest rounded-md border border-[#2D241A] opacity-0 group-hover/btn:opacity-100 transition-opacity whitespace-nowrap pointer-events-none">Preserve Masterpiece</span> | |
| </button> | |
| <button onClick={() => setShowLightbox(false)} className="text-[#A89880] hover:text-[#61C2DF] transition-colors bg-black/40 p-3 rounded-full border border-[#2D241A] shadow-xl" title="Close"> | |
| <X size={24} /> | |
| </button> | |
| </div> | |
| <div className="relative max-w-full max-h-full flex items-center justify-center animate-in zoom-in-95 duration-500" onClick={e => e.stopPropagation()}> | |
| <div className="relative"> | |
| <div className="absolute inset-0 border border-[#DAB062]/20 rounded-xl pointer-events-none blur-sm"></div> | |
| <img | |
| src={imageSrc || ""} | |
| alt="Portrait High Resolution" | |
| className="max-w-full max-h-[92vh] rounded-xl shadow-[0_0_100px_rgba(218,176,98,0.15)] object-contain border border-[#2D241A]" | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* SYSTEM LOG PANEL */} | |
| {showLogs && ( | |
| <div className="fixed left-[300px] bottom-0 right-0 h-[300px] bg-[#0a0805]/95 backdrop-blur-2xl border-t border-[#2D241A] z-40 animate-in slide-in-from-bottom duration-500 shadow-[0_-20px_50px_rgba(0,0,0,0.5)]"> | |
| <div className="p-4 border-b border-[#2D241A] flex items-center justify-between"> | |
| <div className="flex items-center gap-3"> | |
| <Terminal size={16} className="text-[#DAB062]" /> | |
| <span className="font-cinzel text-xs uppercase tracking-widest text-[#DAB062]">Alchemical Synthesis Logs</span> | |
| </div> | |
| <button onClick={() => setShowLogs(false)} className="text-[#665D4F] hover:text-[#E2D1B3]"> | |
| <X size={18} /> | |
| </button> | |
| </div> | |
| <div className="p-4 overflow-y-auto h-[calc(300px-54px)] custom-scrollbar font-mono text-[11px] space-y-2"> | |
| {logs.length === 0 ? ( | |
| <div className="text-[#665D4F] italic text-center py-10">The records are empty. Initiate an operation.</div> | |
| ) : ( | |
| logs.map((log, i) => ( | |
| <div key={i} className="flex gap-4 group/log"> | |
| <span className="text-[#443C32] select-none shrink-0">[{log.timestamp}]</span> | |
| <span className={` | |
| ${log.type === 'error' ? 'text-red-400' : ''} | |
| ${log.type === 'success' ? 'text-green-400' : ''} | |
| ${log.type === 'info' ? 'text-[#8C7F6B]' : ''} | |
| `}>{log.message}</span> | |
| </div> | |
| )) | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| {/* TOAST SYSTEM */} | |
| <div className="fixed top-8 right-8 z-[150] flex flex-col gap-4 pointer-events-none"> | |
| {toasts.map(toast => ( | |
| <div | |
| key={toast.id} | |
| className={` | |
| pointer-events-auto px-6 py-4 rounded-xl border shadow-2xl backdrop-blur-xl animate-in fade-in slide-in-from-right-8 duration-500 flex items-center gap-4 min-w-[320px] max-w-md | |
| ${toast.type === 'error' ? 'bg-red-500/10 border-red-500/20 text-red-200' : ''} | |
| ${toast.type === 'success' ? 'bg-[#DAB062]/10 border-[#DAB062]/20 text-[#DAB062]' : ''} | |
| ${toast.type === 'info' ? 'bg-blue-500/10 border-blue-500/20 text-blue-200' : ''} | |
| `} | |
| > | |
| {toast.type === 'error' ? <AlertTriangle size={20} className="text-red-400" /> : <Sparkles size={20} className="text-[#DAB062]" />} | |
| <div className="flex-1"> | |
| <div className="text-[10px] uppercase tracking-widest opacity-60 font-bold mb-1">{toast.type}</div> | |
| <div className="text-xs font-medium leading-relaxed">{toast.message}</div> | |
| </div> | |
| <button onClick={() => setToasts(prev => prev.filter(t => t.id !== toast.id))} className="text-white/20 hover:text-white transition-colors"> | |
| <X size={14} /> | |
| </button> | |
| </div> | |
| ))} | |
| </div> | |
| {/* MAXIMIZED PROMPT MODAL */} | |
| {maximizedField && ( | |
| <div className="fixed inset-0 z-[200] bg-[#080604]/98 backdrop-blur-3xl flex items-center justify-center p-6 md:p-12 animate-in fade-in duration-300"> | |
| <div className="absolute inset-0 bg-[url('https://www.transparenttextures.com/patterns/dark-matter.png')] opacity-10 pointer-events-none"></div> | |
| <div className="relative w-full max-w-5xl h-full flex flex-col bg-[#0a0805] border border-[#2D241A] rounded-2xl shadow-[0_0_100px_rgba(0,0,0,0.8)] overflow-hidden animate-in zoom-in-95 duration-500"> | |
| <div className="p-6 border-b border-[#2D241A] flex items-center justify-between bg-gradient-to-r from-[#0a0805] to-[#120E0A]"> | |
| <div className="flex items-center gap-4"> | |
| <div className="p-2 bg-[#DAB062]/10 rounded-lg"> | |
| {maximizedField === 'refined' ? <Sparkles className="text-[#DAB062]" size={20} /> : <Settings className="text-[#A89880]" size={20} />} | |
| </div> | |
| <div> | |
| <h3 className="font-cinzel text-xl text-[#E2D1B3] tracking-wider"> | |
| {maximizedField === 'refined' ? 'Expanded Narrative Scroll' : 'Internal Technical Formula'} | |
| </h3> | |
| <p className="text-[10px] uppercase tracking-[0.2em] text-[#665D4F] mt-1"> | |
| {maximizedField === 'refined' ? 'Detailed character chronicle' : 'Direct alchemical markers'} | |
| </p> | |
| </div> | |
| </div> | |
| <button onClick={() => setMaximizedField(null)} className="p-2 text-[#665D4F] hover:text-[#E2D1B3] transition-colors"><X size={24} /></button> | |
| </div> | |
| <div className="flex-1 p-8 relative"> | |
| <textarea | |
| readOnly={maximizedField === 'technical'} | |
| value={maximizedField === 'refined' ? refinedOutput : promptOutput} | |
| onChange={e => maximizedField === 'refined' && setRefinedOutput(e.target.value)} | |
| className="w-full h-full bg-transparent text-lg md:text-xl text-[#E2D1B3] leading-relaxed resize-none font-serif tracking-wide focus:outline-none custom-scrollbar" | |
| placeholder={maximizedField === 'refined' ? "Write your epic narrative here..." : ""} | |
| /> | |
| <div className="absolute bottom-8 right-8 flex gap-4"> | |
| <button | |
| onClick={() => handleCopy(maximizedField === 'refined' ? (refinedOutput || promptOutput) : promptOutput)} | |
| className="flex items-center gap-3 px-6 py-3 bg-[#0a0805] border border-[#2D241A] rounded-xl text-[#A89880] hover:text-[#DAB062] hover:border-[#DAB062]/50 transition-all font-cinzel text-xs uppercase tracking-widest" | |
| > | |
| <Copy size={16} /> Copy Record | |
| </button> | |
| <button | |
| onClick={() => setMaximizedField(null)} | |
| className="flex items-center gap-3 px-8 py-3 bg-[#DAB062] text-[#0a0805] rounded-xl font-cinzel font-bold text-xs uppercase tracking-widest hover:shadow-[0_0_20px_rgba(218,176,98,0.4)] transition-all" | |
| > | |
| Close Scroll | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |