Spaces:
Sleeping
Sleeping
| import { useEffect, useMemo, useRef, useState } from "react"; | |
| type JobStatus = "queued" | "running" | "done" | "error" | "cancelled"; | |
| type ModelInfo = { | |
| id: string; | |
| name: string; | |
| available: boolean; | |
| description: string; | |
| }; | |
| type JobInfo = { | |
| job_id: string; | |
| status: JobStatus; | |
| progress: number; | |
| message: string; | |
| image_paths: string[]; | |
| output_dir: string | null; | |
| error: string | null; | |
| }; | |
| type HistoryItem = { | |
| prompt: string; | |
| negative_prompt: string; | |
| timestamp: string; | |
| }; | |
| type Preset = { | |
| name: string; | |
| prompt: string; | |
| negative_prompt: string; | |
| model: string; | |
| size: string; | |
| count: number; | |
| steps: number; | |
| guidance: number; | |
| image_type: string; | |
| style_preset: string; | |
| style_strength: number; | |
| updated_at: string; | |
| }; | |
| type DashboardStats = { | |
| queued: number; | |
| running: number; | |
| done: number; | |
| error: number; | |
| cancelled: number; | |
| total: number; | |
| last_24h: number; | |
| }; | |
| type AdminSettings = { | |
| content_profile: string; | |
| rate_limit_per_minute: number; | |
| output_retention_days: number; | |
| adult_enabled: boolean; | |
| }; | |
| const API_BASE = "http://127.0.0.1:8008"; | |
| function toImageUrl(path: string): string { | |
| return `${API_BASE}/image?path=${encodeURIComponent(path)}`; | |
| } | |
| type ResolvedImageProps = { | |
| path: string; | |
| alt: string; | |
| }; | |
| function ResolvedImage({ path, alt }: ResolvedImageProps) { | |
| const [src, setSrc] = useState(() => toImageUrl(path)); | |
| useEffect(() => { | |
| let active = true; | |
| setSrc(toImageUrl(path)); | |
| void window.imageForge.readImageDataUrl(path).then((dataUrl) => { | |
| if (active && dataUrl) { | |
| setSrc(dataUrl); | |
| } | |
| }); | |
| return () => { | |
| active = false; | |
| }; | |
| }, [path]); | |
| return <img src={src} alt={alt} />; | |
| } | |
| async function api<T>(path: string, init?: RequestInit): Promise<T> { | |
| const apiKey = localStorage.getItem("imageforge_api_key") || ""; | |
| const response = await fetch(`${API_BASE}${path}`, { | |
| ...init, | |
| headers: { | |
| "Content-Type": "application/json", | |
| "X-ImageForge-Api-Key": apiKey, | |
| ...(init?.headers || {}), | |
| }, | |
| }); | |
| if (!response.ok) { | |
| const text = await response.text(); | |
| throw new Error(text || `HTTP ${response.status}`); | |
| } | |
| return (await response.json()) as T; | |
| } | |
| function App() { | |
| const [activeTab, setActiveTab] = useState<"studio" | "dashboard" | "presets" | "settings">("studio"); | |
| const [apiKeyInput, setApiKeyInput] = useState(localStorage.getItem("imageforge_api_key") || ""); | |
| const [adminSettings, setAdminSettings] = useState<AdminSettings | null>(null); | |
| const lastJobLogSignatureRef = useRef<string>(""); | |
| const [models, setModels] = useState<ModelInfo[]>([]); | |
| const [history, setHistory] = useState<HistoryItem[]>([]); | |
| const [presets, setPresets] = useState<Preset[]>([]); | |
| const [stats, setStats] = useState<DashboardStats | null>(null); | |
| const [prompt, setPrompt] = useState(""); | |
| const [batchPrompts, setBatchPrompts] = useState(""); | |
| const [negativePrompt, setNegativePrompt] = useState(""); | |
| const [model, setModel] = useState("dummy"); | |
| const [modelVariant, setModelVariant] = useState(""); | |
| const [size, setSize] = useState("512x512"); | |
| const [count, setCount] = useState(1); | |
| const [randomSeed, setRandomSeed] = useState(true); | |
| const [seed, setSeed] = useState<number>(42); | |
| const [steps, setSteps] = useState(18); | |
| const [guidance, setGuidance] = useState(6.5); | |
| const [imageType, setImageType] = useState("general"); | |
| const [stylePreset, setStylePreset] = useState("auto"); | |
| const [styleStrength, setStyleStrength] = useState(60); | |
| const [initImagePath, setInitImagePath] = useState<string | null>(null); | |
| const [img2imgStrength, setImg2imgStrength] = useState(0.45); | |
| const [currentJobId, setCurrentJobId] = useState<string | null>(null); | |
| const [jobs, setJobs] = useState<JobInfo[]>([]); | |
| const [jobInfo, setJobInfo] = useState<JobInfo | null>(null); | |
| const [logs, setLogs] = useState<string[]>([]); | |
| const [selectedImage, setSelectedImage] = useState<string | null>(null); | |
| const [compareImages, setCompareImages] = useState<string[]>([]); | |
| async function refreshModels() { | |
| const response = await api<{ value: ModelInfo[]; Count: number }>("/models"); | |
| const data = Array.isArray(response) ? response : response.value || []; | |
| setModels(data); | |
| const preferred = ["a1111", "localai", "diffusion", "dummy"]; | |
| const available = | |
| preferred | |
| .map((id) => data.find((m) => m.id === id && m.available)) | |
| .find(Boolean) || | |
| data.find((m) => m.available) || | |
| data[0]; | |
| if (available) { | |
| setModel((prev) => { | |
| const current = data.find((m) => m.id === prev); | |
| if (!current || !current.available || prev === "dummy") { | |
| return available.id; | |
| } | |
| return prev; | |
| }); | |
| } | |
| } | |
| async function refreshHistory() { | |
| const data = await api<HistoryItem[]>("/history"); | |
| setHistory(data); | |
| } | |
| async function refreshJobs() { | |
| const data = await api<JobInfo[]>("/jobs"); | |
| setJobs(data); | |
| } | |
| async function refreshPresets() { | |
| const data = await api<Preset[]>("/presets"); | |
| setPresets(data); | |
| } | |
| async function refreshStats() { | |
| const data = await api<DashboardStats>("/dashboard/stats"); | |
| setStats(data); | |
| } | |
| async function refreshAdminSettings() { | |
| try { | |
| const data = await api<AdminSettings>("/admin/settings"); | |
| setAdminSettings(data); | |
| } catch { | |
| setAdminSettings(null); | |
| } | |
| } | |
| useEffect(() => { | |
| void Promise.all([refreshModels(), refreshHistory(), refreshJobs(), refreshPresets(), refreshStats(), refreshAdminSettings()]).catch((err: Error) => { | |
| void window.imageForge.showError("Startup Error", err.message); | |
| }); | |
| }, []); | |
| useEffect(() => { | |
| const timer = window.setInterval(() => { | |
| void refreshJobs().catch(() => {}); | |
| void refreshStats().catch(() => {}); | |
| }, 2000); | |
| return () => window.clearInterval(timer); | |
| }, []); | |
| useEffect(() => { | |
| if (!currentJobId) { | |
| return; | |
| } | |
| const timer = window.setInterval(() => { | |
| void api<JobInfo>(`/jobs/${currentJobId}`) | |
| .then((info) => { | |
| setJobInfo(info); | |
| const signature = `${info.status}|${info.progress}|${info.message}`; | |
| if (lastJobLogSignatureRef.current !== signature) { | |
| lastJobLogSignatureRef.current = signature; | |
| setLogs((prev) => { | |
| const next = [...prev, `${new Date().toLocaleTimeString()} | ${info.message}`]; | |
| return next.slice(-200); | |
| }); | |
| } | |
| if (info.status === "done") { | |
| setSelectedImage(info.image_paths[0] || null); | |
| setCurrentJobId(null); | |
| void refreshHistory().catch(() => {}); | |
| void refreshJobs().catch(() => {}); | |
| } | |
| if (info.status === "error" || info.status === "cancelled") { | |
| setCurrentJobId(null); | |
| if (info.error) { | |
| void window.imageForge.showError("Generation Error", info.error); | |
| } | |
| } | |
| }) | |
| .catch((err: Error) => { | |
| setLogs((prev) => [...prev, `${new Date().toLocaleTimeString()} | Poll failed: ${err.message}`]); | |
| }); | |
| }, 1000); | |
| return () => window.clearInterval(timer); | |
| }, [currentJobId]); | |
| const isGenerating = useMemo( | |
| () => Boolean(currentJobId) || jobInfo?.status === "queued" || jobInfo?.status === "running", | |
| [currentJobId, jobInfo?.status] | |
| ); | |
| function buildPayload(basePrompt: string) { | |
| return { | |
| prompt: basePrompt, | |
| negative_prompt: negativePrompt, | |
| model, | |
| model_variant: modelVariant.trim() || null, | |
| size, | |
| count, | |
| random_seed: randomSeed, | |
| seed: randomSeed ? null : seed, | |
| steps, | |
| guidance, | |
| image_type: imageType, | |
| style_preset: stylePreset, | |
| style_strength: styleStrength, | |
| init_image_path: initImagePath, | |
| img2img_strength: img2imgStrength, | |
| }; | |
| } | |
| async function submitOne(basePrompt: string) { | |
| const response = await api<{ job_id: string }>("/generate", { | |
| method: "POST", | |
| body: JSON.stringify(buildPayload(basePrompt)), | |
| }); | |
| return response.job_id; | |
| } | |
| async function handleGenerate() { | |
| if (!prompt.trim()) { | |
| await window.imageForge.showError("Validation", "Prompt darf nicht leer sein."); | |
| return; | |
| } | |
| try { | |
| setLogs((prev) => [...prev, `${new Date().toLocaleTimeString()} | Submit generation`]); | |
| setJobInfo(null); | |
| lastJobLogSignatureRef.current = ""; | |
| setSelectedImage(null); | |
| const jobId = await submitOne(prompt.trim()); | |
| setCurrentJobId(jobId); | |
| await refreshJobs(); | |
| } catch (err) { | |
| const message = err instanceof Error ? err.message : String(err); | |
| await window.imageForge.showError("Generate Failed", message); | |
| } | |
| } | |
| async function handleBatchGenerate() { | |
| const lines = batchPrompts | |
| .split("\n") | |
| .map((line) => line.trim()) | |
| .filter(Boolean); | |
| if (lines.length === 0) { | |
| await window.imageForge.showError("Validation", "Batch-Prompts sind leer."); | |
| return; | |
| } | |
| try { | |
| for (const line of lines) { | |
| await submitOne(line); | |
| } | |
| setLogs((prev) => [...prev, `${new Date().toLocaleTimeString()} | Batch queued (${lines.length})`]); | |
| await refreshJobs(); | |
| await refreshStats(); | |
| } catch (err) { | |
| const message = err instanceof Error ? err.message : String(err); | |
| await window.imageForge.showError("Batch Failed", message); | |
| } | |
| } | |
| async function handleCancel() { | |
| if (!currentJobId) { | |
| return; | |
| } | |
| await api(`/jobs/${currentJobId}/cancel`, { method: "POST" }); | |
| setLogs((prev) => [...prev, `${new Date().toLocaleTimeString()} | Cancel requested`]); | |
| await refreshJobs(); | |
| } | |
| async function handleCancelById(jobId: string) { | |
| await api(`/jobs/${jobId}/cancel`, { method: "POST" }); | |
| await refreshJobs(); | |
| } | |
| async function handleRetry(jobId: string) { | |
| const out = await api<{ new_job_id: string }>(`/jobs/${jobId}/retry`, { method: "POST" }); | |
| setCurrentJobId(out.new_job_id); | |
| await refreshJobs(); | |
| } | |
| async function handleSaveImage() { | |
| if (!selectedImage) { | |
| return; | |
| } | |
| const ok = await window.imageForge.saveImage(selectedImage, "imageforge_output.png"); | |
| if (ok) { | |
| setLogs((prev) => [...prev, `${new Date().toLocaleTimeString()} | Image saved`]); | |
| } | |
| } | |
| async function handleExportImage(format: "png" | "jpg" | "webp") { | |
| if (!selectedImage) { | |
| return; | |
| } | |
| const out = await api<{ output_path: string }>("/export", { | |
| method: "POST", | |
| body: JSON.stringify({ source_path: selectedImage, format, quality: 92 }), | |
| }); | |
| await window.imageForge.saveImage(out.output_path, `imageforge_export.${format}`); | |
| } | |
| async function handleOpenFolder() { | |
| if (jobInfo?.output_dir) { | |
| await window.imageForge.openFolder(jobInfo.output_dir); | |
| } | |
| } | |
| async function handlePickInitImage() { | |
| const path = await window.imageForge.pickImage(); | |
| if (path) { | |
| setInitImagePath(path); | |
| } | |
| } | |
| function toggleCompare(path: string) { | |
| setCompareImages((prev) => (prev.includes(path) ? prev.filter((p) => p !== path) : [...prev.slice(-3), path])); | |
| } | |
| async function savePreset() { | |
| const name = prompt.slice(0, 40) || `preset-${Date.now()}`; | |
| const payload = { | |
| name, | |
| prompt, | |
| negative_prompt: negativePrompt, | |
| model, | |
| size, | |
| count, | |
| steps, | |
| guidance, | |
| image_type: imageType, | |
| style_preset: stylePreset, | |
| style_strength: styleStrength, | |
| }; | |
| await api("/presets", { method: "POST", body: JSON.stringify(payload) }); | |
| await refreshPresets(); | |
| } | |
| function applyHyperrealPortraitPreset() { | |
| setImageType("portrait"); | |
| setStylePreset("photorealistic"); | |
| setStyleStrength(90); | |
| setSteps(48); | |
| setGuidance(7.0); | |
| setModel((prev) => prev || "localai"); | |
| setNegativePrompt( | |
| "low quality, blurry, bad anatomy, extra fingers, waxy skin, overprocessed face, watermark" | |
| ); | |
| } | |
| function applyHyperrealProductPreset() { | |
| setImageType("product"); | |
| setStylePreset("photorealistic"); | |
| setStyleStrength(85); | |
| setSteps(42); | |
| setGuidance(6.5); | |
| setModel((prev) => prev || "localai"); | |
| setNegativePrompt( | |
| "low quality, blurry, noisy, distorted geometry, warped label, watermark, text clutter" | |
| ); | |
| } | |
| async function applyPreset(p: Preset) { | |
| setPrompt(p.prompt); | |
| setNegativePrompt(p.negative_prompt); | |
| setModel(p.model); | |
| setSize(p.size); | |
| setCount(p.count); | |
| setSteps(p.steps); | |
| setGuidance(p.guidance); | |
| setImageType(p.image_type); | |
| setStylePreset(p.style_preset); | |
| setStyleStrength(p.style_strength); | |
| setActiveTab("studio"); | |
| } | |
| async function deletePreset(name: string) { | |
| await api(`/presets/${encodeURIComponent(name)}`, { method: "DELETE" }); | |
| await refreshPresets(); | |
| } | |
| async function saveAdminSettings() { | |
| if (!adminSettings) { | |
| return; | |
| } | |
| const out = await api<AdminSettings>("/admin/settings", { | |
| method: "PUT", | |
| body: JSON.stringify(adminSettings), | |
| }); | |
| setAdminSettings(out); | |
| setLogs((prev) => [...prev, `${new Date().toLocaleTimeString()} | Admin settings updated`]); | |
| } | |
| return ( | |
| <div className="app-grid"> | |
| <aside className="sidebar"> | |
| <h2>Control</h2> | |
| <div className="tabs"> | |
| <button className={activeTab === "studio" ? "tab-active" : ""} onClick={() => setActiveTab("studio")}>Studio</button> | |
| <button className={activeTab === "dashboard" ? "tab-active" : ""} onClick={() => setActiveTab("dashboard")}>Dashboard</button> | |
| <button className={activeTab === "presets" ? "tab-active" : ""} onClick={() => setActiveTab("presets")}>Presets</button> | |
| <button className={activeTab === "settings" ? "tab-active" : ""} onClick={() => setActiveTab("settings")}>Settings</button> | |
| </div> | |
| <label> | |
| API Key | |
| <div className="row-actions"> | |
| <input value={apiKeyInput} onChange={(e) => setApiKeyInput(e.target.value)} /> | |
| <button | |
| type="button" | |
| onClick={() => { | |
| localStorage.setItem("imageforge_api_key", apiKeyInput.trim()); | |
| void Promise.all([refreshModels(), refreshJobs(), refreshStats()]).catch(() => {}); | |
| }} | |
| > | |
| Save | |
| </button> | |
| </div> | |
| </label> | |
| <label> | |
| Model | |
| <select value={model} onChange={(e) => setModel(e.target.value)}> | |
| {models.map((m) => ( | |
| <option key={m.id} value={m.id} disabled={!m.available}> | |
| {m.name} {m.available ? "" : "(not available)"} | |
| </option> | |
| ))} | |
| </select> | |
| </label> | |
| <label> | |
| Model Variant / Checkpoint | |
| <input value={modelVariant} onChange={(e) => setModelVariant(e.target.value)} placeholder="optional model id" /> | |
| </label> | |
| <label> | |
| Size | |
| <select value={size} onChange={(e) => setSize(e.target.value)}> | |
| <option value="512x512">512x512 (fast)</option> | |
| <option value="768x768">768x768</option> | |
| <option value="1024x1024">1024x1024</option> | |
| <option value="1024x1536">1024x1536</option> | |
| <option value="1536x1024">1536x1024</option> | |
| </select> | |
| </label> | |
| <label> | |
| Count | |
| <input type="number" min={1} max={4} value={count} onChange={(e) => setCount(Number(e.target.value) || 1)} /> | |
| </label> | |
| <label className="row"> | |
| <input type="checkbox" checked={randomSeed} onChange={(e) => setRandomSeed(e.target.checked)} /> Random Seed | |
| </label> | |
| <label> | |
| Seed | |
| <input type="number" disabled={randomSeed} value={seed} onChange={(e) => setSeed(Number(e.target.value) || 0)} /> | |
| </label> | |
| <label> | |
| Steps / Quality: {steps} | |
| <input type="range" min={1} max={100} value={steps} onChange={(e) => setSteps(Number(e.target.value))} /> | |
| </label> | |
| <label> | |
| Guidance / Creativity: {guidance.toFixed(1)} | |
| <input type="range" min={1} max={20} step={0.5} value={guidance} onChange={(e) => setGuidance(Number(e.target.value))} /> | |
| </label> | |
| <label> | |
| Bildtyp | |
| <select value={imageType} onChange={(e) => setImageType(e.target.value)}> | |
| <option value="general">Allgemein</option> | |
| <option value="photo">Foto</option> | |
| <option value="portrait">Portrait</option> | |
| <option value="landscape">Landschaft</option> | |
| <option value="architecture">Architektur</option> | |
| <option value="product">Produkt</option> | |
| <option value="logo">Logo</option> | |
| <option value="icon">Icon</option> | |
| <option value="poster">Poster</option> | |
| <option value="illustration">Illustration</option> | |
| <option value="anime">Anime</option> | |
| <option value="pixel_art">Pixel Art</option> | |
| <option value="sketch">Skizze</option> | |
| <option value="painting">Painting</option> | |
| <option value="3d">3D Render</option> | |
| </select> | |
| </label> | |
| <label> | |
| Stil | |
| <select value={stylePreset} onChange={(e) => setStylePreset(e.target.value)}> | |
| <option value="auto">Auto</option> | |
| <option value="photorealistic">Photorealistisch</option> | |
| <option value="cinematic">Cinematic</option> | |
| <option value="minimal">Minimal</option> | |
| <option value="vibrant">Vibrant</option> | |
| <option value="monochrome">Monochrom</option> | |
| <option value="watercolor">Watercolor</option> | |
| <option value="oil">Oil Painting</option> | |
| <option value="noir">Noir</option> | |
| <option value="fantasy">Fantasy</option> | |
| </select> | |
| </label> | |
| <label> | |
| Stil-Staerke: {styleStrength}% | |
| <input type="range" min={0} max={100} step={5} value={styleStrength} onChange={(e) => setStyleStrength(Number(e.target.value))} /> | |
| </label> | |
| <label> | |
| Input-Bild (Img2Img) | |
| <div className="row-actions"> | |
| <button type="button" onClick={handlePickInitImage}>Bild waehlen</button> | |
| <button type="button" onClick={() => setInitImagePath(null)} disabled={!initImagePath}>Entfernen</button> | |
| </div> | |
| {initImagePath ? <small>{initImagePath}</small> : <small>Kein Startbild gesetzt</small>} | |
| </label> | |
| <label> | |
| Img2Img Staerke: {img2imgStrength.toFixed(2)} | |
| <input type="range" min={0} max={1} step={0.05} value={img2imgStrength} onChange={(e) => setImg2imgStrength(Number(e.target.value))} disabled={!initImagePath} /> | |
| </label> | |
| </aside> | |
| <main className="main-area"> | |
| {activeTab === "studio" && ( | |
| <> | |
| <h1>ImageForge Studio</h1> | |
| <label> | |
| Prompt | |
| <textarea value={prompt} onChange={(e) => setPrompt(e.target.value)} rows={5} placeholder="Describe your image..." /> | |
| </label> | |
| <label> | |
| Negative Prompt | |
| <textarea value={negativePrompt} onChange={(e) => setNegativePrompt(e.target.value)} rows={3} /> | |
| </label> | |
| <div className="history-box"> | |
| {history.slice(0, 6).map((item, idx) => ( | |
| <button key={idx} className="history-item" onClick={() => setPrompt(item.prompt)}> | |
| {item.prompt} | |
| </button> | |
| ))} | |
| </div> | |
| <label> | |
| Batch Queue (ein Prompt pro Zeile) | |
| <textarea value={batchPrompts} onChange={(e) => setBatchPrompts(e.target.value)} rows={4} placeholder="prompt 1 prompt 2" /> | |
| </label> | |
| <div className="actions"> | |
| <button onClick={handleGenerate} disabled={isGenerating}>Generate</button> | |
| <button onClick={handleBatchGenerate}>Queue Batch</button> | |
| <button onClick={handleCancel} disabled={!isGenerating}>Cancel</button> | |
| <button onClick={applyHyperrealPortraitPreset}>Hyperreal Portrait</button> | |
| <button onClick={applyHyperrealProductPreset}>Hyperreal Product</button> | |
| <button onClick={savePreset}>Save Preset</button> | |
| <button onClick={handleSaveImage} disabled={!selectedImage}>Save As</button> | |
| <button onClick={handleOpenFolder} disabled={!jobInfo?.output_dir}>Open Folder</button> | |
| <button onClick={() => void handleExportImage("webp")} disabled={!selectedImage}>Export WEBP</button> | |
| </div> | |
| </> | |
| )} | |
| {activeTab === "dashboard" && ( | |
| <> | |
| <h1>Operations Dashboard</h1> | |
| {adminSettings && ( | |
| <div className="preset-list" style={{ marginBottom: 12 }}> | |
| <label className="row" style={{ justifyContent: "space-between" }}> | |
| <span>Adult Repo (HF Space) aktivieren</span> | |
| <input | |
| type="checkbox" | |
| checked={adminSettings.adult_enabled} | |
| onChange={(e) => | |
| setAdminSettings({ ...adminSettings, adult_enabled: e.target.checked }) | |
| } | |
| /> | |
| </label> | |
| <div className="row-actions"> | |
| <button onClick={() => void saveAdminSettings()}>Schalter speichern</button> | |
| </div> | |
| </div> | |
| )} | |
| <div className="stats-grid"> | |
| <div className="stat-card"><strong>Total</strong><span>{stats?.total ?? 0}</span></div> | |
| <div className="stat-card"><strong>Queued</strong><span>{stats?.queued ?? 0}</span></div> | |
| <div className="stat-card"><strong>Running</strong><span>{stats?.running ?? 0}</span></div> | |
| <div className="stat-card"><strong>Done</strong><span>{stats?.done ?? 0}</span></div> | |
| <div className="stat-card"><strong>Error</strong><span>{stats?.error ?? 0}</span></div> | |
| <div className="stat-card"><strong>24h</strong><span>{stats?.last_24h ?? 0}</span></div> | |
| </div> | |
| <div className="job-table"> | |
| {jobs.slice(0, 30).map((j) => ( | |
| <div className="job-row" key={j.job_id}> | |
| <div>{j.job_id}</div> | |
| <div>{j.status}</div> | |
| <div>{j.progress}%</div> | |
| <div>{j.message}</div> | |
| <div className="row-actions"> | |
| <button onClick={() => void handleRetry(j.job_id)}>Retry</button> | |
| <button onClick={() => void handleCancelById(j.job_id)}>Cancel</button> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </> | |
| )} | |
| {activeTab === "presets" && ( | |
| <> | |
| <h1>Preset Library</h1> | |
| <div className="preset-list"> | |
| {presets.map((preset) => ( | |
| <div className="preset-item" key={preset.name}> | |
| <div> | |
| <strong>{preset.name}</strong> | |
| <div>{preset.model} | {preset.size} | steps {preset.steps}</div> | |
| </div> | |
| <div className="row-actions"> | |
| <button onClick={() => void applyPreset(preset)}>Apply</button> | |
| <button onClick={() => void deletePreset(preset.name)}>Delete</button> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </> | |
| )} | |
| {activeTab === "settings" && ( | |
| <> | |
| <h1>Admin Settings</h1> | |
| {adminSettings ? ( | |
| <div className="preset-list"> | |
| <label> | |
| Content Profile | |
| <select | |
| value={adminSettings.content_profile} | |
| onChange={(e) => setAdminSettings({ ...adminSettings, content_profile: e.target.value })} | |
| > | |
| <option value="strict">strict</option> | |
| <option value="internal-relaxed">internal-relaxed</option> | |
| </select> | |
| </label> | |
| <label> | |
| Rate Limit / min | |
| <input | |
| type="number" | |
| value={adminSettings.rate_limit_per_minute} | |
| onChange={(e) => | |
| setAdminSettings({ ...adminSettings, rate_limit_per_minute: Number(e.target.value) || 1 }) | |
| } | |
| /> | |
| </label> | |
| <label> | |
| Output Retention Days | |
| <input | |
| type="number" | |
| value={adminSettings.output_retention_days} | |
| onChange={(e) => | |
| setAdminSettings({ ...adminSettings, output_retention_days: Number(e.target.value) || 1 }) | |
| } | |
| /> | |
| </label> | |
| <label className="row" style={{ justifyContent: "space-between" }}> | |
| <span>Adult Repo (HF Space) aktiv</span> | |
| <input | |
| type="checkbox" | |
| checked={adminSettings.adult_enabled} | |
| onChange={(e) => setAdminSettings({ ...adminSettings, adult_enabled: e.target.checked })} | |
| /> | |
| </label> | |
| <div className="row-actions"> | |
| <button onClick={() => void saveAdminSettings()}>Save Settings</button> | |
| <button onClick={() => void api("/admin/cleanup", { method: "POST" }).then(refreshStats)}>Run Cleanup</button> | |
| </div> | |
| </div> | |
| ) : ( | |
| <div>No admin permissions for settings.</div> | |
| )} | |
| </> | |
| )} | |
| <section className="progress-box"> | |
| <div className="progress-label"> | |
| <span>{jobInfo?.status ?? "idle"}</span> | |
| <span>{jobInfo?.progress ?? 0}%</span> | |
| </div> | |
| <progress max={100} value={jobInfo?.progress ?? 0}></progress> | |
| <div className="log-box"> | |
| {logs.map((line, idx) => ( | |
| <div key={idx}>{line}</div> | |
| ))} | |
| </div> | |
| </section> | |
| </main> | |
| <section className="gallery"> | |
| <h2>Gallery & Compare</h2> | |
| <div className="thumbs"> | |
| {(jobInfo?.image_paths || []).map((path) => ( | |
| <div key={path}> | |
| <button className="thumb" onClick={() => setSelectedImage(path)}> | |
| <ResolvedImage path={path} alt="result" /> | |
| </button> | |
| <label className="row"><input type="checkbox" checked={compareImages.includes(path)} onChange={() => toggleCompare(path)} /> Compare</label> | |
| </div> | |
| ))} | |
| </div> | |
| <div className="preview"> | |
| {selectedImage ? <ResolvedImage path={selectedImage} alt="preview" /> : <div>No preview</div>} | |
| </div> | |
| {compareImages.length > 0 && ( | |
| <div className="compare-grid"> | |
| {compareImages.map((path) => ( | |
| <ResolvedImage key={path} path={path} alt="compare" /> | |
| ))} | |
| </div> | |
| )} | |
| </section> | |
| </div> | |
| ); | |
| } | |
| export default App; | |