Initial commit — ComfyUI Discord bot + web UI
Full source for the-third-rev: Discord bot (discord.py), FastAPI web UI (React/TS/Vite/Tailwind), ComfyUI integration, generation history DB, preset manager, workflow inspector, and all supporting modules. Excluded from tracking: .env, invite_tokens.json, *.db (SQLite), current-workflow-changes.json, user_settings/, presets/, logs/, web-static/ (build output), frontend/node_modules/. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ComfyUI Bot</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2774
frontend/package-lock.json
generated
Normal file
2774
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
frontend/package.json
Normal file
27
frontend/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "comfyui-bot-ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.27.0",
|
||||
"@tanstack/react-query": "^5.62.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.11"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
68
frontend/src/App.tsx
Normal file
68
frontend/src/App.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import React from 'react'
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { useAuth } from './hooks/useAuth'
|
||||
import Layout from './components/Layout'
|
||||
import { GenerationProvider } from './context/GenerationContext'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import GeneratePage from './pages/GeneratePage'
|
||||
import InputImagesPage from './pages/InputImagesPage'
|
||||
import WorkflowPage from './pages/WorkflowPage'
|
||||
import PresetsPage from './pages/PresetsPage'
|
||||
import StatusPage from './pages/StatusPage'
|
||||
import ServerPage from './pages/ServerPage'
|
||||
import HistoryPage from './pages/HistoryPage'
|
||||
import AdminPage from './pages/AdminPage'
|
||||
import SharePage from './pages/SharePage'
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuth()
|
||||
if (isLoading) return <div className="flex items-center justify-center h-screen text-gray-500">Loading...</div>
|
||||
if (!isAuthenticated) return <Navigate to="/login" replace />
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function RequireAdmin({ children }: { children: React.ReactNode }) {
|
||||
const { isAdmin, isLoading } = useAuth()
|
||||
if (isLoading) return null
|
||||
if (!isAdmin) return <Navigate to="/generate" replace />
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<GenerationProvider>
|
||||
<Layout />
|
||||
</GenerationProvider>
|
||||
</RequireAuth>
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="/generate" replace />} />
|
||||
<Route path="generate" element={<GeneratePage />} />
|
||||
<Route path="inputs" element={<InputImagesPage />} />
|
||||
<Route path="workflow" element={<WorkflowPage />} />
|
||||
<Route path="presets" element={<PresetsPage />} />
|
||||
<Route path="status" element={<StatusPage />} />
|
||||
<Route path="server" element={<ServerPage />} />
|
||||
<Route path="history" element={<HistoryPage />} />
|
||||
<Route
|
||||
path="admin"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<AdminPage />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/share/:token" element={<SharePage />} />
|
||||
<Route path="*" element={<Navigate to="/generate" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
198
frontend/src/api/client.ts
Normal file
198
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
/** Typed API client for the ComfyUI Bot web API. */
|
||||
|
||||
const BASE = '' // same-origin in prod; Vite proxy in dev
|
||||
|
||||
async function _fetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(BASE + path, {
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...init?.headers },
|
||||
...init,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const msg = await res.text().catch(() => res.statusText)
|
||||
throw new Error(`${res.status}: ${msg}`)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
// Auth
|
||||
export const authLogin = (token: string) =>
|
||||
_fetch<{ label: string; admin: boolean }>('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ token }),
|
||||
})
|
||||
|
||||
export const authLogout = () =>
|
||||
_fetch<{ ok: boolean }>('/api/auth/logout', { method: 'POST' })
|
||||
|
||||
export const authMe = () =>
|
||||
_fetch<{ label: string; admin: boolean }>('/api/auth/me')
|
||||
|
||||
// Admin
|
||||
export const adminLogin = (password: string) =>
|
||||
_fetch<{ label: string; admin: boolean }>('/api/admin/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password }),
|
||||
})
|
||||
|
||||
export const adminListTokens = () =>
|
||||
_fetch<Array<{ id: string; label: string; admin: boolean; created_at: string }>>('/api/admin/tokens')
|
||||
|
||||
export const adminCreateToken = (label: string, admin = false) =>
|
||||
_fetch<{ token: string; label: string; admin: boolean }>('/api/admin/tokens', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ label, admin }),
|
||||
})
|
||||
|
||||
export const adminRevokeToken = (id: string) =>
|
||||
_fetch<{ ok: boolean }>(`/api/admin/tokens/${id}`, { method: 'DELETE' })
|
||||
|
||||
// Status
|
||||
export const getStatus = () => _fetch<Record<string, unknown>>('/api/status')
|
||||
|
||||
// State / overrides
|
||||
export const getState = () => _fetch<Record<string, unknown>>('/api/state')
|
||||
|
||||
export const putState = (overrides: Record<string, unknown>) =>
|
||||
_fetch<Record<string, unknown>>('/api/state', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(overrides),
|
||||
})
|
||||
|
||||
export const deleteStateKey = (key: string) =>
|
||||
_fetch<{ ok: boolean }>(`/api/state/${key}`, { method: 'DELETE' })
|
||||
|
||||
// Generation
|
||||
export interface GenerateRequest {
|
||||
prompt: string
|
||||
negative_prompt?: string
|
||||
overrides?: Record<string, unknown>
|
||||
}
|
||||
export const generate = (body: GenerateRequest) =>
|
||||
_fetch<{ queued: boolean; queue_position: number }>('/api/generate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
export interface WorkflowGenRequest {
|
||||
count?: number
|
||||
overrides?: Record<string, unknown>
|
||||
}
|
||||
export const workflowGen = (body: WorkflowGenRequest) =>
|
||||
_fetch<{ queued: boolean; count: number; queue_position: number }>('/api/workflow-gen', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
// Inputs
|
||||
export interface InputImage {
|
||||
id: number
|
||||
original_message_id: number
|
||||
bot_reply_id: number | null
|
||||
channel_id: number
|
||||
filename: string
|
||||
is_active: number
|
||||
active_slot_key: string | null
|
||||
}
|
||||
export const listInputs = () => _fetch<InputImage[]>('/api/inputs')
|
||||
|
||||
export const uploadInput = (file: File, slotKey = 'input_image') => {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
form.append('slot_key', slotKey)
|
||||
return fetch('/api/inputs', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: form,
|
||||
}).then(r => r.json())
|
||||
}
|
||||
|
||||
export const activateInput = (id: number, slotKey = 'input_image') =>
|
||||
_fetch<{ ok: boolean }>(`/api/inputs/${id}/activate?slot_key=${slotKey}`, { method: 'POST' })
|
||||
|
||||
export const deleteInput = (id: number) =>
|
||||
_fetch<{ ok: boolean }>(`/api/inputs/${id}`, { method: 'DELETE' })
|
||||
|
||||
export const getInputImage = (id: number) => `/api/inputs/${id}/image`
|
||||
export const getInputThumb = (id: number) => `/api/inputs/${id}/thumb`
|
||||
export const getInputMid = (id: number) => `/api/inputs/${id}/mid`
|
||||
|
||||
// Presets
|
||||
export interface PresetMeta { name: string; owner: string | null; description: string | null }
|
||||
export const listPresets = () => _fetch<{ presets: PresetMeta[] }>('/api/presets')
|
||||
export const savePreset = (name: string, description?: string) =>
|
||||
_fetch<{ ok: boolean }>('/api/presets', { method: 'POST', body: JSON.stringify({ name, description: description ?? null }) })
|
||||
export const getPreset = (name: string) => _fetch<Record<string, unknown>>(`/api/presets/${name}`)
|
||||
export const loadPreset = (name: string) =>
|
||||
_fetch<{ ok: boolean }>(`/api/presets/${name}/load`, { method: 'POST' })
|
||||
export const deletePreset = (name: string) =>
|
||||
_fetch<{ ok: boolean }>(`/api/presets/${name}`, { method: 'DELETE' })
|
||||
export const savePresetFromHistory = (promptId: string, name: string, description?: string) =>
|
||||
_fetch<{ ok: boolean; name: string }>(`/api/presets/from-history/${promptId}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, description: description ?? null }),
|
||||
})
|
||||
|
||||
// Server
|
||||
export const getServerStatus = () =>
|
||||
_fetch<{ service_state: string; http_reachable: boolean }>('/api/server/status')
|
||||
export const serverAction = (action: string) =>
|
||||
_fetch<{ ok: boolean }>(`/api/server/${action}`, { method: 'POST' })
|
||||
export const tailLogs = (lines = 100) =>
|
||||
_fetch<{ lines: string[] }>(`/api/logs/tail?lines=${lines}`)
|
||||
|
||||
// History
|
||||
export const getHistory = (q?: string) =>
|
||||
_fetch<{ history: Array<Record<string, unknown>> }>(q ? `/api/history?q=${encodeURIComponent(q)}` : '/api/history')
|
||||
|
||||
export const createHistoryShare = (promptId: string) =>
|
||||
_fetch<{ share_token: string }>(`/api/history/${promptId}/share`, { method: 'POST' })
|
||||
|
||||
export const revokeHistoryShare = (promptId: string) =>
|
||||
_fetch<{ ok: boolean }>(`/api/history/${promptId}/share`, { method: 'DELETE' })
|
||||
|
||||
export const getShareFileUrl = (token: string, filename: string) =>
|
||||
`/api/share/${token}/file/${encodeURIComponent(filename)}`
|
||||
|
||||
// Workflow
|
||||
export const getWorkflow = () =>
|
||||
_fetch<{ loaded: boolean; node_count: number; last_workflow_file: string | null }>('/api/workflow')
|
||||
|
||||
export interface NodeInput {
|
||||
key: string
|
||||
label: string
|
||||
input_type: string
|
||||
current_value: unknown
|
||||
node_class: string
|
||||
node_title: string
|
||||
is_common: boolean
|
||||
}
|
||||
export const getWorkflowInputs = () =>
|
||||
_fetch<{ common: NodeInput[]; advanced: NodeInput[] }>('/api/workflow/inputs')
|
||||
|
||||
export const listWorkflowFiles = () =>
|
||||
_fetch<{ files: string[] }>('/api/workflow/files')
|
||||
|
||||
export const uploadWorkflow = (file: File) => {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return fetch('/api/workflow/upload', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: form,
|
||||
}).then(r => r.json())
|
||||
}
|
||||
|
||||
export const loadWorkflow = (filename: string) => {
|
||||
const form = new FormData()
|
||||
form.append('filename', filename)
|
||||
return fetch('/api/workflow/load', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: form,
|
||||
}).then(r => r.json())
|
||||
}
|
||||
|
||||
export const getModels = (type: 'checkpoints' | 'loras') =>
|
||||
_fetch<{ type: string; models: string[] }>(`/api/workflow/models?type=${type}`)
|
||||
|
||||
329
frontend/src/components/DynamicWorkflowForm.tsx
Normal file
329
frontend/src/components/DynamicWorkflowForm.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
getWorkflowInputs,
|
||||
getModels,
|
||||
putState,
|
||||
deleteStateKey,
|
||||
getState,
|
||||
NodeInput,
|
||||
activateInput,
|
||||
listInputs,
|
||||
getInputImage,
|
||||
getInputThumb,
|
||||
getInputMid,
|
||||
} from '../api/client'
|
||||
import LazyImage from './LazyImage'
|
||||
|
||||
interface Props {
|
||||
/** Called when the Generate button is clicked with the current overrides */
|
||||
onGenerate: (overrides: Record<string, unknown>, count: number) => void
|
||||
/** Live seed from WS generation_complete event */
|
||||
lastSeed?: number | null
|
||||
generating?: boolean
|
||||
/** The authenticated user's label (used to find their active input image) */
|
||||
userLabel?: string
|
||||
}
|
||||
|
||||
export default function DynamicWorkflowForm({ onGenerate, lastSeed, generating, userLabel }: Props) {
|
||||
const qc = useQueryClient()
|
||||
const { data: inputsData, isLoading: inputsLoading } = useQuery({
|
||||
queryKey: ['workflow', 'inputs'],
|
||||
queryFn: getWorkflowInputs,
|
||||
})
|
||||
const { data: stateData } = useQuery({
|
||||
queryKey: ['state'],
|
||||
queryFn: getState,
|
||||
})
|
||||
const { data: checkpoints } = useQuery({
|
||||
queryKey: ['models', 'checkpoints'],
|
||||
queryFn: () => getModels('checkpoints'),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
const { data: loras } = useQuery({
|
||||
queryKey: ['models', 'loras'],
|
||||
queryFn: () => getModels('loras'),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
const { data: inputImages } = useQuery({
|
||||
queryKey: ['inputs'],
|
||||
queryFn: listInputs,
|
||||
})
|
||||
|
||||
const [localValues, setLocalValues] = useState<Record<string, unknown>>({})
|
||||
const [randomSeeds, setRandomSeeds] = useState<Record<string, boolean>>({})
|
||||
const [imagePicker, setImagePicker] = useState<string | null>(null) // key of slot being picked
|
||||
const [count, setCount] = useState(1)
|
||||
|
||||
// Sync local values from state when stateData arrives
|
||||
useEffect(() => {
|
||||
if (stateData) setLocalValues(stateData as Record<string, unknown>)
|
||||
}, [stateData])
|
||||
|
||||
// Update seed field when WS reports completed seed
|
||||
useEffect(() => {
|
||||
if (lastSeed != null) {
|
||||
setLocalValues(v => ({ ...v, seed: lastSeed }))
|
||||
}
|
||||
}, [lastSeed])
|
||||
|
||||
const putStateMut = useMutation({
|
||||
mutationFn: (overrides: Record<string, unknown>) => putState(overrides),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['state'] }),
|
||||
})
|
||||
const deleteKeyMut = useMutation({
|
||||
mutationFn: (key: string) => deleteStateKey(key),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['state'] }),
|
||||
})
|
||||
|
||||
const setValue = (key: string, value: unknown) => {
|
||||
setLocalValues(v => ({ ...v, [key]: value }))
|
||||
putStateMut.mutate({ [key]: value })
|
||||
}
|
||||
|
||||
const handleActivateImage = async (imageId: number, slotKey: string) => {
|
||||
await activateInput(imageId, slotKey)
|
||||
qc.invalidateQueries({ queryKey: ['inputs'] })
|
||||
qc.invalidateQueries({ queryKey: ['state'] })
|
||||
setImagePicker(null)
|
||||
}
|
||||
|
||||
const handleGenerate = () => {
|
||||
const overrides: Record<string, unknown> = {}
|
||||
const allInputs = [...(inputsData?.common ?? []), ...(inputsData?.advanced ?? [])]
|
||||
for (const inp of allInputs) {
|
||||
if (inp.input_type === 'seed') {
|
||||
overrides[inp.key] = randomSeeds[inp.key] !== false ? -1 : (localValues[inp.key] ?? -1)
|
||||
} else if (inp.input_type === 'image') {
|
||||
// image slot — server reads from state_manager
|
||||
} else {
|
||||
const v = localValues[inp.key]
|
||||
if (v !== undefined && v !== '') overrides[inp.key] = v
|
||||
}
|
||||
}
|
||||
onGenerate(overrides, count)
|
||||
}
|
||||
|
||||
if (inputsLoading) return <div className="text-sm text-gray-400">Loading workflow inputs…</div>
|
||||
if (!inputsData) return <div className="text-sm text-gray-400">No workflow loaded.</div>
|
||||
|
||||
const renderField = (inp: NodeInput) => {
|
||||
const val = localValues[inp.key] ?? inp.current_value
|
||||
|
||||
if (inp.input_type === 'text') {
|
||||
return (
|
||||
<textarea
|
||||
rows={3}
|
||||
className="w-full border border-gray-300 dark:border-gray-600 rounded px-2 py-1 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 resize-y focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
value={String(val ?? '')}
|
||||
onChange={e => setValue(inp.key, e.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (inp.input_type === 'seed') {
|
||||
const isRandom = randomSeeds[inp.key] !== false
|
||||
return (
|
||||
<div className="flex gap-2 items-center">
|
||||
<input
|
||||
type="number"
|
||||
className="flex-1 border border-gray-300 dark:border-gray-600 rounded px-2 py-1 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-40"
|
||||
value={isRandom ? '' : String(val ?? '')}
|
||||
placeholder={isRandom ? 'Random' : undefined}
|
||||
disabled={isRandom}
|
||||
onChange={e => setValue(inp.key, Number(e.target.value))}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRandomSeeds(r => ({ ...r, [inp.key]: !isRandom }))}
|
||||
className={`text-xs px-2 py-1 rounded border ${isRandom ? 'bg-blue-600 text-white border-blue-600' : 'border-gray-400 text-gray-600 dark:text-gray-300'}`}
|
||||
>
|
||||
🎲 Random
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (inp.input_type === 'image') {
|
||||
const activeFilename = String(val ?? '')
|
||||
const namespacedKey = userLabel ? `${userLabel}_${inp.key}` : inp.key
|
||||
const activeImg = inputImages?.find(i => i.active_slot_key === namespacedKey)
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{activeImg ? (
|
||||
<img
|
||||
src={getInputThumb(activeImg.id)}
|
||||
alt={activeImg.filename}
|
||||
className="w-16 h-16 object-cover rounded border border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-16 h-16 rounded border border-dashed border-gray-400 flex items-center justify-center text-xs text-gray-400">none</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[12rem]">{activeFilename || 'No image active'}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setImagePicker(imagePicker === inp.key ? null : inp.key)}
|
||||
className="text-xs bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500 rounded px-2 py-0.5"
|
||||
>
|
||||
Browse
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (inp.input_type === 'checkpoint') {
|
||||
return (
|
||||
<select
|
||||
className="w-full border border-gray-300 dark:border-gray-600 rounded px-2 py-1 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
value={String(val ?? '')}
|
||||
onChange={e => setValue(inp.key, e.target.value)}
|
||||
>
|
||||
<option value="">— select checkpoint —</option>
|
||||
{(checkpoints?.models ?? []).map(m => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
if (inp.input_type === 'lora') {
|
||||
return (
|
||||
<select
|
||||
className="w-full border border-gray-300 dark:border-gray-600 rounded px-2 py-1 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
value={String(val ?? '')}
|
||||
onChange={e => setValue(inp.key, e.target.value)}
|
||||
>
|
||||
<option value="">— select lora —</option>
|
||||
{(loras?.models ?? []).map(m => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
// integer, float, string
|
||||
return (
|
||||
<input
|
||||
type={inp.input_type === 'integer' || inp.input_type === 'float' ? 'number' : 'text'}
|
||||
step={inp.input_type === 'float' ? 'any' : undefined}
|
||||
className="w-full border border-gray-300 dark:border-gray-600 rounded px-2 py-1 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
value={String(val ?? '')}
|
||||
placeholder={String(inp.current_value ?? '')}
|
||||
onChange={e => {
|
||||
const raw = e.target.value
|
||||
const coerced = inp.input_type === 'integer' ? parseInt(raw) || raw
|
||||
: inp.input_type === 'float' ? parseFloat(raw) || raw
|
||||
: raw
|
||||
setValue(inp.key, coerced)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Common inputs */}
|
||||
{inputsData.common.map(inp => (
|
||||
<div key={inp.key}>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{inp.label}</label>
|
||||
{renderField(inp)}
|
||||
{imagePicker === inp.key && (
|
||||
<ImagePickerGrid
|
||||
images={inputImages ?? []}
|
||||
slotKey={inp.key}
|
||||
onPick={handleActivateImage}
|
||||
onClose={() => setImagePicker(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Advanced inputs */}
|
||||
{inputsData.advanced.length > 0 && (
|
||||
<details className="border border-gray-200 dark:border-gray-700 rounded">
|
||||
<summary className="px-3 py-2 text-sm font-medium cursor-pointer select-none text-gray-700 dark:text-gray-300">
|
||||
Advanced ({inputsData.advanced.length} inputs)
|
||||
</summary>
|
||||
<div className="px-3 pb-3 space-y-3 mt-2">
|
||||
{inputsData.advanced.map(inp => (
|
||||
<div key={inp.key}>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">{inp.label}</label>
|
||||
{renderField(inp)}
|
||||
{imagePicker === inp.key && (
|
||||
<ImagePickerGrid
|
||||
images={inputImages ?? []}
|
||||
slotKey={inp.key}
|
||||
onPick={handleActivateImage}
|
||||
onClose={() => setImagePicker(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 items-center">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
value={count}
|
||||
onChange={e => setCount(Math.max(1, Math.min(20, Number(e.target.value))))}
|
||||
className="w-16 border border-gray-300 dark:border-gray-600 rounded px-2 py-2 text-sm text-center bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
title="Number of generations to queue"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
disabled={generating}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded px-4 py-2 text-sm font-semibold transition-colors"
|
||||
>
|
||||
{generating ? '⏳ Generating…' : count > 1 ? `Generate ×${count}` : 'Generate'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ImagePickerGrid({
|
||||
images,
|
||||
slotKey,
|
||||
onPick,
|
||||
onClose,
|
||||
}: {
|
||||
images: { id: number; filename: string }[]
|
||||
slotKey: string
|
||||
onPick: (id: number, key: string) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-2 border border-gray-300 dark:border-gray-600 rounded p-2 bg-gray-50 dark:bg-gray-800">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">Select image for slot: {slotKey}</span>
|
||||
<button onClick={onClose} className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">✕</button>
|
||||
</div>
|
||||
{images.length === 0 ? (
|
||||
<p className="text-xs text-gray-400">No images available. Upload some first.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 gap-1 max-h-48 overflow-y-auto">
|
||||
{images.map(img => (
|
||||
<button
|
||||
key={img.id}
|
||||
type="button"
|
||||
onClick={() => onPick(img.id, slotKey)}
|
||||
className="relative aspect-square overflow-hidden rounded border border-gray-200 dark:border-gray-600 hover:border-blue-500"
|
||||
title={img.filename}
|
||||
>
|
||||
<LazyImage
|
||||
thumbSrc={getInputThumb(img.id)}
|
||||
midSrc={getInputMid(img.id)}
|
||||
fullSrc={getInputImage(img.id)}
|
||||
alt={img.filename}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
146
frontend/src/components/Layout.tsx
Normal file
146
frontend/src/components/Layout.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { NavLink, Outlet, useLocation } from 'react-router-dom'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
import { useStatus } from '../hooks/useStatus'
|
||||
import { useGeneration } from '../context/GenerationContext'
|
||||
|
||||
const navItems = [
|
||||
{ to: '/generate', label: 'Generate' },
|
||||
{ to: '/inputs', label: 'Input Images' },
|
||||
{ to: '/workflow', label: 'Workflow' },
|
||||
{ to: '/presets', label: 'Presets' },
|
||||
{ to: '/status', label: 'Status' },
|
||||
{ to: '/server', label: 'Server' },
|
||||
{ to: '/history', label: 'History' },
|
||||
]
|
||||
|
||||
export default function Layout() {
|
||||
const { user, logout, isAdmin } = useAuth()
|
||||
const location = useLocation()
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
const [dark, setDark] = useState(() => {
|
||||
if (typeof window === 'undefined') return false
|
||||
const stored = localStorage.getItem('dark-mode')
|
||||
if (stored !== null) return stored === 'true'
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
})
|
||||
|
||||
// Apply dark class on mount and changes
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle('dark', dark)
|
||||
localStorage.setItem('dark-mode', String(dark))
|
||||
}, [dark])
|
||||
|
||||
// Auto-close sidebar on navigation
|
||||
useEffect(() => {
|
||||
setSidebarOpen(false)
|
||||
}, [location.pathname])
|
||||
|
||||
const { pendingCount, decrementPending } = useGeneration()
|
||||
const queryClient = useQueryClient()
|
||||
const titleResetTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const onNodeExecuting = useCallback(() => {
|
||||
document.title = '⏳ Generating… | ComfyUI Bot'
|
||||
}, [])
|
||||
|
||||
const onGenerationComplete = useCallback(() => {
|
||||
decrementPending()
|
||||
queryClient.invalidateQueries({ queryKey: ['history'] })
|
||||
if (titleResetTimer.current) clearTimeout(titleResetTimer.current)
|
||||
document.title = 'Done | ComfyUI Bot'
|
||||
titleResetTimer.current = setTimeout(() => { document.title = 'ComfyUI Bot' }, 5000)
|
||||
}, [decrementPending, queryClient])
|
||||
|
||||
const onGenerationError = useCallback(() => {
|
||||
decrementPending()
|
||||
if (titleResetTimer.current) clearTimeout(titleResetTimer.current)
|
||||
document.title = 'Error | ComfyUI Bot'
|
||||
titleResetTimer.current = setTimeout(() => { document.title = 'ComfyUI Bot' }, 5000)
|
||||
}, [decrementPending])
|
||||
|
||||
useEffect(() => () => { document.title = 'ComfyUI Bot' }, [])
|
||||
|
||||
const { status } = useStatus({ enabled: !!user, onGenerationComplete, onGenerationError, onNodeExecuting })
|
||||
const comfyReachable = status.comfy?.reachable ?? null
|
||||
|
||||
const toggleDark = () => setDark(d => !d)
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
{/* Mobile backdrop */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/40 z-30 md:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`fixed md:static inset-y-0 left-0 z-40 w-48 flex-none bg-gray-800 text-gray-100 flex flex-col transition-transform duration-200 ${
|
||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'
|
||||
}`}
|
||||
>
|
||||
<div className="p-4 font-bold text-lg border-b border-gray-700 flex items-center gap-2">
|
||||
<span>ComfyUI Bot</span>
|
||||
<span
|
||||
title={comfyReachable == null ? 'Connecting…' : comfyReachable ? 'ComfyUI reachable' : 'ComfyUI unreachable'}
|
||||
className={`ml-auto w-2 h-2 rounded-full flex-none ${comfyReachable == null ? 'bg-gray-500' : comfyReachable ? 'bg-green-400' : 'bg-red-400'}`}
|
||||
/>
|
||||
</div>
|
||||
<nav className="flex-1 overflow-y-auto py-2">
|
||||
{navItems.map(({ to, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center px-4 py-2 text-sm hover:bg-gray-700 transition-colors ${isActive ? 'bg-gray-700 font-medium' : ''}`
|
||||
}
|
||||
>
|
||||
<span className="flex-1">{label}</span>
|
||||
{to === '/generate' && pendingCount > 0 && (
|
||||
<span className="ml-2 text-xs bg-yellow-400 text-gray-900 rounded-full px-1.5 py-0.5 leading-none">
|
||||
{pendingCount}
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
{isAdmin && (
|
||||
<NavLink
|
||||
to="/admin"
|
||||
className={({ isActive }) =>
|
||||
`block px-4 py-2 text-sm hover:bg-gray-700 transition-colors ${isActive ? 'bg-gray-700 font-medium' : ''}`
|
||||
}
|
||||
>
|
||||
Admin
|
||||
</NavLink>
|
||||
)}
|
||||
</nav>
|
||||
<div className="p-3 border-t border-gray-700 text-xs flex items-center gap-2">
|
||||
<span className="flex-1 truncate">{user?.label ?? '...'}</span>
|
||||
<button onClick={toggleDark} className="hover:text-yellow-300" title="Toggle dark mode">
|
||||
{dark ? '☀' : '🌙'}
|
||||
</button>
|
||||
<button onClick={logout} className="hover:text-red-400" title="Logout">
|
||||
⏏
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main */}
|
||||
<main className="flex-1 overflow-y-auto p-3 sm:p-6 bg-gray-50 dark:bg-gray-900">
|
||||
{/* Hamburger button — mobile only */}
|
||||
<button
|
||||
className="md:hidden mb-3 p-1.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
aria-label="Open menu"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
59
frontend/src/components/LazyImage.tsx
Normal file
59
frontend/src/components/LazyImage.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React, { useState } from 'react'
|
||||
|
||||
interface Props {
|
||||
thumbSrc: string
|
||||
midSrc: string
|
||||
fullSrc: string
|
||||
alt: string
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 3-stage progressive image loader:
|
||||
* stage 0 — blurred tiny thumb (loads instantly)
|
||||
* stage 1 — clear medium-compressed image (fades in when ready)
|
||||
* stage 2 — full original image (fades in when ready)
|
||||
*
|
||||
* All three requests fire in parallel; stage only advances forward so if full
|
||||
* arrives before mid we jump straight to stage 2.
|
||||
*/
|
||||
export default function LazyImage({ thumbSrc, midSrc, fullSrc, alt, className = '', onClick }: Props) {
|
||||
const [stage, setStage] = useState(0)
|
||||
const advance = (to: number) => setStage(s => Math.max(s, to))
|
||||
|
||||
return (
|
||||
<div className={`relative overflow-hidden ${className}`}>
|
||||
{/* Stage 0: blurred thumbnail */}
|
||||
<img
|
||||
src={thumbSrc}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-300 ${
|
||||
stage === 0 ? 'opacity-100 blur-sm scale-105' : 'opacity-0'
|
||||
}`}
|
||||
/>
|
||||
{/* Stage 1: clear mid-resolution */}
|
||||
<img
|
||||
src={midSrc}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-300 ${
|
||||
stage === 1 ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
onLoad={() => advance(1)}
|
||||
/>
|
||||
{/* Stage 2: full resolution */}
|
||||
<img
|
||||
src={fullSrc}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
className={`relative w-full h-full object-cover transition-opacity duration-300 ${
|
||||
stage === 2 ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
onLoad={() => advance(2)}
|
||||
onClick={onClick}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
22
frontend/src/context/GenerationContext.tsx
Normal file
22
frontend/src/context/GenerationContext.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React, { createContext, useContext, useState, useCallback } from 'react'
|
||||
|
||||
interface Value {
|
||||
pendingCount: number
|
||||
addPending: (n?: number) => void
|
||||
decrementPending: () => void
|
||||
}
|
||||
|
||||
const Ctx = createContext<Value>({
|
||||
pendingCount: 0,
|
||||
addPending: () => {},
|
||||
decrementPending: () => {},
|
||||
})
|
||||
|
||||
export function GenerationProvider({ children }: { children: React.ReactNode }) {
|
||||
const [pendingCount, setPendingCount] = useState(0)
|
||||
const addPending = useCallback((n = 1) => setPendingCount(c => c + n), [])
|
||||
const decrementPending = useCallback(() => setPendingCount(c => Math.max(0, c - 1)), [])
|
||||
return <Ctx.Provider value={{ pendingCount, addPending, decrementPending }}>{children}</Ctx.Provider>
|
||||
}
|
||||
|
||||
export const useGeneration = () => useContext(Ctx)
|
||||
26
frontend/src/hooks/useAuth.ts
Normal file
26
frontend/src/hooks/useAuth.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { authMe, authLogout } from '../api/client'
|
||||
|
||||
export function useAuth() {
|
||||
const qc = useQueryClient()
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['auth', 'me'],
|
||||
queryFn: authMe,
|
||||
retry: false,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const logout = async () => {
|
||||
await authLogout()
|
||||
qc.clear()
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
return {
|
||||
user: data ?? null,
|
||||
isLoading,
|
||||
isAuthenticated: !!data,
|
||||
isAdmin: data?.admin ?? false,
|
||||
logout,
|
||||
}
|
||||
}
|
||||
71
frontend/src/hooks/useStatus.ts
Normal file
71
frontend/src/hooks/useStatus.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useWebSocket } from './useWebSocket'
|
||||
|
||||
export interface StatusSnapshot {
|
||||
bot?: { latency_ms: number; uptime: string }
|
||||
comfy?: {
|
||||
server: string
|
||||
reachable?: boolean
|
||||
queue_pending: number
|
||||
queue_running: number
|
||||
workflow_loaded: boolean
|
||||
last_seed: number | null
|
||||
total_generated: number
|
||||
}
|
||||
overrides?: Record<string, unknown>
|
||||
service?: { state: string }
|
||||
upload?: { configured: boolean; running: boolean; total_ok: number; total_fail: number }
|
||||
}
|
||||
|
||||
export interface GenerationResult {
|
||||
prompt_id: string
|
||||
seed: number | null
|
||||
image_count: number
|
||||
video_count: number
|
||||
}
|
||||
|
||||
interface UseStatusOptions {
|
||||
enabled?: boolean
|
||||
onGenerationComplete?: (result: GenerationResult) => void
|
||||
onGenerationError?: (error: { prompt_id: string | null; error: string }) => void
|
||||
onNodeExecuting?: (node: string, promptId: string) => void
|
||||
}
|
||||
|
||||
export function useStatus({
|
||||
enabled = true,
|
||||
onGenerationComplete,
|
||||
onGenerationError,
|
||||
onNodeExecuting,
|
||||
}: UseStatusOptions) {
|
||||
const [status, setStatus] = useState<StatusSnapshot>({})
|
||||
const [executingNode, setExecutingNode] = useState<string | null>(null)
|
||||
|
||||
const handleMessage = useCallback(
|
||||
(msg: { type: string; data: unknown; ts: number }) => {
|
||||
if (msg.type === 'status_snapshot') {
|
||||
setStatus(msg.data as StatusSnapshot)
|
||||
} else if (msg.type === 'node_executing') {
|
||||
const d = msg.data as { node: string; prompt_id: string }
|
||||
setExecutingNode(d.node)
|
||||
onNodeExecuting?.(d.node, d.prompt_id)
|
||||
} else if (msg.type === 'generation_complete') {
|
||||
setExecutingNode(null)
|
||||
onGenerationComplete?.(msg.data as GenerationResult)
|
||||
} else if (msg.type === 'generation_error') {
|
||||
setExecutingNode(null)
|
||||
onGenerationError?.(msg.data as { prompt_id: string | null; error: string })
|
||||
} else if (msg.type === 'server_state') {
|
||||
const d = msg.data as { state: string; http_reachable: boolean }
|
||||
setStatus(prev => ({
|
||||
...prev,
|
||||
service: { state: d.state },
|
||||
}))
|
||||
}
|
||||
},
|
||||
[onGenerationComplete, onGenerationError, onNodeExecuting],
|
||||
)
|
||||
|
||||
useWebSocket({ onMessage: handleMessage, enabled })
|
||||
|
||||
return { status, executingNode }
|
||||
}
|
||||
55
frontend/src/hooks/useWebSocket.ts
Normal file
55
frontend/src/hooks/useWebSocket.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
|
||||
interface WSOptions {
|
||||
onMessage: (data: { type: string; data: unknown; ts: number }) => void
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
/** Reconnecting WebSocket hook with exponential backoff. Auth via ttb_session cookie. */
|
||||
export function useWebSocket({ onMessage, enabled = true }: WSOptions) {
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const backoffRef = useRef(1000)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const onMessageRef = useRef(onMessage)
|
||||
onMessageRef.current = onMessage
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (!enabled) return
|
||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const url = `${proto}://${window.location.host}/ws`
|
||||
const ws = new WebSocket(url)
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const parsed = JSON.parse(ev.data)
|
||||
if (parsed.type !== 'ping') {
|
||||
onMessageRef.current(parsed)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
ws.onopen = () => {
|
||||
backoffRef.current = 1000
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
if (enabled) {
|
||||
timerRef.current = setTimeout(() => {
|
||||
backoffRef.current = Math.min(backoffRef.current * 2, 30_000)
|
||||
connect()
|
||||
}, backoffRef.current)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = () => ws.close()
|
||||
}, [enabled])
|
||||
|
||||
useEffect(() => {
|
||||
connect()
|
||||
return () => {
|
||||
enabled && (wsRef.current?.close())
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
}
|
||||
}, [connect, enabled])
|
||||
}
|
||||
12
frontend/src/index.css
Normal file
12
frontend/src/index.css
Normal file
@@ -0,0 +1,12 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
19
frontend/src/main.tsx
Normal file
19
frontend/src/main.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: 1, staleTime: 5000 },
|
||||
},
|
||||
})
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
113
frontend/src/pages/AdminPage.tsx
Normal file
113
frontend/src/pages/AdminPage.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { adminListTokens, adminCreateToken, adminRevokeToken } from '../api/client'
|
||||
|
||||
export default function AdminPage() {
|
||||
const qc = useQueryClient()
|
||||
const [label, setLabel] = useState('')
|
||||
const [isAdmin, setIsAdmin] = useState(false)
|
||||
const [newToken, setNewToken] = useState<string | null>(null)
|
||||
const [createError, setCreateError] = useState('')
|
||||
|
||||
const { data: tokens = [], isLoading } = useQuery({
|
||||
queryKey: ['admin', 'tokens'],
|
||||
queryFn: adminListTokens,
|
||||
})
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: ({ label, admin }: { label: string; admin: boolean }) => adminCreateToken(label, admin),
|
||||
onSuccess: (res) => {
|
||||
qc.invalidateQueries({ queryKey: ['admin', 'tokens'] })
|
||||
setNewToken(res.token)
|
||||
setLabel('')
|
||||
setIsAdmin(false)
|
||||
setCreateError('')
|
||||
},
|
||||
onError: (err) => setCreateError(err instanceof Error ? err.message : String(err)),
|
||||
})
|
||||
|
||||
const revokeMut = useMutation({
|
||||
mutationFn: (id: string) => adminRevokeToken(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'tokens'] }),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="max-w-xl mx-auto space-y-6">
|
||||
<h1 className="text-xl font-bold text-gray-800 dark:text-gray-100">Admin — Token Management</h1>
|
||||
|
||||
{/* Create token */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 p-4 space-y-3">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Create invite token</p>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={e => setLabel(e.target.value)}
|
||||
placeholder="Label (e.g. alice)"
|
||||
className="flex-1 border border-gray-300 dark:border-gray-600 rounded px-3 py-2 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<label className="flex items-center gap-1 text-sm text-gray-600 dark:text-gray-400 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isAdmin}
|
||||
onChange={e => setIsAdmin(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Admin
|
||||
</label>
|
||||
<button
|
||||
onClick={() => createMut.mutate({ label, admin: isAdmin })}
|
||||
disabled={!label.trim() || createMut.isPending}
|
||||
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded px-3 py-2 text-sm font-medium"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
{createError && <p className="text-red-500 text-sm">{createError}</p>}
|
||||
</div>
|
||||
|
||||
{/* New token display — one-time */}
|
||||
{newToken && (
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-300 dark:border-yellow-700 rounded p-4 space-y-2">
|
||||
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
||||
New token (copy now — shown only once):
|
||||
</p>
|
||||
<code className="block text-xs break-all bg-yellow-100 dark:bg-yellow-900/50 rounded p-2 text-yellow-900 dark:text-yellow-100 select-all">
|
||||
{newToken}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => setNewToken(null)}
|
||||
className="text-xs text-yellow-600 dark:text-yellow-400 hover:underline"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Token list */}
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-gray-400">Loading tokens…</div>
|
||||
) : tokens.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">No tokens yet.</p>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded">
|
||||
{tokens.map(t => (
|
||||
<div key={t.id} className="flex items-center justify-between px-3 py-2 text-sm">
|
||||
<div>
|
||||
<span className="font-medium text-gray-700 dark:text-gray-300">{t.label}</span>
|
||||
{t.admin && <span className="ml-1 text-xs text-purple-600 dark:text-purple-400">(admin)</span>}
|
||||
<span className="ml-2 text-xs text-gray-400">{new Date(t.created_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => revokeMut.mutate(t.id)}
|
||||
className="text-xs text-red-500 hover:text-red-700 dark:hover:text-red-400"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
250
frontend/src/pages/GeneratePage.tsx
Normal file
250
frontend/src/pages/GeneratePage.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { generate, workflowGen, listPresets, loadPreset } from '../api/client'
|
||||
import { useStatus, GenerationResult } from '../hooks/useStatus'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
import { useGeneration } from '../context/GenerationContext'
|
||||
import DynamicWorkflowForm from '../components/DynamicWorkflowForm'
|
||||
|
||||
interface Notification {
|
||||
id: number
|
||||
type: 'success' | 'error' | 'info'
|
||||
msg: string
|
||||
}
|
||||
|
||||
export default function GeneratePage() {
|
||||
const { user } = useAuth()
|
||||
const { pendingCount, addPending } = useGeneration()
|
||||
const qc = useQueryClient()
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [notifications, setNotifications] = useState<Notification[]>([])
|
||||
const [executingNodeDisplay, setExecutingNodeDisplay] = useState<string | null>(null)
|
||||
const [lastSeed, setLastSeed] = useState<number | null>(null)
|
||||
const [mode, setMode] = useState<'workflow' | 'prompt'>('workflow')
|
||||
const [prompt, setPrompt] = useState('')
|
||||
const [negPrompt, setNegPrompt] = useState('')
|
||||
const [promptCount, setPromptCount] = useState(1)
|
||||
const [loadingPreset, setLoadingPreset] = useState(false)
|
||||
|
||||
const { data: presetsData } = useQuery({ queryKey: ['presets'], queryFn: listPresets })
|
||||
|
||||
const addNotif = useCallback((type: Notification['type'], msg: string, ttl = 8000) => {
|
||||
const id = Date.now()
|
||||
setNotifications(n => [...n, { id, type, msg }])
|
||||
setTimeout(() => setNotifications(n => n.filter(x => x.id !== id)), ttl)
|
||||
}, [])
|
||||
|
||||
const handlePresetLoad = useCallback(async (name: string) => {
|
||||
if (!name) return
|
||||
setLoadingPreset(true)
|
||||
try {
|
||||
await loadPreset(name)
|
||||
qc.invalidateQueries({ queryKey: ['state'] })
|
||||
qc.invalidateQueries({ queryKey: ['workflowInputs'] })
|
||||
addNotif('info', `Loaded preset: ${name}`, 3000)
|
||||
} catch (err: unknown) {
|
||||
addNotif('error', `Failed to load preset: ${err instanceof Error ? err.message : String(err)}`)
|
||||
} finally {
|
||||
setLoadingPreset(false)
|
||||
}
|
||||
}, [qc, addNotif])
|
||||
|
||||
const dismissNotif = useCallback((id: number) => {
|
||||
setNotifications(n => n.filter(x => x.id !== id))
|
||||
}, [])
|
||||
|
||||
const onGenerationComplete = useCallback((r: GenerationResult) => {
|
||||
setExecutingNodeDisplay(null)
|
||||
setLastSeed(r.seed)
|
||||
addNotif('success', `Done — seed: ${r.seed ?? 'unknown'} · ${r.image_count} image(s) · ${r.video_count} video(s)`)
|
||||
}, [addNotif])
|
||||
|
||||
const onGenerationError = useCallback((e: { prompt_id: string | null; error: string }) => {
|
||||
setExecutingNodeDisplay(null)
|
||||
addNotif('error', e.error)
|
||||
}, [addNotif])
|
||||
|
||||
const onNodeExecuting = useCallback((node: string) => {
|
||||
setExecutingNodeDisplay(node)
|
||||
}, [])
|
||||
|
||||
const { status } = useStatus({
|
||||
enabled: !!user,
|
||||
onGenerationComplete,
|
||||
onGenerationError,
|
||||
onNodeExecuting,
|
||||
})
|
||||
|
||||
const handleWorkflowGenerate = async (overrides: Record<string, unknown>, count: number = 1) => {
|
||||
setGenerating(true)
|
||||
document.title = '⏳ Queued… | ComfyUI Bot'
|
||||
try {
|
||||
const res = await workflowGen({ count, overrides })
|
||||
setGenerating(false)
|
||||
addPending(res.count ?? count)
|
||||
addNotif('info', `Queued ${res.count ?? count} generation(s) at position ${res.queue_position}`, 3000)
|
||||
} catch (err: unknown) {
|
||||
setGenerating(false)
|
||||
addNotif('error', err instanceof Error ? err.message : String(err))
|
||||
document.title = 'ComfyUI Bot'
|
||||
}
|
||||
}
|
||||
|
||||
const handlePromptGenerate = async () => {
|
||||
const n = Math.max(1, Math.min(20, promptCount))
|
||||
setGenerating(true)
|
||||
document.title = '⏳ Queued… | ComfyUI Bot'
|
||||
try {
|
||||
let lastPos = 0
|
||||
for (let i = 0; i < n; i++) {
|
||||
const res = await generate({ prompt, negative_prompt: negPrompt })
|
||||
lastPos = res.queue_position
|
||||
}
|
||||
setGenerating(false)
|
||||
addPending(n)
|
||||
addNotif('info', `Queued ${n} generation(s) at position ${lastPos}`, 3000)
|
||||
} catch (err: unknown) {
|
||||
setGenerating(false)
|
||||
addNotif('error', err instanceof Error ? err.message : String(err))
|
||||
document.title = 'ComfyUI Bot'
|
||||
}
|
||||
}
|
||||
|
||||
const queuePending = (status.comfy?.queue_pending ?? 0)
|
||||
const queueRunning = (status.comfy?.queue_running ?? 0)
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-gray-800 dark:text-gray-100">Generate</h1>
|
||||
<div className="text-xs text-gray-400">
|
||||
ComfyUI: {queueRunning} running, {queuePending} pending
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mode toggle */}
|
||||
<div className="flex flex-wrap gap-2 text-sm">
|
||||
<button
|
||||
onClick={() => setMode('workflow')}
|
||||
className={`px-3 py-1.5 rounded ${mode === 'workflow' ? 'bg-blue-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'}`}
|
||||
>
|
||||
Workflow mode
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('prompt')}
|
||||
className={`px-3 py-1.5 rounded ${mode === 'prompt' ? 'bg-blue-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'}`}
|
||||
>
|
||||
Prompt mode
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Quick-load preset */}
|
||||
{(presetsData?.presets ?? []).length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
defaultValue=""
|
||||
disabled={loadingPreset}
|
||||
onChange={e => {
|
||||
const v = e.target.value
|
||||
e.target.value = ''
|
||||
if (v) handlePresetLoad(v)
|
||||
}}
|
||||
className="flex-1 border border-gray-300 dark:border-gray-600 rounded px-3 py-2 text-sm bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
|
||||
>
|
||||
<option value="" disabled>Load a preset…</option>
|
||||
{(presetsData?.presets ?? []).map(p => (
|
||||
<option key={p.name} value={p.name}>
|
||||
{p.name}{p.description ? ` — ${p.description}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{loadingPreset && <span className="text-xs text-gray-400">Loading…</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress banner */}
|
||||
{(pendingCount > 0 || executingNodeDisplay) && (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-700 rounded p-3 text-sm text-blue-700 dark:text-blue-300">
|
||||
{pendingCount > 0 && `${pendingCount} generation(s) in progress`}
|
||||
{executingNodeDisplay && ` · running: ${executingNodeDisplay}`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
{mode === 'workflow' ? (
|
||||
<DynamicWorkflowForm
|
||||
onGenerate={handleWorkflowGenerate}
|
||||
lastSeed={lastSeed}
|
||||
generating={generating}
|
||||
userLabel={user?.label}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Prompt</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
className="w-full border border-gray-300 dark:border-gray-600 rounded px-3 py-2 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 resize-y focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
value={prompt}
|
||||
onChange={e => setPrompt(e.target.value)}
|
||||
placeholder="Describe what you want to generate"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Negative prompt</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
className="w-full border border-gray-300 dark:border-gray-600 rounded px-3 py-2 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 resize-y focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
value={negPrompt}
|
||||
onChange={e => setNegPrompt(e.target.value)}
|
||||
placeholder="What to avoid"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
value={promptCount}
|
||||
onChange={e => setPromptCount(Math.max(1, Math.min(20, Number(e.target.value))))}
|
||||
className="w-16 border border-gray-300 dark:border-gray-600 rounded px-2 py-2 text-sm text-center bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
title="Number of generations to queue"
|
||||
/>
|
||||
<button
|
||||
onClick={handlePromptGenerate}
|
||||
disabled={generating || !prompt.trim()}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded px-4 py-2 text-sm font-semibold transition-colors"
|
||||
>
|
||||
{generating ? '⏳ Queuing…' : promptCount > 1 ? `Generate ×${promptCount}` : 'Generate'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toast notification stack */}
|
||||
<div className="fixed bottom-4 right-4 z-50 space-y-2 max-w-sm">
|
||||
{notifications.map(n => (
|
||||
<div
|
||||
key={n.id}
|
||||
className={`flex items-start gap-2 rounded border p-3 text-sm shadow-md ${
|
||||
n.type === 'success'
|
||||
? 'bg-green-50 dark:bg-green-900/40 border-green-200 dark:border-green-700 text-green-700 dark:text-green-300'
|
||||
: n.type === 'error'
|
||||
? 'bg-red-50 dark:bg-red-900/40 border-red-200 dark:border-red-700 text-red-700 dark:text-red-300'
|
||||
: 'bg-blue-50 dark:bg-blue-900/40 border-blue-200 dark:border-blue-700 text-blue-700 dark:text-blue-300'
|
||||
}`}
|
||||
>
|
||||
<span className="flex-1">{n.msg}</span>
|
||||
<button
|
||||
onClick={() => dismissNotif(n.id)}
|
||||
className="flex-none opacity-60 hover:opacity-100 leading-none"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
382
frontend/src/pages/HistoryPage.tsx
Normal file
382
frontend/src/pages/HistoryPage.tsx
Normal file
@@ -0,0 +1,382 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { getHistory, createHistoryShare, revokeHistoryShare, savePresetFromHistory } from '../api/client'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
|
||||
interface HistoryRow {
|
||||
id: number
|
||||
prompt_id: string
|
||||
source: string
|
||||
user_label?: string
|
||||
overrides: Record<string, unknown>
|
||||
seed?: number
|
||||
file_paths?: string[]
|
||||
created_at: string
|
||||
share_token?: string | null
|
||||
}
|
||||
|
||||
/** Debounce a value by `delay` ms. */
|
||||
function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debounced, setDebounced] = useState(value)
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebounced(value), delay)
|
||||
return () => clearTimeout(t)
|
||||
}, [value, delay])
|
||||
return debounced
|
||||
}
|
||||
|
||||
export default function HistoryPage() {
|
||||
const [lightbox, setLightbox] = useState<string | null>(null)
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const [searchInput, setSearchInput] = useState('')
|
||||
const { user } = useAuth()
|
||||
|
||||
const debouncedQ = useDebounce(searchInput.trim(), 300)
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['history', debouncedQ],
|
||||
queryFn: () => getHistory(debouncedQ || undefined),
|
||||
refetchInterval: 10_000,
|
||||
})
|
||||
|
||||
const rows: HistoryRow[] = ((data?.history ?? []) as unknown as HistoryRow[])
|
||||
|
||||
const isSearching = searchInput.trim() !== debouncedQ
|
||||
|
||||
if (isLoading && !searchInput) return <div className="text-sm text-gray-400">Loading history…</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-xl font-bold text-gray-800 dark:text-gray-100">History</h1>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={searchInput}
|
||||
onChange={e => setSearchInput(e.target.value)}
|
||||
placeholder="Search by prompt, checkpoint, seed…"
|
||||
className="w-full border border-gray-300 dark:border-gray-600 rounded px-3 py-2 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
{searchInput && (
|
||||
<button
|
||||
onClick={() => setSearchInput('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-xs px-1"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isSearching && <p className="text-xs text-gray-400">Searching…</p>}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-gray-400">Loading…</div>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">
|
||||
{debouncedQ ? `No results for "${debouncedQ}".` : 'No generation history yet.'}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop table — hidden on mobile */}
|
||||
<div className="hidden sm:block overflow-x-auto">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-gray-400 border-b border-gray-200 dark:border-gray-700">
|
||||
<th className="pb-2 pr-4">Time</th>
|
||||
<th className="pb-2 pr-4">Source</th>
|
||||
<th className="pb-2 pr-4">User</th>
|
||||
<th className="pb-2 pr-4">Seed</th>
|
||||
<th className="pb-2">Files</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(row => (
|
||||
<React.Fragment key={row.prompt_id}>
|
||||
<tr
|
||||
className="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/50 cursor-pointer"
|
||||
onClick={() => setExpandedId(expandedId === row.prompt_id ? null : row.prompt_id)}
|
||||
>
|
||||
<td className="py-2 pr-4 text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||
{new Date(row.created_at).toLocaleString()}
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${row.source === 'web' ? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300' : 'bg-purple-100 dark:bg-purple-900 text-purple-700 dark:text-purple-300'}`}>
|
||||
{row.source}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-gray-600 dark:text-gray-400">{row.user_label ?? '—'}</td>
|
||||
<td className="py-2 pr-4 font-mono text-gray-700 dark:text-gray-300">{row.seed ?? '—'}</td>
|
||||
<td className="py-2 text-gray-500 dark:text-gray-400">{(row.file_paths ?? []).length} file(s)</td>
|
||||
</tr>
|
||||
{expandedId === row.prompt_id && (
|
||||
<tr className="bg-gray-50 dark:bg-gray-800/50">
|
||||
<td colSpan={5} className="px-3 py-3">
|
||||
<ExpandedRow row={row} onLightbox={setLightbox} currentUserLabel={user?.label ?? null} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile card list — hidden on sm+ */}
|
||||
<div className="sm:hidden space-y-2">
|
||||
{rows.map(row => {
|
||||
const isExpanded = expandedId === row.prompt_id
|
||||
const dt = new Date(row.created_at)
|
||||
const timeStr = dt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
const dateStr = dt.toLocaleDateString([], { month: 'short', day: 'numeric' })
|
||||
return (
|
||||
<div key={row.prompt_id} className="border border-gray-200 dark:border-gray-700 rounded bg-white dark:bg-gray-800">
|
||||
<button
|
||||
className="w-full text-left px-3 py-2 space-y-1"
|
||||
onClick={() => setExpandedId(isExpanded ? null : row.prompt_id)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">{timeStr} · {dateStr}</span>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${row.source === 'web' ? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300' : 'bg-purple-100 dark:bg-purple-900 text-purple-700 dark:text-purple-300'}`}>
|
||||
{row.source}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-gray-600 dark:text-gray-400">
|
||||
<span>User: {row.user_label ?? '—'}</span>
|
||||
<span>Seed: {row.seed ?? '—'}</span>
|
||||
<span>{(row.file_paths ?? []).length} file(s)</span>
|
||||
<span className="ml-auto text-gray-400">{isExpanded ? '▲' : '▼'}</span>
|
||||
</div>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3 border-t border-gray-100 dark:border-gray-700 pt-2">
|
||||
<ExpandedRow row={row} onLightbox={setLightbox} currentUserLabel={user?.label ?? null} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Lightbox */}
|
||||
{lightbox && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/80 flex items-center justify-center z-50"
|
||||
onClick={() => setLightbox(null)}
|
||||
>
|
||||
<button
|
||||
className="absolute top-3 right-3 text-white text-2xl leading-none hover:text-gray-300"
|
||||
onClick={() => setLightbox(null)}
|
||||
aria-label="Close"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<img src={lightbox} alt="preview" className="max-w-[90vw] max-h-[90vh] object-contain rounded" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ExpandedRow({
|
||||
row,
|
||||
onLightbox,
|
||||
currentUserLabel,
|
||||
}: {
|
||||
row: HistoryRow
|
||||
onLightbox: (url: string) => void
|
||||
currentUserLabel: string | null
|
||||
}) {
|
||||
const qc = useQueryClient()
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [showSavePreset, setShowSavePreset] = useState(false)
|
||||
const [presetName, setPresetName] = useState('')
|
||||
const [presetDesc, setPresetDesc] = useState('')
|
||||
const [presetMsg, setPresetMsg] = useState<{ ok: boolean; text: string } | null>(null)
|
||||
const presetNameRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const { data: imagesData, isLoading } = useQuery({
|
||||
queryKey: ['history', row.prompt_id, 'images'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/history/${row.prompt_id}/images`, { credentials: 'include' })
|
||||
if (!res.ok) throw new Error(`${res.status}`)
|
||||
return res.json() as Promise<{ images: Array<{ filename: string; data: string | null; mime_type: string }> }>
|
||||
},
|
||||
})
|
||||
|
||||
const shareMut = useMutation({
|
||||
mutationFn: () => createHistoryShare(row.prompt_id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['history'] }),
|
||||
})
|
||||
|
||||
const revokeMut = useMutation({
|
||||
mutationFn: () => revokeHistoryShare(row.prompt_id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['history'] }),
|
||||
})
|
||||
|
||||
const savePresetMut = useMutation({
|
||||
mutationFn: () => savePresetFromHistory(row.prompt_id, presetName.trim(), presetDesc.trim() || undefined),
|
||||
onSuccess: (res) => {
|
||||
qc.invalidateQueries({ queryKey: ['presets'] })
|
||||
setPresetMsg({ ok: true, text: `Saved as preset "${res.name}"` })
|
||||
setPresetName('')
|
||||
setPresetDesc('')
|
||||
},
|
||||
onError: (err) => {
|
||||
setPresetMsg({ ok: false, text: err instanceof Error ? err.message : String(err) })
|
||||
},
|
||||
})
|
||||
|
||||
const shareUrl = row.share_token
|
||||
? `${window.location.origin}/share/${row.share_token}`
|
||||
: null
|
||||
|
||||
const isOwner = currentUserLabel !== null && row.user_label === currentUserLabel
|
||||
|
||||
const handleCopy = () => {
|
||||
if (!shareUrl) return
|
||||
navigator.clipboard.writeText(shareUrl).then(() => {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Overrides */}
|
||||
<details>
|
||||
<summary className="text-xs text-gray-400 cursor-pointer">Overrides</summary>
|
||||
<pre className="text-xs bg-gray-100 dark:bg-gray-700 rounded p-2 mt-1 overflow-auto max-h-32 text-gray-600 dark:text-gray-400">
|
||||
{JSON.stringify(row.overrides, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
{/* Images */}
|
||||
{isLoading ? (
|
||||
<p className="text-xs text-gray-400">Loading images…</p>
|
||||
) : (imagesData?.images ?? []).length > 0 ? (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{(imagesData?.images ?? []).map((img, i) => {
|
||||
const isVideo = img.mime_type.startsWith('video/')
|
||||
if (isVideo) {
|
||||
const videoSrc = `/api/history/${row.prompt_id}/file/${encodeURIComponent(img.filename)}`
|
||||
return (
|
||||
<video
|
||||
key={i}
|
||||
src={videoSrc}
|
||||
controls
|
||||
className="rounded max-h-40"
|
||||
/>
|
||||
)
|
||||
}
|
||||
const src = `data:${img.mime_type};base64,${img.data}`
|
||||
return (
|
||||
<img
|
||||
key={i}
|
||||
src={src}
|
||||
alt={img.filename}
|
||||
className="rounded max-h-40 cursor-pointer border border-gray-200 dark:border-gray-700"
|
||||
onClick={() => onLightbox(src)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-400">Files not available (may have been moved or deleted).</p>
|
||||
)}
|
||||
|
||||
{/* Owner-only actions */}
|
||||
{isOwner && (
|
||||
<div className="pt-1 border-t border-gray-100 dark:border-gray-700 space-y-2">
|
||||
{/* Share section */}
|
||||
{shareUrl ? (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Share link</p>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<code className="text-xs bg-gray-100 dark:bg-gray-700 rounded px-2 py-1 text-gray-700 dark:text-gray-300 break-all select-all flex-1 min-w-0">
|
||||
{shareUrl}
|
||||
</code>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="shrink-0 text-xs px-2 py-1 rounded bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 hover:bg-blue-200 dark:hover:bg-blue-800 transition-colors"
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => revokeMut.mutate()}
|
||||
disabled={revokeMut.isPending}
|
||||
className="shrink-0 text-xs px-2 py-1 rounded bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300 hover:bg-red-200 dark:hover:bg-red-800 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{revokeMut.isPending ? 'Revoking…' : 'Revoke'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => shareMut.mutate()}
|
||||
disabled={shareMut.isPending}
|
||||
className="text-xs px-2 py-1 rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{shareMut.isPending ? 'Creating link…' : 'Share'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Save-as-preset section */}
|
||||
{!showSavePreset ? (
|
||||
<button
|
||||
onClick={() => { setShowSavePreset(true); setPresetMsg(null); setTimeout(() => presetNameRef.current?.focus(), 50) }}
|
||||
className="text-xs px-2 py-1 rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Save as preset
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Save overrides as preset</p>
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||
Note: workflow template is not saved — load it separately before using this preset.
|
||||
</p>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<input
|
||||
ref={presetNameRef}
|
||||
type="text"
|
||||
value={presetName}
|
||||
onChange={e => setPresetName(e.target.value)}
|
||||
placeholder="Preset name"
|
||||
className="flex-1 min-w-0 border border-gray-300 dark:border-gray-600 rounded px-2 py-1 text-xs bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={presetDesc}
|
||||
onChange={e => setPresetDesc(e.target.value)}
|
||||
placeholder="Description (optional)"
|
||||
className="flex-1 min-w-0 border border-gray-300 dark:border-gray-600 rounded px-2 py-1 text-xs bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => savePresetMut.mutate()}
|
||||
disabled={!presetName.trim() || savePresetMut.isPending}
|
||||
className="text-xs px-2 py-1 rounded bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white transition-colors"
|
||||
>
|
||||
{savePresetMut.isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowSavePreset(false); setPresetMsg(null); setPresetName(''); setPresetDesc('') }}
|
||||
className="text-xs px-2 py-1 rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{presetMsg && (
|
||||
<p className={`text-xs ${presetMsg.ok ? 'text-green-600 dark:text-green-400' : 'text-red-500'}`}>
|
||||
{presetMsg.text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
216
frontend/src/pages/InputImagesPage.tsx
Normal file
216
frontend/src/pages/InputImagesPage.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import React, { useRef, useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
listInputs,
|
||||
uploadInput,
|
||||
activateInput,
|
||||
deleteInput,
|
||||
getInputImage,
|
||||
getInputThumb,
|
||||
getInputMid,
|
||||
getWorkflowInputs,
|
||||
getState,
|
||||
InputImage,
|
||||
} from '../api/client'
|
||||
import LazyImage from '../components/LazyImage'
|
||||
|
||||
export default function InputImagesPage() {
|
||||
const qc = useQueryClient()
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadSlot, setUploadSlot] = useState<string | null>(null)
|
||||
const [lightbox, setLightbox] = useState<string | null>(null)
|
||||
|
||||
const { data: images = [], isLoading } = useQuery({ queryKey: ['inputs'], queryFn: listInputs })
|
||||
const { data: inputsData } = useQuery({ queryKey: ['workflow', 'inputs'], queryFn: getWorkflowInputs })
|
||||
const { data: stateData } = useQuery({ queryKey: ['state'], queryFn: getState })
|
||||
|
||||
const imageSlots = inputsData
|
||||
? [...inputsData.common, ...inputsData.advanced].filter(i => i.input_type === 'image')
|
||||
: []
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (id: number) => deleteInput(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['inputs'] }),
|
||||
})
|
||||
|
||||
const activateMut = useMutation({
|
||||
mutationFn: ({ id, slotKey }: { id: number; slotKey: string }) => activateInput(id, slotKey),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['inputs'] })
|
||||
qc.invalidateQueries({ queryKey: ['state'] })
|
||||
},
|
||||
})
|
||||
|
||||
const handleUpload = async (slotKey: string) => {
|
||||
if (!fileRef.current?.files?.length) return
|
||||
setUploading(true)
|
||||
setUploadSlot(slotKey)
|
||||
try {
|
||||
await uploadInput(fileRef.current.files[0], slotKey)
|
||||
qc.invalidateQueries({ queryKey: ['inputs'] })
|
||||
qc.invalidateQueries({ queryKey: ['state'] })
|
||||
if (fileRef.current) fileRef.current.value = ''
|
||||
} finally {
|
||||
setUploading(false)
|
||||
setUploadSlot(null)
|
||||
}
|
||||
}
|
||||
|
||||
const activeForSlot = (slotKey: string): string => {
|
||||
const st = stateData as Record<string, unknown> | undefined
|
||||
return String(st?.[slotKey] ?? '')
|
||||
}
|
||||
|
||||
if (isLoading) return <div className="text-sm text-gray-400">Loading images…</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-xl font-bold text-gray-800 dark:text-gray-100">Input Images</h1>
|
||||
|
||||
{/* Per-slot sections */}
|
||||
{imageSlots.length > 0 ? (
|
||||
imageSlots.map(slot => {
|
||||
const activeFilename = activeForSlot(slot.key)
|
||||
return (
|
||||
<section key={slot.key} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
{slot.label}
|
||||
{activeFilename && (
|
||||
<span className="ml-2 text-xs font-normal text-blue-500">(active: {activeFilename})</span>
|
||||
)}
|
||||
</h2>
|
||||
<label className="cursor-pointer text-xs bg-blue-600 hover:bg-blue-700 text-white rounded px-2 py-1">
|
||||
{uploading && uploadSlot === slot.key ? 'Uploading…' : 'Upload'}
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={() => handleUpload(slot.key)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<ImageGrid
|
||||
images={images}
|
||||
activeFilename={activeFilename}
|
||||
slotKey={slot.key}
|
||||
onActivate={(id) => activateMut.mutate({ id, slotKey: slot.key })}
|
||||
onDelete={(id) => deleteMut.mutate(id)}
|
||||
onLightbox={setLightbox}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
/* No workflow loaded — show flat list */
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-gray-700 dark:text-gray-300">All images</h2>
|
||||
<label className="cursor-pointer text-xs bg-blue-600 hover:bg-blue-700 text-white rounded px-2 py-1">
|
||||
{uploading ? 'Uploading…' : 'Upload'}
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={() => handleUpload('input_image')}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<ImageGrid
|
||||
images={images}
|
||||
activeFilename=""
|
||||
slotKey="input_image"
|
||||
onActivate={(id) => activateMut.mutate({ id, slotKey: 'input_image' })}
|
||||
onDelete={(id) => deleteMut.mutate(id)}
|
||||
onLightbox={setLightbox}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{images.length === 0 && (
|
||||
<p className="text-sm text-gray-400">No images yet. Upload one to get started.</p>
|
||||
)}
|
||||
|
||||
{/* Lightbox */}
|
||||
{lightbox && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/80 flex items-center justify-center z-50"
|
||||
onClick={() => setLightbox(null)}
|
||||
>
|
||||
<button
|
||||
className="absolute top-3 right-3 text-white text-2xl leading-none hover:text-gray-300"
|
||||
onClick={() => setLightbox(null)}
|
||||
aria-label="Close"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<img src={lightbox} alt="preview" className="max-w-[90vw] max-h-[90vh] object-contain rounded" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ImageGrid({
|
||||
images,
|
||||
activeFilename,
|
||||
slotKey,
|
||||
onActivate,
|
||||
onDelete,
|
||||
onLightbox,
|
||||
}: {
|
||||
images: InputImage[]
|
||||
activeFilename: string
|
||||
slotKey: string
|
||||
onActivate: (id: number) => void
|
||||
onDelete: (id: number) => void
|
||||
onLightbox: (url: string) => void
|
||||
}) {
|
||||
if (images.length === 0) return <p className="text-xs text-gray-400">No images.</p>
|
||||
return (
|
||||
<div className="grid grid-cols-3 sm:grid-cols-5 lg:grid-cols-8 gap-2">
|
||||
{images.map(img => {
|
||||
const isActive = img.filename === activeFilename
|
||||
return (
|
||||
<div
|
||||
key={img.id}
|
||||
className={`relative group aspect-square rounded border-2 overflow-hidden cursor-pointer ${
|
||||
isActive ? 'border-blue-500' : 'border-transparent'
|
||||
}`}
|
||||
>
|
||||
<LazyImage
|
||||
thumbSrc={getInputThumb(img.id)}
|
||||
midSrc={getInputMid(img.id)}
|
||||
fullSrc={getInputImage(img.id)}
|
||||
alt={img.filename}
|
||||
className="w-full h-full"
|
||||
onClick={() => onLightbox(getInputImage(img.id))}
|
||||
/>
|
||||
{isActive && (
|
||||
<div className="absolute top-0.5 left-0.5 bg-blue-500 text-white text-[9px] px-1 rounded">active</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 [@media(hover:none)]:opacity-100 transition-opacity flex flex-col items-center justify-center gap-1">
|
||||
{!isActive && (
|
||||
<button
|
||||
onClick={() => onActivate(img.id)}
|
||||
className="text-[10px] bg-blue-600 text-white rounded px-1.5 py-0.5 hover:bg-blue-700"
|
||||
>
|
||||
Activate
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onDelete(img.id)}
|
||||
className="text-[10px] bg-red-600 text-white rounded px-1.5 py-0.5 hover:bg-red-700"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
76
frontend/src/pages/LoginPage.tsx
Normal file
76
frontend/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { authLogin, adminLogin } from '../api/client'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
const [token, setToken] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [isAdmin, setIsAdmin] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
if (isAdmin) {
|
||||
await adminLogin(token)
|
||||
} else {
|
||||
await authLogin(token)
|
||||
}
|
||||
await qc.invalidateQueries({ queryKey: ['auth', 'me'] })
|
||||
navigate('/generate')
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
if (msg.includes('429')) {
|
||||
setError('Too many failed attempts. Please wait 1 hour before trying again.')
|
||||
} else if (msg.includes('401')) {
|
||||
setError('Invalid token or password.')
|
||||
} else {
|
||||
setError(msg)
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900">
|
||||
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-8 w-full max-w-sm">
|
||||
<h1 className="text-xl font-bold mb-6 text-gray-800 dark:text-gray-100">ComfyUI Bot</h1>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{isAdmin ? 'Admin password' : 'Invite token'}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={e => setToken(e.target.value)}
|
||||
className="w-full border border-gray-300 dark:border-gray-600 rounded px-3 py-2 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder={isAdmin ? 'Password' : 'Paste your invite token'}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-red-500 text-sm">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded px-4 py-2 text-sm font-medium transition-colors"
|
||||
>
|
||||
{loading ? 'Logging in…' : 'Log in'}
|
||||
</button>
|
||||
</form>
|
||||
<button
|
||||
onClick={() => { setIsAdmin(a => !a); setError('') }}
|
||||
className="mt-4 text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 w-full text-center"
|
||||
>
|
||||
{isAdmin ? 'Use invite token instead' : 'Admin login'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
196
frontend/src/pages/PresetsPage.tsx
Normal file
196
frontend/src/pages/PresetsPage.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { listPresets, savePreset, getPreset, loadPreset, deletePreset, PresetMeta } from '../api/client'
|
||||
|
||||
export default function PresetsPage() {
|
||||
const qc = useQueryClient()
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newDescription, setNewDescription] = useState('')
|
||||
const [savingError, setSavingError] = useState('')
|
||||
const [expanded, setExpanded] = useState<string | null>(null)
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
|
||||
const { data, isLoading } = useQuery({ queryKey: ['presets'], queryFn: listPresets })
|
||||
|
||||
const { data: presetDetail } = useQuery({
|
||||
queryKey: ['preset', expanded],
|
||||
queryFn: () => getPreset(expanded!),
|
||||
enabled: !!expanded,
|
||||
})
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: ({ name, description }: { name: string; description: string }) =>
|
||||
savePreset(name, description || undefined),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['presets'] })
|
||||
setNewName('')
|
||||
setNewDescription('')
|
||||
setSavingError('')
|
||||
setMessage('Preset saved.')
|
||||
},
|
||||
onError: (err) => setSavingError(err instanceof Error ? err.message : String(err)),
|
||||
})
|
||||
|
||||
const loadMut = useMutation({
|
||||
mutationFn: (name: string) => loadPreset(name),
|
||||
onSuccess: (_, name) => {
|
||||
qc.invalidateQueries({ queryKey: ['state'] })
|
||||
qc.invalidateQueries({ queryKey: ['workflowInputs'] })
|
||||
setMessage(`Loaded preset: ${name}`)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (name: string) => deletePreset(name),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['presets'] })
|
||||
setMessage('Preset deleted.')
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="max-w-xl mx-auto space-y-6">
|
||||
<h1 className="text-xl font-bold text-gray-800 dark:text-gray-100">Presets</h1>
|
||||
|
||||
{message && (
|
||||
<div className="text-sm text-blue-600 dark:text-blue-400">{message}</div>
|
||||
)}
|
||||
|
||||
{/* Save current state */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
placeholder="Preset name"
|
||||
className="flex-1 border border-gray-300 dark:border-gray-600 rounded px-3 py-2 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
onClick={() => { setSavingError(''); saveMut.mutate({ name: newName, description: newDescription }) }}
|
||||
disabled={!newName.trim() || saveMut.isPending}
|
||||
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded px-3 py-2 text-sm font-medium"
|
||||
>
|
||||
Save current state
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={newDescription}
|
||||
onChange={e => setNewDescription(e.target.value)}
|
||||
placeholder="Description (optional)"
|
||||
className="w-full border border-gray-300 dark:border-gray-600 rounded px-3 py-2 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
{savingError && <p className="text-red-500 text-sm">{savingError}</p>}
|
||||
|
||||
{/* Preset list */}
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-gray-400">Loading presets…</div>
|
||||
) : (data?.presets ?? []).length === 0 ? (
|
||||
<p className="text-sm text-gray-400">No presets saved yet.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded">
|
||||
{(data?.presets ?? []).map((preset: PresetMeta) => (
|
||||
<li key={preset.name} className="px-3 py-2 space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setExpanded(expanded === preset.name ? null : preset.name)}
|
||||
className="text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 text-left"
|
||||
>
|
||||
{preset.name}
|
||||
{preset.owner && (
|
||||
<span className="ml-2 text-xs text-gray-400 dark:text-gray-500 font-normal">
|
||||
{preset.owner}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { setMessage(null); loadMut.mutate(preset.name) }}
|
||||
disabled={loadMut.isPending}
|
||||
className="text-xs bg-green-600 hover:bg-green-700 disabled:opacity-50 text-white rounded px-2 py-1"
|
||||
>
|
||||
Load
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setMessage(null); deleteMut.mutate(preset.name) }}
|
||||
className="text-xs bg-red-600 hover:bg-red-700 text-white rounded px-2 py-1"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{preset.description && (
|
||||
<p className="text-xs italic text-gray-400 dark:text-gray-500">{preset.description}</p>
|
||||
)}
|
||||
{expanded === preset.name && presetDetail && (
|
||||
<PresetDetail data={presetDetail} />
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PresetDetail({ data }: { data: Record<string, unknown> }) {
|
||||
const state = (data.state ?? {}) as Record<string, unknown>
|
||||
const { prompt, negative_prompt, seed, ...otherOverrides } = state
|
||||
const hasOther = Object.keys(otherOverrides).length > 0
|
||||
|
||||
return (
|
||||
<div className="mt-2 space-y-2 text-xs">
|
||||
{prompt != null && (
|
||||
<div>
|
||||
<span className="font-semibold text-gray-600 dark:text-gray-400">Prompt</span>
|
||||
<p className="mt-0.5 bg-gray-50 dark:bg-gray-800 rounded p-2 text-gray-700 dark:text-gray-300 whitespace-pre-wrap break-words">
|
||||
{String(prompt)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{negative_prompt != null && (
|
||||
<div>
|
||||
<span className="font-semibold text-gray-600 dark:text-gray-400">Negative prompt</span>
|
||||
<p className="mt-0.5 bg-gray-50 dark:bg-gray-800 rounded p-2 text-gray-700 dark:text-gray-300 whitespace-pre-wrap break-words">
|
||||
{String(negative_prompt)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{seed != null && (
|
||||
<div className="flex gap-2 items-baseline">
|
||||
<span className="font-semibold text-gray-600 dark:text-gray-400">Seed</span>
|
||||
<span className="font-mono text-gray-700 dark:text-gray-300">
|
||||
{String(seed)}
|
||||
{seed === -1 && <span className="ml-1 text-gray-400">(random)</span>}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{hasOther && (
|
||||
<div>
|
||||
<span className="font-semibold text-gray-600 dark:text-gray-400">Other overrides</span>
|
||||
<table className="mt-0.5 w-full text-xs border-collapse">
|
||||
<tbody>
|
||||
{Object.entries(otherOverrides).map(([k, v]) => (
|
||||
<tr key={k} className="border-b border-gray-100 dark:border-gray-700">
|
||||
<td className="py-0.5 pr-3 font-mono text-gray-500 dark:text-gray-400 whitespace-nowrap">{k}</td>
|
||||
<td className="py-0.5 text-gray-700 dark:text-gray-300 break-all">{JSON.stringify(v)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{!!data.workflow && (
|
||||
<div className="text-green-600 dark:text-green-400">Includes workflow template</div>
|
||||
)}
|
||||
<details>
|
||||
<summary className="cursor-pointer text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">Raw JSON</summary>
|
||||
<pre className="mt-1 text-xs bg-gray-100 dark:bg-gray-800 rounded p-2 overflow-auto max-h-48 text-gray-600 dark:text-gray-400">
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
100
frontend/src/pages/ServerPage.tsx
Normal file
100
frontend/src/pages/ServerPage.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { useQuery, useMutation } from '@tanstack/react-query'
|
||||
import { getServerStatus, serverAction, tailLogs } from '../api/client'
|
||||
|
||||
const ACTIONS = ['start', 'stop', 'restart'] as const
|
||||
|
||||
export default function ServerPage() {
|
||||
const [actionMsg, setActionMsg] = useState<string | null>(null)
|
||||
const logRef = useRef<HTMLPreElement>(null)
|
||||
|
||||
const { data: srv, refetch: refetchStatus } = useQuery({
|
||||
queryKey: ['server', 'status'],
|
||||
queryFn: getServerStatus,
|
||||
refetchInterval: 5000,
|
||||
})
|
||||
|
||||
const { data: logsData, refetch: refetchLogs } = useQuery({
|
||||
queryKey: ['logs'],
|
||||
queryFn: () => tailLogs(200),
|
||||
refetchInterval: 2000,
|
||||
})
|
||||
|
||||
// Auto-scroll log to bottom
|
||||
useEffect(() => {
|
||||
if (logRef.current) {
|
||||
logRef.current.scrollTop = logRef.current.scrollHeight
|
||||
}
|
||||
}, [logsData])
|
||||
|
||||
const actionMut = useMutation({
|
||||
mutationFn: (action: string) => serverAction(action),
|
||||
onSuccess: (_, action) => {
|
||||
setActionMsg(`${action} sent.`)
|
||||
setTimeout(() => { setActionMsg(null); refetchStatus() }, 2000)
|
||||
},
|
||||
onError: (err) => setActionMsg(`Error: ${err instanceof Error ? err.message : String(err)}`),
|
||||
})
|
||||
|
||||
const stateColor = srv?.service_state === 'SERVICE_RUNNING'
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: srv?.service_state === 'SERVICE_STOPPED'
|
||||
? 'text-red-500 dark:text-red-400'
|
||||
: 'text-yellow-500'
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<h1 className="text-xl font-bold text-gray-800 dark:text-gray-100">Server</h1>
|
||||
|
||||
{/* Status */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 p-4 text-sm space-y-1">
|
||||
<p>
|
||||
State: <span className={`font-medium ${stateColor}`}>{srv?.service_state ?? '—'}</span>
|
||||
</p>
|
||||
<p>
|
||||
HTTP: <span className={srv?.http_reachable ? 'text-green-600 dark:text-green-400' : 'text-red-500'}>
|
||||
{srv == null ? '—' : srv.http_reachable ? '✅ reachable' : '❌ unreachable'}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex gap-2">
|
||||
{ACTIONS.map(a => (
|
||||
<button
|
||||
key={a}
|
||||
onClick={() => { setActionMsg(null); actionMut.mutate(a) }}
|
||||
disabled={actionMut.isPending}
|
||||
className={`text-sm rounded px-3 py-2 font-medium disabled:opacity-50 transition-colors ${
|
||||
a === 'stop' ? 'bg-red-600 hover:bg-red-700 text-white'
|
||||
: a === 'restart' ? 'bg-yellow-500 hover:bg-yellow-600 text-white'
|
||||
: 'bg-green-600 hover:bg-green-700 text-white'
|
||||
}`}
|
||||
>
|
||||
{a.charAt(0).toUpperCase() + a.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{actionMsg && <p className="text-sm text-blue-600 dark:text-blue-400">{actionMsg}</p>}
|
||||
|
||||
{/* Log tail */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Log tail (last 200 lines)</p>
|
||||
<button
|
||||
onClick={() => refetchLogs()}
|
||||
className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<pre
|
||||
ref={logRef}
|
||||
className="bg-gray-900 text-gray-100 text-xs rounded p-3 h-72 overflow-y-auto whitespace-pre-wrap font-mono"
|
||||
>
|
||||
{(logsData?.lines ?? []).join('\n') || 'No log lines available.'}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
119
frontend/src/pages/SharePage.tsx
Normal file
119
frontend/src/pages/SharePage.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import React from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getShareFileUrl } from '../api/client'
|
||||
|
||||
interface ShareData {
|
||||
prompt_id: string
|
||||
created_at: string
|
||||
overrides: Record<string, unknown>
|
||||
seed?: number
|
||||
images: Array<{ filename: string; data: string | null; mime_type: string }>
|
||||
}
|
||||
|
||||
export default function SharePage() {
|
||||
const { token } = useParams<{ token: string }>()
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['share', token],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/share/${token}`, { credentials: 'include' })
|
||||
if (!res.ok) {
|
||||
const msg = await res.text().catch(() => res.statusText)
|
||||
throw Object.assign(new Error(msg), { status: res.status })
|
||||
}
|
||||
return res.json() as Promise<ShareData>
|
||||
},
|
||||
retry: false,
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900">
|
||||
<p className="text-sm text-gray-400">Loading…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const status = (error as any)?.status
|
||||
|
||||
if (status === 401) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900">
|
||||
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-8 w-full max-w-sm text-center space-y-4">
|
||||
<p className="text-gray-700 dark:text-gray-300">You need to be logged in to view this shared link.</p>
|
||||
<Link
|
||||
to="/login"
|
||||
className="inline-block bg-blue-600 hover:bg-blue-700 text-white rounded px-4 py-2 text-sm font-medium transition-colors"
|
||||
>
|
||||
Log in
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900">
|
||||
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-8 w-full max-w-sm text-center">
|
||||
<p className="text-gray-700 dark:text-gray-300">This share link has been revoked or does not exist.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 py-8 px-4">
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6 space-y-4">
|
||||
<h1 className="text-xl font-bold text-gray-800 dark:text-gray-100">Shared Generation</h1>
|
||||
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 space-y-1">
|
||||
<p>Generated: {data && new Date(data.created_at).toLocaleString()}</p>
|
||||
{data?.seed != null && (
|
||||
<p>Seed: <span className="font-mono">{data.seed}</span></p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data && Object.keys(data.overrides).length > 0 && (
|
||||
<details>
|
||||
<summary className="text-xs text-gray-400 cursor-pointer">Overrides</summary>
|
||||
<pre className="text-xs bg-gray-100 dark:bg-gray-700 rounded p-2 mt-1 overflow-auto max-h-40 text-gray-600 dark:text-gray-400">
|
||||
{JSON.stringify(data.overrides, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{/* Images / Videos */}
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
{(data?.images ?? []).map((img, i) => {
|
||||
if (img.mime_type.startsWith('video/')) {
|
||||
return (
|
||||
<video
|
||||
key={i}
|
||||
src={getShareFileUrl(token!, img.filename)}
|
||||
controls
|
||||
className="rounded max-h-80 max-w-full"
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<img
|
||||
key={i}
|
||||
src={`data:${img.mime_type};base64,${img.data}`}
|
||||
alt={img.filename}
|
||||
className="rounded max-h-80 max-w-full object-contain border border-gray-200 dark:border-gray-700"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-gray-400">
|
||||
<Link to="/login" className="hover:underline">ComfyUI Bot</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
77
frontend/src/pages/StatusPage.tsx
Normal file
77
frontend/src/pages/StatusPage.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React from 'react'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
import { useStatus } from '../hooks/useStatus'
|
||||
|
||||
export default function StatusPage() {
|
||||
const { user } = useAuth()
|
||||
const { status, executingNode } = useStatus({ enabled: !!user })
|
||||
|
||||
const { bot, comfy, service, upload } = status
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<h1 className="text-xl font-bold text-gray-800 dark:text-gray-100">Status</h1>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
|
||||
{/* Bot */}
|
||||
<Card title="Bot">
|
||||
<Row label="Latency" value={bot ? `${bot.latency_ms} ms` : '—'} />
|
||||
<Row label="Uptime" value={bot?.uptime ?? '—'} />
|
||||
</Card>
|
||||
|
||||
{/* ComfyUI */}
|
||||
<Card title="ComfyUI">
|
||||
<Row label="Server" value={comfy?.server ?? '—'} />
|
||||
<Row
|
||||
label="Reachable"
|
||||
value={comfy?.reachable == null ? '—' : comfy.reachable ? '✅ yes' : '❌ no'}
|
||||
/>
|
||||
<Row label="Queue running" value={String(comfy?.queue_running ?? 0)} />
|
||||
<Row label="Queue pending" value={String(comfy?.queue_pending ?? 0)} />
|
||||
<Row label="Workflow loaded" value={comfy?.workflow_loaded ? '✓' : '✗'} />
|
||||
<Row label="Last seed" value={comfy?.last_seed != null ? String(comfy.last_seed) : '—'} />
|
||||
<Row label="Total generated" value={String(comfy?.total_generated ?? 0)} />
|
||||
</Card>
|
||||
|
||||
{/* Service */}
|
||||
<Card title="Service">
|
||||
<Row label="State" value={service?.state ?? '—'} />
|
||||
</Card>
|
||||
|
||||
{/* Auto-upload */}
|
||||
<Card title="Auto-upload">
|
||||
<Row label="Configured" value={upload?.configured ? '✓' : '✗'} />
|
||||
<Row label="Running" value={upload?.running ? '⏳ yes' : 'idle'} />
|
||||
<Row label="Total ok" value={String(upload?.total_ok ?? 0)} />
|
||||
<Row label="Total fail" value={String(upload?.total_fail ?? 0)} />
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Executing node */}
|
||||
{executingNode && (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-700 rounded p-3 text-sm text-blue-700 dark:text-blue-300">
|
||||
Executing node: <strong>{executingNode}</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Card({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500 mb-2">{title}</p>
|
||||
<dl className="space-y-1">{children}</dl>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex justify-between text-sm">
|
||||
<dt className="text-gray-500 dark:text-gray-400">{label}</dt>
|
||||
<dd className="text-gray-800 dark:text-gray-200 font-mono text-right">{value}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
151
frontend/src/pages/WorkflowPage.tsx
Normal file
151
frontend/src/pages/WorkflowPage.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import React, { useRef, useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
getWorkflow,
|
||||
getWorkflowInputs,
|
||||
listWorkflowFiles,
|
||||
uploadWorkflow,
|
||||
loadWorkflow,
|
||||
} from '../api/client'
|
||||
|
||||
export default function WorkflowPage() {
|
||||
const qc = useQueryClient()
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [loadingFile, setLoadingFile] = useState<string | null>(null)
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
|
||||
const { data: wf } = useQuery({ queryKey: ['workflow'], queryFn: getWorkflow })
|
||||
const { data: inputs } = useQuery({
|
||||
queryKey: ['workflow', 'inputs'],
|
||||
queryFn: getWorkflowInputs,
|
||||
enabled: wf?.loaded ?? false,
|
||||
})
|
||||
const { data: filesData, refetch: refetchFiles } = useQuery({
|
||||
queryKey: ['workflow', 'files'],
|
||||
queryFn: listWorkflowFiles,
|
||||
})
|
||||
|
||||
const loadMut = useMutation({
|
||||
mutationFn: (filename: string) => loadWorkflow(filename),
|
||||
onSuccess: (_, filename) => {
|
||||
setMessage(`Loaded: ${filename}`)
|
||||
qc.invalidateQueries({ queryKey: ['workflow'] })
|
||||
qc.invalidateQueries({ queryKey: ['state'] })
|
||||
setLoadingFile(null)
|
||||
},
|
||||
onError: (err) => {
|
||||
setMessage(`Error: ${err instanceof Error ? err.message : String(err)}`)
|
||||
setLoadingFile(null)
|
||||
},
|
||||
})
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!fileRef.current?.files?.length) return
|
||||
setUploading(true)
|
||||
setMessage(null)
|
||||
try {
|
||||
const res = await uploadWorkflow(fileRef.current.files[0])
|
||||
setMessage(`Uploaded: ${res.filename ?? 'ok'}`)
|
||||
refetchFiles()
|
||||
if (fileRef.current) fileRef.current.value = ''
|
||||
} catch (err) {
|
||||
setMessage(`Upload error: ${err instanceof Error ? err.message : String(err)}`)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const allInputs = [...(inputs?.common ?? []), ...(inputs?.advanced ?? [])]
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<h1 className="text-xl font-bold text-gray-800 dark:text-gray-100">Workflow</h1>
|
||||
|
||||
{/* Current workflow */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 p-4 text-sm space-y-1">
|
||||
<p className="font-medium text-gray-700 dark:text-gray-300">Current workflow</p>
|
||||
{wf?.loaded ? (
|
||||
<>
|
||||
<p className="text-gray-500 dark:text-gray-400">{wf.last_workflow_file ?? '(loaded from state)'}</p>
|
||||
<p className="text-gray-500 dark:text-gray-400">{wf.node_count} node(s) detected</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-gray-400">No workflow loaded</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className="text-sm text-blue-600 dark:text-blue-400">{message}</div>
|
||||
)}
|
||||
|
||||
{/* Upload */}
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="cursor-pointer text-sm bg-blue-600 hover:bg-blue-700 text-white rounded px-3 py-2">
|
||||
{uploading ? 'Uploading…' : 'Upload workflow JSON'}
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
className="hidden"
|
||||
onChange={handleUpload}
|
||||
/>
|
||||
</label>
|
||||
<span className="text-xs text-gray-400">Uploads to workflows/ folder</span>
|
||||
</div>
|
||||
|
||||
{/* Available files */}
|
||||
{(filesData?.files ?? []).length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Available workflows</p>
|
||||
<ul className="divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded">
|
||||
{(filesData?.files ?? []).map(f => (
|
||||
<li key={f} className="flex items-center justify-between px-3 py-2 text-sm">
|
||||
<span className={`text-gray-700 dark:text-gray-300 ${wf?.last_workflow_file === f ? 'font-semibold text-blue-600 dark:text-blue-400' : ''}`}>
|
||||
{f}
|
||||
{wf?.last_workflow_file === f && <span className="ml-1 text-xs">(active)</span>}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => { setLoadingFile(f); setMessage(null); loadMut.mutate(f) }}
|
||||
disabled={loadingFile === f}
|
||||
className="text-xs bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded px-2 py-1 disabled:opacity-50"
|
||||
>
|
||||
{loadingFile === f ? 'Loading…' : 'Load'}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Discovered inputs summary */}
|
||||
{inputs && allInputs.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Discovered inputs ({allInputs.length})</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs border-collapse border border-gray-200 dark:border-gray-700 rounded">
|
||||
<thead>
|
||||
<tr className="bg-gray-100 dark:bg-gray-700">
|
||||
<th className="text-left p-2 border-b border-gray-200 dark:border-gray-600">Key</th>
|
||||
<th className="text-left p-2 border-b border-gray-200 dark:border-gray-600">Label</th>
|
||||
<th className="text-left p-2 border-b border-gray-200 dark:border-gray-600">Type</th>
|
||||
<th className="text-left p-2 border-b border-gray-200 dark:border-gray-600">Common</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{allInputs.map(inp => (
|
||||
<tr key={inp.key} className="border-b border-gray-100 dark:border-gray-700 last:border-0">
|
||||
<td className="p-2 font-mono text-gray-600 dark:text-gray-400">{inp.key}</td>
|
||||
<td className="p-2 text-gray-700 dark:text-gray-300">{inp.label}</td>
|
||||
<td className="p-2 text-gray-500 dark:text-gray-400">{inp.input_type}</td>
|
||||
<td className="p-2 text-gray-500 dark:text-gray-400">{inp.is_common ? '✓' : ''}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
9
frontend/tailwind.config.js
Normal file
9
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
24
frontend/tsconfig.json
Normal file
24
frontend/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
25
frontend/vite.config.ts
Normal file
25
frontend/vite.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: '../web-static',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://localhost:8080',
|
||||
ws: true,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user