manual submit

This commit is contained in:
Khoa (Revenovich) Tran Gia
2026-03-07 21:49:16 +07:00
parent 1748cbf8d2
commit 6004b000a7
39 changed files with 5794 additions and 614 deletions

View File

@@ -9,6 +9,8 @@
"version": "0.1.0",
"dependencies": {
"@tanstack/react-query": "^5.62.0",
"framer-motion": "^12.35.0",
"lucide-react": "^0.577.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.27.0"
@@ -1729,6 +1731,33 @@
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/framer-motion": {
"version": "12.35.0",
"resolved": "https://artifactory.ubisoft.org/api/npm/npm/framer-motion/-/framer-motion-12.35.0.tgz",
"integrity": "sha512-w8hghCMQ4oq10j6aZh3U2yeEQv5K69O/seDI/41PK4HtgkLrcBovUNc0ayBC3UyyU7V1mrY2yLzvYdWJX9pGZQ==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.35.0",
"motion-utils": "^12.29.2",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://artifactory.ubisoft.org/api/npm/npm/fsevents/-/fsevents-2.3.3.tgz",
@@ -1936,6 +1965,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "0.577.0",
"resolved": "https://artifactory.ubisoft.org/api/npm/npm/lucide-react/-/lucide-react-0.577.0.tgz",
"integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://artifactory.ubisoft.org/api/npm/npm/merge2/-/merge2-1.4.1.tgz",
@@ -1960,6 +1998,21 @@
"node": ">=8.6"
}
},
"node_modules/motion-dom": {
"version": "12.35.0",
"resolved": "https://artifactory.ubisoft.org/api/npm/npm/motion-dom/-/motion-dom-12.35.0.tgz",
"integrity": "sha512-FFMLEnIejK/zDABn+vqGVAUN4T0+3fw+cVAY8MMT65yR+j5uMuvWdd4npACWhh94OVWQs79CrBBuwOwGRZAQiA==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.29.2"
}
},
"node_modules/motion-utils": {
"version": "12.29.2",
"resolved": "https://artifactory.ubisoft.org/api/npm/npm/motion-utils/-/motion-utils-12.29.2.tgz",
"integrity": "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://artifactory.ubisoft.org/api/npm/npm/ms/-/ms-2.1.3.tgz",
@@ -2651,6 +2704,12 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://artifactory.ubisoft.org/api/npm/npm/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://artifactory.ubisoft.org/api/npm/npm/typescript/-/typescript-5.9.3.tgz",

View File

@@ -9,10 +9,12 @@
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.62.0",
"framer-motion": "^12.35.0",
"lucide-react": "^0.577.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.27.0",
"@tanstack/react-query": "^5.62.0"
"react-router-dom": "^6.27.0"
},
"devDependencies": {
"@types/react": "^18.3.12",

View File

@@ -12,6 +12,7 @@ import StatusPage from './pages/StatusPage'
import ServerPage from './pages/ServerPage'
import HistoryPage from './pages/HistoryPage'
import AdminPage from './pages/AdminPage'
import FacesPage from './pages/FacesPage'
import SharePage from './pages/SharePage'
function RequireAuth({ children }: { children: React.ReactNode }) {
@@ -59,6 +60,14 @@ export default function App() {
</RequireAdmin>
}
/>
<Route
path="faces"
element={
<RequireAdmin>
<FacesPage />
</RequireAdmin>
}
/>
</Route>
<Route path="/share/:token" element={<SharePage />} />
<Route path="*" element={<Navigate to="/generate" replace />} />

View File

@@ -93,8 +93,14 @@ export interface InputImage {
filename: string
is_active: number
active_slot_key: string | null
detected_persons?: string[]
}
export const listInputs = (persons?: string[]) => {
const params = new URLSearchParams()
persons?.forEach(p => params.append('persons', p))
const qs = params.toString()
return _fetch<InputImage[]>(qs ? `/api/inputs?${qs}` : '/api/inputs')
}
export const listInputs = () => _fetch<InputImage[]>('/api/inputs')
export const uploadInput = (file: File, slotKey = 'input_image') => {
const form = new FormData()
@@ -142,11 +148,44 @@ 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 getHistory = (q?: string, persons?: string[]) => {
const params = new URLSearchParams()
if (q) params.set('q', q)
persons?.forEach(p => params.append('persons', p))
const qs = params.toString()
return _fetch<{ history: Array<Record<string, unknown>> }>(qs ? `/api/history?${qs}` : '/api/history')
}
export const createHistoryShare = (promptId: string) =>
_fetch<{ share_token: string }>(`/api/history/${promptId}/share`, { method: 'POST' })
export const getGenerationPersons = (promptId: string) =>
_fetch<{ persons: Array<{ id: number; name: string }> }>(`/api/history/${promptId}/persons`)
export const addGenerationPerson = (promptId: string, name: string) =>
_fetch<{ person_id: number; name: string }>(`/api/history/${promptId}/persons`, {
method: 'POST',
body: JSON.stringify({ name }),
})
export const removeGenerationPerson = (promptId: string, personId: number) =>
_fetch<{ ok: boolean }>(`/api/history/${promptId}/persons/${personId}`, { method: 'DELETE' })
export interface ShareOptions {
is_public?: boolean
expires_in_hours?: number
max_views?: number
}
export interface ShareResult {
share_token: string
is_public: boolean
expires_at: string | null
max_views: number | null
}
export const createHistoryShare = (promptId: string, options?: ShareOptions) =>
_fetch<ShareResult>(`/api/history/${promptId}/share`, {
method: 'POST',
body: JSON.stringify(options ?? {}),
})
export const revokeHistoryShare = (promptId: string) =>
_fetch<{ ok: boolean }>(`/api/history/${promptId}/share`, { method: 'DELETE' })
@@ -196,3 +235,165 @@ export const loadWorkflow = (filename: string) => {
export const getModels = (type: 'checkpoints' | 'loras') =>
_fetch<{ type: string; models: string[] }>(`/api/workflow/models?type=${type}`)
// Faces
export interface PendingFace {
detection_id: number
face_index: number
bbox: Record<string, number>
}
export interface Alias {
id: number
alias: string
}
export interface Person {
id: number
name: string
created_at: string
aliases: Alias[]
face_count: number
}
export interface UnidentifiedDetection {
id: number
source_id: number
face_index: number
bbox_json: string
created_at: string
}
export const listPersons = () =>
_fetch<{ persons: Person[] }>('/api/faces/persons')
export const checkPersonName = (name: string) =>
_fetch<{ exists: boolean }>(`/api/faces/persons/check?name=${encodeURIComponent(name)}`)
export const identifyFaces = (
identifications: Array<{ detection_id: number; name: string; use_existing: boolean }>
) =>
_fetch<{ identifications: Array<{ detection_id: number; person_id: number; person_name: string; is_new: boolean }>; auto_linked_count: number }>(
'/api/faces/identify',
{ method: 'POST', body: JSON.stringify({ identifications }) }
)
export const faceCropUrl = (detectionId: number) => `/api/faces/crop/${detectionId}`
export const getUnidentifiedDetections = (limit = 50, offset = 0) =>
_fetch<{ detections: UnidentifiedDetection[]; total: number }>(
`/api/faces/detections/unidentified?limit=${limit}&offset=${offset}`
)
export const addPersonAlias = (personId: number, alias: string) =>
_fetch<{ id: number; alias: string }>(`/api/faces/persons/${personId}/aliases`, {
method: 'POST',
body: JSON.stringify({ alias }),
})
export const removePersonAlias = (personId: number, aliasId: number) =>
_fetch<{ ok: boolean }>(`/api/faces/persons/${personId}/aliases/${aliasId}`, {
method: 'DELETE',
})
export interface FaceGroup {
id: number
label: string | null
threshold: number
is_manual: boolean
created_at: string
count: number
detection_ids: number[]
preview_ids: number[]
}
export interface GroupDetection {
id: number
source_type: 'input' | 'output'
face_index: number
created_at: string
}
export const listFaceGroups = () =>
_fetch<{ groups: FaceGroup[]; total: number }>('/api/faces/groups')
export const getFaceGroupDetections = (groupId: number) =>
_fetch<{ detections: GroupDetection[] }>(`/api/faces/groups/${groupId}/detections`)
export const computeFaceGroups = (threshold: number) =>
_fetch<{ groups_created: number; total_detections_clustered: number; threshold: number }>(
'/api/faces/groups/compute',
{ method: 'POST', body: JSON.stringify({ threshold }) }
)
export const mergeFaceGroups = (keepId: number, discardId: number) =>
_fetch<{ ok: boolean; surviving_group_id: number }>('/api/faces/groups/merge', {
method: 'POST',
body: JSON.stringify({ keep_group_id: keepId, discard_group_id: discardId }),
})
export const identifyFaceGroup = (groupId: number, name: string, useExisting = false) =>
_fetch<{
person_id: number; person_name: string; is_new: boolean
identified_count: number; auto_linked_count: number
}>(`/api/faces/groups/${groupId}/identify`, {
method: 'POST',
body: JSON.stringify({ name, use_existing: useExisting }),
})
export const removeDetectionFromGroup = (groupId: number, detectionId: number) =>
_fetch<{ ok: boolean }>(
`/api/faces/groups/${groupId}/detections/${detectionId}`,
{ method: 'DELETE' }
)
export const rescanOutputEmbeddings = () =>
_fetch<{ processed: number; updated: number }>('/api/faces/rescan/outputs', { method: 'POST' })
export interface PersonDetection {
id: number
source_type: 'input' | 'output'
source_id: number
face_index: number
frame_index: number
bbox_json: string | null
created_at: string
identified_at: string | null
}
export const getPerson = (personId: number) =>
_fetch<Person>(`/api/faces/persons/${personId}`)
export const getPersonDetections = (personId: number, limit = 50, offset = 0) =>
_fetch<{ detections: PersonDetection[]; total: number }>(
`/api/faces/persons/${personId}/detections?limit=${limit}&offset=${offset}`
)
export const searchPersons = (q: string, limit = 10) =>
_fetch<{ persons: Person[] }>(
`/api/faces/persons/search?q=${encodeURIComponent(q)}&limit=${limit}`
)
export const reassignDetection = (
detectionId: number,
personName: string | null,
useExisting = false,
) =>
_fetch<{ detection_id: number; person_id: number | null; is_new?: boolean; unidentified?: boolean }>(
`/api/faces/detections/${detectionId}/reassign`,
{ method: 'POST', body: JSON.stringify({ person_name: personName, use_existing: useExisting }) }
)
export const renamePerson = (personId: number, name: string) =>
_fetch<{ ok: boolean; person_id: number; name: string }>(`/api/faces/persons/${personId}`, {
method: 'PATCH',
body: JSON.stringify({ name }),
})
export const deletePerson = (personId: number) =>
_fetch<{ ok: boolean }>(`/api/faces/persons/${personId}`, { method: 'DELETE' })
export const mergePersons = (survivorId: number, otherId: number) =>
_fetch<{ ok: boolean; survivor_id: number; absorbed_id: number }>(
`/api/faces/persons/${survivorId}/merge`,
{ method: 'POST', body: JSON.stringify({ other_person_id: otherId }) }
)

View File

@@ -14,6 +14,7 @@ import {
getInputMid,
} from '../api/client'
import LazyImage from './LazyImage'
import { X } from 'lucide-react'
interface Props {
/** Called when the Generate button is clicked with the current overrides */
@@ -47,20 +48,18 @@ export default function DynamicWorkflowForm({ onGenerate, lastSeed, generating,
})
const { data: inputImages } = useQuery({
queryKey: ['inputs'],
queryFn: listInputs,
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 [imagePicker, setImagePicker] = useState<string | null>(null)
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 }))
@@ -114,7 +113,7 @@ export default function DynamicWorkflowForm({ onGenerate, lastSeed, generating,
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"
className="glass-input w-full resize-y"
value={String(val ?? '')}
onChange={e => setValue(inp.key, e.target.value)}
/>
@@ -127,7 +126,7 @@ export default function DynamicWorkflowForm({ onGenerate, lastSeed, generating,
<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"
className="glass-input flex-1 disabled:opacity-40"
value={isRandom ? '' : String(val ?? '')}
placeholder={isRandom ? 'Random' : undefined}
disabled={isRandom}
@@ -136,7 +135,11 @@ export default function DynamicWorkflowForm({ onGenerate, lastSeed, generating,
<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'}`}
className={`text-xs px-2.5 py-1.5 rounded-xl border transition-all ${
isRandom
? 'bg-indigo-600 text-white border-indigo-600 shadow-md shadow-indigo-500/20'
: 'border-white/20 dark:border-white/10 text-gray-600 dark:text-gray-300 hover:bg-white/10'
}`}
>
🎲 Random
</button>
@@ -154,17 +157,21 @@ export default function DynamicWorkflowForm({ onGenerate, lastSeed, generating,
<img
src={getInputThumb(activeImg.id)}
alt={activeImg.filename}
className="w-16 h-16 object-cover rounded border border-gray-300 dark:border-gray-600"
className="w-16 h-16 object-cover rounded-xl border border-white/20 dark:border-white/10"
/>
) : (
<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="w-16 h-16 rounded-xl border border-dashed border-white/20 dark:border-white/10 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>
<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"
className="btn-secondary text-xs py-0.5 px-2"
>
Browse
</button>
@@ -176,7 +183,7 @@ export default function DynamicWorkflowForm({ onGenerate, lastSeed, generating,
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"
className="glass-input w-full"
value={String(val ?? '')}
onChange={e => setValue(inp.key, e.target.value)}
>
@@ -189,7 +196,7 @@ export default function DynamicWorkflowForm({ onGenerate, lastSeed, generating,
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"
className="glass-input w-full"
value={String(val ?? '')}
onChange={e => setValue(inp.key, e.target.value)}
>
@@ -204,7 +211,7 @@ export default function DynamicWorkflowForm({ onGenerate, lastSeed, generating,
<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"
className="glass-input w-full"
value={String(val ?? '')}
placeholder={String(inp.current_value ?? '')}
onChange={e => {
@@ -223,7 +230,9 @@ export default function DynamicWorkflowForm({ onGenerate, lastSeed, generating,
{/* 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>
<label className="block text-sm font-medium text-indigo-400/80 dark:text-indigo-300/80 mb-1.5">
{inp.label}
</label>
{renderField(inp)}
{imagePicker === inp.key && (
<ImagePickerGrid
@@ -238,14 +247,16 @@ export default function DynamicWorkflowForm({ onGenerate, lastSeed, generating,
{/* 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">
<details className="border border-white/10 dark:border-white/5 rounded-2xl">
<summary className="px-3 py-2 text-sm font-medium cursor-pointer select-none text-gray-600 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200">
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>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
{inp.label}
</label>
{renderField(inp)}
{imagePicker === inp.key && (
<ImagePickerGrid
@@ -268,14 +279,14 @@ export default function DynamicWorkflowForm({ onGenerate, lastSeed, generating,
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"
className="glass-input w-16 text-center"
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"
className="btn-primary flex-1"
>
{generating ? '⏳ Generating…' : count > 1 ? `Generate ×${count}` : 'Generate'}
</button>
@@ -296,21 +307,23 @@ function ImagePickerGrid({
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="mt-2 glass-card p-3">
<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>
<button onClick={onClose} className="text-gray-400 hover:text-gray-300 transition-colors">
<X size={14} />
</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">
<div className="grid grid-cols-3 sm:grid-cols-4 gap-1.5 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"
className="relative aspect-square overflow-hidden rounded-xl border border-white/10 hover:border-indigo-400 hover:shadow-[0_0_8px_rgba(99,102,241,0.4)] transition-all"
title={img.filename}
>
<LazyImage

View File

@@ -0,0 +1,183 @@
import React, { useCallback, useEffect, useState } from 'react'
import { checkPersonName, faceCropUrl, identifyFaces, PendingFace } from '../api/client'
interface FaceState {
name: string
nameConflicts: boolean
useExisting: boolean
}
interface Props {
pendingFaces: PendingFace[]
inputId: number
onDone: () => void
}
export default function FaceIdentifyModal({ pendingFaces, onDone }: Props) {
const [faces, setFaces] = useState<FaceState[]>(() =>
pendingFaces.map(() => ({ name: '', nameConflicts: false, useExisting: false }))
)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
// Debounced name-conflict check per face index
useEffect(() => {
const timers: ReturnType<typeof setTimeout>[] = []
faces.forEach((f, i) => {
const name = f.name.trim()
if (!name) {
setFaces(prev => {
const next = [...prev]
next[i] = { ...next[i], nameConflicts: false, useExisting: false }
return next
})
return
}
const t = setTimeout(async () => {
try {
const { exists } = await checkPersonName(name)
setFaces(prev => {
const next = [...prev]
// If name changed while we were waiting, discard stale result
if (prev[i].name.trim() !== name) return prev
next[i] = { ...next[i], nameConflicts: exists, useExisting: exists ? prev[i].useExisting : false }
return next
})
} catch {
// ignore
}
}, 300)
timers.push(t)
})
return () => timers.forEach(clearTimeout)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [faces.map(f => f.name).join('|')])
const setName = useCallback((i: number, name: string) => {
setFaces(prev => {
const next = [...prev]
next[i] = { ...next[i], name, useExisting: false }
return next
})
}, [])
const setUseExisting = useCallback((i: number, val: boolean) => {
setFaces(prev => {
const next = [...prev]
next[i] = { ...next[i], useExisting: val }
return next
})
}, [])
const canSubmit = faces.every(f => !f.nameConflicts || f.useExisting) && !submitting
const handleSubmit = async () => {
setSubmitting(true)
setError(null)
try {
const identifications = faces
.map((f, i) => ({
detection_id: pendingFaces[i].detection_id,
name: f.name.trim(),
use_existing: f.useExisting,
}))
.filter(item => item.name.length > 0)
if (identifications.length > 0) {
await identifyFaces(identifications)
}
onDone()
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to save identifications')
setSubmitting(false)
}
}
return (
<div
className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4"
onClick={e => { if (e.target === e.currentTarget) onDone() }}
>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-base font-semibold text-gray-800 dark:text-gray-100">
Identify Faces
</h2>
<button
onClick={onDone}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-xl leading-none"
aria-label="Close"
>
</button>
</div>
{/* Face rows */}
<div className="p-4 space-y-4 max-h-[60vh] overflow-y-auto">
{pendingFaces.map((face, i) => {
const fState = faces[i]
return (
<div key={face.detection_id} className="flex items-start gap-3">
{/* Face crop */}
<img
src={faceCropUrl(face.detection_id)}
alt={`Face ${i + 1}`}
className="w-14 h-14 rounded object-cover flex-shrink-0 bg-gray-200 dark:bg-gray-700"
/>
{/* Name input */}
<div className="flex-1 space-y-1">
<input
type="text"
maxLength={100}
placeholder={`Face ${i + 1} name`}
value={fState.name}
onChange={e => setName(i, e.target.value)}
className="w-full text-sm rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-100 px-2 py-1 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
{fState.nameConflicts && (
<div className="space-y-1">
<p className="text-xs text-amber-600 dark:text-amber-400">
&ldquo;{fState.name.trim()}&rdquo; already exists
</p>
<label className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-300 cursor-pointer">
<input
type="checkbox"
checked={fState.useExisting}
onChange={e => setUseExisting(i, e.target.checked)}
className="accent-blue-600"
/>
Use existing &ldquo;{fState.name.trim()}&rdquo;
</label>
</div>
)}
</div>
</div>
)
})}
</div>
{error && (
<p className="px-4 text-xs text-red-500 dark:text-red-400">{error}</p>
)}
{/* Footer */}
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-gray-200 dark:border-gray-700">
<button
onClick={onDone}
className="text-sm px-3 py-1.5 rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
>
Skip
</button>
<button
onClick={handleSubmit}
disabled={!canSubmit}
className="text-sm px-3 py-1.5 rounded bg-blue-600 hover:bg-blue-700 disabled:opacity-40 text-white"
>
{submitting ? 'Saving…' : 'Save Identifications'}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,21 @@
import React from 'react'
export function GlassCard({
children,
className = '',
padding = 'p-4',
}: {
children: React.ReactNode
className?: string
padding?: string
}) {
return <div className={`glass-card ${padding} ${className}`}>{children}</div>
}
export function CardTitle({ children }: { children: React.ReactNode }) {
return (
<p className="text-xs font-semibold uppercase tracking-widest text-indigo-400/80 dark:text-indigo-300/80 mb-3">
{children}
</p>
)
}

View File

@@ -1,18 +1,23 @@
import React, { useEffect, useRef, useState, useCallback } from 'react'
import { NavLink, Outlet, useLocation } from 'react-router-dom'
import { useQueryClient } from '@tanstack/react-query'
import { AnimatePresence, motion } from 'framer-motion'
import {
Sparkles, Images, GitBranch, BookMarked, Activity, Server,
Clock, Users, Shield, Sun, Moon, LogOut, Menu,
} from 'lucide-react'
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' },
{ to: '/generate', label: 'Generate', icon: Sparkles },
{ to: '/inputs', label: 'Input Images', icon: Images },
{ to: '/workflow', label: 'Workflow', icon: GitBranch },
{ to: '/presets', label: 'Presets', icon: BookMarked },
{ to: '/status', label: 'Status', icon: Activity },
{ to: '/server', label: 'Server', icon: Server },
{ to: '/history', label: 'History', icon: Clock },
]
export default function Layout() {
@@ -26,13 +31,11 @@ export default function Layout() {
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])
@@ -67,79 +70,182 @@ export default function Layout() {
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)}
const sidebarContent = (
<>
{/* Header */}
<div className="px-4 py-4 border-b border-white/10 dark:border-white/5 flex items-center gap-2.5">
<span className="font-bold text-base bg-gradient-to-r from-indigo-400 to-violet-400 bg-clip-text text-transparent">
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-400' : comfyReachable ? 'bg-green-400' : 'bg-red-400'
}`}
/>
)}
</div>
{/* 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 }) => (
{/* Nav */}
<nav className="flex-1 overflow-y-auto py-2 px-0 space-y-0.5">
{navItems.map(({ to, label, icon: Icon }) => (
<motion.div
key={to}
whileHover={{ x: 6 }}
transition={{ type: 'spring', stiffness: 400, damping: 25 }}
>
<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' : ''}`
`relative flex items-center gap-2.5 px-3 py-2.5 text-sm rounded-xl mx-2 transition-colors ${
isActive
? 'bg-indigo-500/20 text-indigo-100 font-medium ring-1 ring-inset ring-indigo-400/30 shadow-[0_0_12px_rgba(99,102,241,0.2)]'
: 'text-gray-300 hover:text-white hover:bg-white/5'
}`
}
>
<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>
{({ isActive }) => (
<>
{isActive && (
<span className="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-4 bg-indigo-400 rounded-full" />
)}
<Icon size={15} className="flex-none" />
<span className="flex-1">{label}</span>
{to === '/generate' && pendingCount > 0 && (
<span className="ml-auto text-[10px] bg-yellow-400 text-gray-900 rounded-full px-1.5 py-0.5 leading-none font-semibold">
{pendingCount}
</span>
)}
</>
)}
</NavLink>
))}
{isAdmin && (
</motion.div>
))}
{isAdmin && (
<motion.div whileHover={{ x: 6 }} transition={{ type: 'spring', stiffness: 400, damping: 25 }}>
<NavLink
to="/faces"
className={({ isActive }) =>
`relative flex items-center gap-2.5 px-3 py-2.5 text-sm rounded-xl mx-2 transition-colors ${
isActive
? 'bg-indigo-500/20 text-indigo-100 font-medium ring-1 ring-inset ring-indigo-400/30 shadow-[0_0_12px_rgba(99,102,241,0.2)]'
: 'text-gray-300 hover:text-white hover:bg-white/5'
}`
}
>
{({ isActive }) => (
<>
{isActive && <span className="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-4 bg-indigo-400 rounded-full" />}
<Users size={15} className="flex-none" />
<span className="flex-1">Faces</span>
</>
)}
</NavLink>
</motion.div>
)}
{isAdmin && (
<motion.div whileHover={{ x: 6 }} transition={{ type: 'spring', stiffness: 400, damping: 25 }}>
<NavLink
to="/admin"
className={({ isActive }) =>
`block px-4 py-2 text-sm hover:bg-gray-700 transition-colors ${isActive ? 'bg-gray-700 font-medium' : ''}`
`relative flex items-center gap-2.5 px-3 py-2.5 text-sm rounded-xl mx-2 transition-colors ${
isActive
? 'bg-indigo-500/20 text-indigo-100 font-medium ring-1 ring-inset ring-indigo-400/30 shadow-[0_0_12px_rgba(99,102,241,0.2)]'
: 'text-gray-300 hover:text-white hover:bg-white/5'
}`
}
>
Admin
{({ isActive }) => (
<>
{isActive && <span className="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-4 bg-indigo-400 rounded-full" />}
<Shield size={15} className="flex-none" />
<span className="flex-1">Admin</span>
</>
)}
</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>
</motion.div>
)}
</nav>
{/* Footer */}
<div className="px-3 py-3 border-t border-white/10 dark:border-white/5 flex items-center gap-2">
<span className="flex-1 truncate text-xs text-gray-400">{user?.label ?? '...'}</span>
<button
onClick={toggleDark}
className="p-1.5 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-yellow-300"
title="Toggle dark mode"
>
{dark ? <Sun size={14} /> : <Moon size={14} />}
</button>
<button
onClick={logout}
className="p-1.5 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-red-400"
title="Logout"
>
<LogOut size={14} />
</button>
</div>
</>
)
return (
<div className="flex h-screen overflow-hidden">
{/* Mobile backdrop */}
<AnimatePresence>
{sidebarOpen && (
<motion.div
key="backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-30 md:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
</AnimatePresence>
{/* Sidebar — mobile (animated) */}
<AnimatePresence>
{sidebarOpen && (
<motion.aside
key="mobile-sidebar"
initial={{ x: -192 }}
animate={{ x: 0 }}
exit={{ x: -192 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
className="fixed inset-y-0 left-0 z-40 w-48 flex-none glass border-r border-white/10 dark:border-white/5 flex flex-col md:hidden"
>
{sidebarContent}
</motion.aside>
)}
</AnimatePresence>
{/* Sidebar — desktop (static) */}
<aside className="hidden md:flex w-48 flex-none glass border-r border-white/10 dark:border-white/5 flex-col">
{sidebarContent}
</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 */}
<main className="flex-1 overflow-y-auto p-3 sm:p-6">
{/* Hamburger — 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"
className="md:hidden mb-3 glass rounded-lg p-1.5 text-gray-700 dark:text-gray-300"
onClick={() => setSidebarOpen(true)}
aria-label="Open menu"
>
<Menu size={18} />
</button>
<Outlet />
<AnimatePresence mode="wait">
<motion.div
key={location.pathname}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
>
<Outlet />
</motion.div>
</AnimatePresence>
</main>
</div>
)

View File

@@ -13,7 +13,7 @@ export interface StatusSnapshot {
total_generated: number
}
overrides?: Record<string, unknown>
service?: { state: string }
service?: { state: string; http_reachable?: boolean }
upload?: { configured: boolean; running: boolean; total_ok: number; total_fail: number }
}
@@ -39,11 +39,17 @@ export function useStatus({
}: UseStatusOptions) {
const [status, setStatus] = useState<StatusSnapshot>({})
const [executingNode, setExecutingNode] = useState<string | null>(null)
const [lastUpdatedAt, setLastUpdatedAt] = useState<number | null>(null)
const handleMessage = useCallback(
(msg: { type: string; data: unknown; ts: number }) => {
if (msg.type === 'status_snapshot') {
setStatus(msg.data as StatusSnapshot)
setStatus(prev => {
const snap = msg.data as StatusSnapshot
// Preserve service (set by server_state events) since status_snapshot never includes it
return { ...snap, service: snap.service ?? prev.service }
})
setLastUpdatedAt(Date.now())
} else if (msg.type === 'node_executing') {
const d = msg.data as { node: string; prompt_id: string }
setExecutingNode(d.node)
@@ -58,14 +64,14 @@ export function useStatus({
const d = msg.data as { state: string; http_reachable: boolean }
setStatus(prev => ({
...prev,
service: { state: d.state },
service: { state: d.state, http_reachable: d.http_reachable },
}))
}
},
[onGenerationComplete, onGenerationError, onNodeExecuting],
)
useWebSocket({ onMessage: handleMessage, enabled })
const { connected } = useWebSocket({ onMessage: handleMessage, enabled })
return { status, executingNode }
return { status, executingNode, connected, lastUpdatedAt }
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useCallback } from 'react'
import { useState, useEffect, useRef, useCallback } from 'react'
interface WSOptions {
onMessage: (data: { type: string; data: unknown; ts: number }) => void
@@ -13,6 +13,8 @@ export function useWebSocket({ onMessage, enabled = true }: WSOptions) {
const onMessageRef = useRef(onMessage)
onMessageRef.current = onMessage
const [connected, setConnected] = useState(false)
const connect = useCallback(() => {
if (!enabled) return
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
@@ -31,9 +33,11 @@ export function useWebSocket({ onMessage, enabled = true }: WSOptions) {
ws.onopen = () => {
backoffRef.current = 1000
setConnected(true)
}
ws.onclose = () => {
setConnected(false)
if (enabled) {
timerRef.current = setTimeout(() => {
backoffRef.current = Math.min(backoffRef.current * 2, 30_000)
@@ -52,4 +56,6 @@ export function useWebSocket({ onMessage, enabled = true }: WSOptions) {
if (timerRef.current) clearTimeout(timerRef.current)
}
}, [connect, enabled])
return { connected }
}

View File

@@ -1,3 +1,5 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
@@ -6,7 +8,44 @@
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;
@keyframes gradient-drift {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
/* Light mode background */
body {
background: linear-gradient(-45deg, #e0e7ff, #ede9fe, #f1f5f9, #ddd6fe);
background-size: 400% 400%;
animation: gradient-drift 18s ease infinite;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
@apply text-gray-900 dark:text-gray-100;
}
/* Dark mode background */
html.dark body {
background: linear-gradient(-45deg, #1e1b4b, #3b0764, #0f172a, #1e1b4b);
background-size: 400% 400%;
animation: gradient-drift 18s ease infinite;
}
@layer components {
.glass {
@apply bg-white/60 dark:bg-white/5 backdrop-blur-xl border border-white/80 dark:border-white/10 shadow-xl;
}
.glass-card {
@apply glass rounded-2xl;
}
.glass-input {
@apply bg-white/70 dark:bg-white/5 backdrop-blur-sm border border-white/80 dark:border-white/10 rounded-xl px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-400/50;
}
.btn-primary {
@apply bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl px-4 py-2 text-sm font-semibold transition-all duration-200 shadow-md shadow-indigo-500/20 hover:shadow-indigo-500/40 hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100;
}
.btn-secondary {
@apply bg-white/20 dark:bg-white/5 hover:bg-white/30 dark:hover:bg-white/10 border border-white/30 dark:border-white/10 rounded-xl px-3 py-2 text-sm font-medium transition-all duration-200;
}
.btn-danger {
@apply bg-red-600/80 hover:bg-red-600 text-white rounded-xl px-3 py-1.5 text-sm font-medium transition-all duration-200;
}
}

View File

@@ -33,51 +33,57 @@ export default function AdminPage() {
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>
<h1 className="text-2xl font-bold tracking-tight">
<span className="bg-gradient-to-r from-indigo-400 to-violet-400 bg-clip-text text-transparent">
Admin
</span>
</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="glass-card p-4 space-y-3">
<p className="text-xs font-semibold uppercase tracking-widest text-indigo-400/80 dark:text-indigo-300/80">
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"
className="glass-input flex-1"
/>
<label className="flex items-center gap-1 text-sm text-gray-600 dark:text-gray-400 cursor-pointer">
<label className="flex items-center gap-1.5 text-sm text-gray-600 dark:text-gray-400 cursor-pointer">
<input
type="checkbox"
checked={isAdmin}
onChange={e => setIsAdmin(e.target.checked)}
className="rounded"
className="rounded accent-indigo-500"
/>
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"
className="btn-primary"
>
Create
</button>
</div>
{createError && <p className="text-red-500 text-sm">{createError}</p>}
{createError && <p className="text-red-400 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">
<div className="glass border-amber-400/30 rounded-2xl p-4 space-y-2">
<p className="text-sm font-medium text-amber-300">
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">
<code className="block text-xs break-all bg-white/5 rounded-xl p-2 text-amber-200 select-all">
{newToken}
</code>
<button
onClick={() => setNewToken(null)}
className="text-xs text-yellow-600 dark:text-yellow-400 hover:underline"
className="text-xs text-amber-400/70 hover:text-amber-300 transition-colors"
>
Dismiss
</button>
@@ -90,17 +96,21 @@ export default function AdminPage() {
) : 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">
<div className="space-y-2">
{tokens.map(t => (
<div key={t.id} className="flex items-center justify-between px-3 py-2 text-sm">
<div key={t.id} className="glass-card p-3 flex items-center justify-between 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>
{t.admin && (
<span className="ml-1.5 text-xs text-indigo-400 dark:text-indigo-300">(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"
className="btn-danger"
>
Revoke
</button>

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@ import { useStatus, GenerationResult } from '../hooks/useStatus'
import { useAuth } from '../hooks/useAuth'
import { useGeneration } from '../context/GenerationContext'
import DynamicWorkflowForm from '../components/DynamicWorkflowForm'
import { X } from 'lucide-react'
interface Notification {
id: number
@@ -114,27 +115,39 @@ export default function GeneratePage() {
const queueRunning = (status.comfy?.queue_running ?? 0)
return (
<div className="max-w-2xl mx-auto space-y-6">
<div className="max-w-2xl mx-auto space-y-5">
<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
<h1 className="text-2xl font-bold tracking-tight">
<span className="bg-gradient-to-r from-indigo-400 to-violet-400 bg-clip-text text-transparent">
Generate
</span>
</h1>
<div className="text-xs text-gray-400 dark:text-gray-500">
{queueRunning} running · {queuePending} pending
</div>
</div>
{/* Mode toggle */}
<div className="flex flex-wrap gap-2 text-sm">
<div className="glass-card p-1 flex gap-1 w-fit">
<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'}`}
className={`px-3 py-1.5 text-sm rounded-xl transition-all font-medium ${
mode === 'workflow'
? 'bg-indigo-600 text-white shadow-md shadow-indigo-500/20'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
}`}
>
Workflow mode
Workflow
</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'}`}
className={`px-3 py-1.5 text-sm rounded-xl transition-all font-medium ${
mode === 'prompt'
? 'bg-indigo-600 text-white shadow-md shadow-indigo-500/20'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
}`}
>
Prompt mode
Prompt
</button>
</div>
@@ -149,7 +162,7 @@ export default function GeneratePage() {
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"
className="glass-input flex-1 disabled:opacity-50"
>
<option value="" disabled>Load a preset</option>
{(presetsData?.presets ?? []).map(p => (
@@ -164,74 +177,76 @@ export default function GeneratePage() {
{/* 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">
<div className="glass border-indigo-400/30 rounded-2xl p-3 text-sm text-indigo-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 className="glass-card p-5">
{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.5">Prompt</label>
<textarea
rows={3}
className="glass-input w-full resize-y"
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.5">Negative prompt</label>
<textarea
rows={2}
className="glass-input w-full resize-y"
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="glass-input w-16 text-center"
title="Number of generations to queue"
/>
<button
onClick={handlePromptGenerate}
disabled={generating || !prompt.trim()}
className="btn-primary flex-1"
>
{generating ? '⏳ Queuing…' : promptCount > 1 ? `Generate ×${promptCount}` : 'Generate'}
</button>
</div>
</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>
)}
)}
</div>
{/* Toast notification stack */}
<div className="fixed bottom-4 right-4 z-50 space-y-2 max-w-sm">
<div className="fixed bottom-4 right-4 z-50 space-y-2 max-w-sm w-full">
{notifications.map(n => (
<div
key={n.id}
className={`flex items-start gap-2 rounded border p-3 text-sm shadow-md ${
className={`glass rounded-xl p-3 text-sm shadow-lg flex items-start gap-2 ${
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'
? 'border-green-400/30 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'
? 'border-red-400/30 text-red-300'
: 'border-indigo-400/30 text-indigo-300'
}`}
>
<span className="flex-1">{n.msg}</span>
@@ -240,7 +255,7 @@ export default function GeneratePage() {
className="flex-none opacity-60 hover:opacity-100 leading-none"
aria-label="Dismiss"
>
<X size={14} />
</button>
</div>
))}

View File

@@ -1,7 +1,8 @@
import React, { useState, useEffect, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { getHistory, createHistoryShare, revokeHistoryShare, savePresetFromHistory } from '../api/client'
import { getHistory, getGenerationPersons, addGenerationPerson, removeGenerationPerson, createHistoryShare, revokeHistoryShare, savePresetFromHistory, listPersons } from '../api/client'
import { useAuth } from '../hooks/useAuth'
import { X } from 'lucide-react'
interface HistoryRow {
id: number
@@ -13,6 +14,11 @@ interface HistoryRow {
file_paths?: string[]
created_at: string
share_token?: string | null
share_is_public?: boolean | null
share_expires_at?: string | null
share_max_views?: number | null
share_view_count?: number | null
detected_persons?: string[]
}
/** Debounce a value by `delay` ms. */
@@ -25,21 +31,133 @@ function useDebounce<T>(value: T, delay: number): T {
return debounced
}
/** Chip-based multi-person tag input with a filterable combobox dropdown. */
function PersonTagInput({
selected,
onChange,
allPersons,
}: {
selected: string[]
onChange: (persons: string[]) => void
allPersons: Array<{ id: number; name: string }>
}) {
const [inputVal, setInputVal] = useState('')
const [showDropdown, setShowDropdown] = useState(false)
const available = allPersons.filter(
p => !selected.includes(p.name) &&
(!inputVal || p.name.toLowerCase().includes(inputVal.toLowerCase()))
)
const add = (name: string) => {
if (!selected.includes(name)) onChange([...selected, name])
setInputVal('')
setShowDropdown(false)
}
const remove = (name: string) => onChange(selected.filter(p => p !== name))
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && available.length > 0) add(available[0].name)
if (e.key === 'Backspace' && !inputVal && selected.length > 0) {
remove(selected[selected.length - 1])
}
}
return (
<div className="space-y-1.5">
{selected.length > 0 && (
<div className="flex flex-wrap gap-1">
{selected.map(p => (
<span
key={p}
className="flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-indigo-500/20 text-indigo-300"
>
{p}
<button onClick={() => remove(p)} className="hover:text-white ml-0.5">
<X size={10} />
</button>
</span>
))}
</div>
)}
{allPersons.length > selected.length && (
<div className="relative">
<input
type="text"
value={inputVal}
onChange={e => setInputVal(e.target.value)}
onFocus={() => setShowDropdown(true)}
onBlur={() => setTimeout(() => setShowDropdown(false), 150)}
onKeyDown={handleKeyDown}
placeholder={selected.length === 0 ? 'Filter by person…' : 'Add person…'}
className="glass-input w-full text-sm"
/>
{showDropdown && available.length > 0 && (
<div className="absolute z-20 top-full mt-1 w-full bg-gray-900/95 backdrop-blur-xl border border-white/10 rounded-2xl p-1 max-h-48 overflow-y-auto shadow-xl">
{available.map(p => (
<button
key={p.id}
onMouseDown={() => add(p.name)}
className="w-full text-left text-xs px-3 py-1.5 rounded-lg hover:bg-white/10 text-gray-300 transition-colors"
>
{p.name}
</button>
))}
</div>
)}
</div>
)}
</div>
)
}
/** Return only the selected persons that appear in the row's detected_persons array. */
function getComboKey(row: HistoryRow, selected: string[]): string {
const matched = selected.filter(p => row.detected_persons?.includes(p))
return matched.join('|')
}
interface SectionGroup { key: string; combo: string[]; rows: HistoryRow[] }
/** Group rows by their exclusive combination of selected persons (ascending combo size). */
function groupByCombo(rows: HistoryRow[], selected: string[]): SectionGroup[] {
const groups = new Map<string, HistoryRow[]>()
for (const row of rows) {
const key = getComboKey(row, selected)
if (!key) continue // matched none of the selected persons
if (!groups.has(key)) groups.set(key, [])
groups.get(key)!.push(row)
}
return Array.from(groups.entries())
.map(([key, sRows]) => ({ key, combo: key.split('|'), rows: sRows }))
.sort((a, b) =>
a.combo.length !== b.combo.length
? a.combo.length - b.combo.length
: a.key.localeCompare(b.key)
)
}
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 [selectedPersons, setSelectedPersons] = useState<string[]>([])
const { user, isAdmin } = useAuth()
const debouncedQ = useDebounce(searchInput.trim(), 300)
const { data: personsData } = useQuery({ queryKey: ['faces', 'persons'], queryFn: listPersons })
const persons = personsData?.persons ?? []
const { data, isLoading } = useQuery({
queryKey: ['history', debouncedQ],
queryFn: () => getHistory(debouncedQ || undefined),
queryKey: ['history', debouncedQ, selectedPersons],
queryFn: () => getHistory(debouncedQ || undefined, selectedPersons.length ? selectedPersons : undefined),
refetchInterval: 10_000,
})
const rows: HistoryRow[] = ((data?.history ?? []) as unknown as HistoryRow[])
const grouped: SectionGroup[] | null = selectedPersons.length >= 2 ? groupByCombo(rows, selectedPersons) : null
const isSearching = searchInput.trim() !== debouncedQ
@@ -47,24 +165,37 @@ export default function HistoryPage() {
return (
<div className="space-y-4">
<h1 className="text-xl font-bold text-gray-800 dark:text-gray-100">History</h1>
<h1 className="text-2xl font-bold tracking-tight">
<span className="bg-gradient-to-r from-indigo-400 to-violet-400 bg-clip-text text-transparent">
History
</span>
</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>
{/* Search + person filter */}
<div className="glass-card p-3 space-y-2 relative z-30">
<div className="relative">
<input
type="text"
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
placeholder="Search by prompt, checkpoint, seed…"
className="glass-input w-full pr-8"
/>
{searchInput && (
<button
onClick={() => setSearchInput('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-300 transition-colors"
>
<X size={14} />
</button>
)}
</div>
{persons.length > 0 && (
<PersonTagInput
selected={selectedPersons}
onChange={setSelectedPersons}
allPersons={persons}
/>
)}
</div>
{isSearching && <p className="text-xs text-gray-400">Searching</p>}
@@ -75,88 +206,42 @@ export default function HistoryPage() {
<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>
)}
) : grouped ? (
/* Grouped view: selectedPersons.length >= 2 */
<div className="space-y-6">
{grouped.length === 0 ? (
<p className="text-sm text-gray-400">No results match the selected persons.</p>
) : (
grouped.map(section => (
<div key={section.key} className="space-y-2">
<div className="glass-card p-3">
<h2 className="text-sm font-semibold text-indigo-300">
{section.combo.join(' & ')}
<span className="ml-2 text-xs text-gray-400">{section.rows.length} result(s)</span>
</h2>
</div>
)
})}
</div>
</>
<HistoryList
rows={section.rows}
expandedId={expandedId}
setExpandedId={setExpandedId}
onLightbox={setLightbox}
currentUserLabel={user?.label ?? null}
isAdmin={isAdmin}
/>
</div>
))
)}
</div>
) : (
/* Flat view */
<HistoryList
rows={rows}
expandedId={expandedId}
setExpandedId={setExpandedId}
onLightbox={setLightbox}
currentUserLabel={user?.label ?? null}
isAdmin={isAdmin}
/>
)}
{/* Lightbox */}
@@ -166,27 +251,147 @@ export default function HistoryPage() {
onClick={() => setLightbox(null)}
>
<button
className="absolute top-3 right-3 text-white text-2xl leading-none hover:text-gray-300"
className="absolute top-3 right-3 text-white hover:text-gray-300 transition-colors"
onClick={() => setLightbox(null)}
aria-label="Close"
>
<X size={24} />
</button>
<img src={lightbox} alt="preview" className="max-w-[90vw] max-h-[90vh] object-contain rounded" />
<img
src={lightbox}
alt="preview"
className="max-w-[90vw] max-h-[90vh] object-contain rounded-2xl shadow-2xl"
/>
</div>
)}
</div>
)
}
function HistoryList({
rows,
expandedId,
setExpandedId,
onLightbox,
currentUserLabel,
isAdmin,
}: {
rows: HistoryRow[]
expandedId: string | null
setExpandedId: (id: string | null) => void
onLightbox: (url: string) => void
currentUserLabel: string | null
isAdmin: boolean
}) {
return (
<>
{/* Desktop table */}
<div className="hidden sm:block glass-card overflow-hidden p-0">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-white/20 dark:bg-white/5 text-left text-xs text-gray-500 dark:text-gray-400">
<th className="pb-2 pt-2.5 px-4 border-b border-white/10 dark:border-white/5">Time</th>
<th className="pb-2 pt-2.5 px-4 border-b border-white/10 dark:border-white/5">Source</th>
<th className="pb-2 pt-2.5 px-4 border-b border-white/10 dark:border-white/5">User</th>
<th className="pb-2 pt-2.5 px-4 border-b border-white/10 dark:border-white/5">Seed</th>
<th className="pb-2 pt-2.5 px-4 border-b border-white/10 dark:border-white/5">Files</th>
</tr>
</thead>
<tbody>
{rows.map(row => (
<React.Fragment key={row.prompt_id}>
<tr
className="border-b border-white/5 dark:border-white/5 hover:bg-white/10 dark:hover:bg-white/5 transition-colors cursor-pointer"
onClick={() => setExpandedId(expandedId === row.prompt_id ? null : row.prompt_id)}
>
<td className="py-2.5 px-4 text-gray-500 dark:text-gray-400 whitespace-nowrap">
{new Date(row.created_at).toLocaleString()}
</td>
<td className="py-2.5 px-4">
<span className={`text-xs px-2 py-0.5 rounded-full ${
row.source === 'web'
? 'bg-blue-500/20 text-blue-300'
: 'bg-violet-500/20 text-violet-300'
}`}>
{row.source}
</span>
</td>
<td className="py-2.5 px-4 text-gray-500 dark:text-gray-400">{row.user_label ?? '—'}</td>
<td className="py-2.5 px-4 font-mono text-gray-600 dark:text-gray-300">{row.seed ?? '—'}</td>
<td className="py-2.5 px-4 text-gray-500 dark:text-gray-400">{(row.file_paths ?? []).length} file(s)</td>
</tr>
{expandedId === row.prompt_id && (
<tr className="bg-white/5 dark:bg-white/5">
<td colSpan={5} className="px-4 py-3">
<ExpandedRow row={row} onLightbox={onLightbox} currentUserLabel={currentUserLabel} isAdmin={isAdmin} />
</td>
</tr>
)}
</React.Fragment>
))}
</tbody>
</table>
</div>
{/* Mobile card list */}
<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="glass-card p-0 overflow-hidden">
<button
className="w-full text-left px-3 py-2.5 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-2 py-0.5 rounded-full ${
row.source === 'web'
? 'bg-blue-500/20 text-blue-300'
: 'bg-violet-500/20 text-violet-300'
}`}>
{row.source}
</span>
</div>
<div className="flex items-center gap-3 text-xs text-gray-500 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">{isExpanded ? '▲' : '▼'}</span>
</div>
</button>
{isExpanded && (
<div className="px-3 pb-3 border-t border-white/10 dark:border-white/5 pt-2">
<ExpandedRow row={row} onLightbox={onLightbox} currentUserLabel={currentUserLabel} isAdmin={isAdmin} />
</div>
)}
</div>
)
})}
</div>
</>
)
}
function formatExpiry(s: string | null | undefined): string {
if (!s) return 'Never'
const d = new Date(s)
return d < new Date() ? 'Expired' : d.toLocaleString()
}
function ExpandedRow({
row,
onLightbox,
currentUserLabel,
isAdmin,
}: {
row: HistoryRow
onLightbox: (url: string) => void
currentUserLabel: string | null
isAdmin: boolean
}) {
const qc = useQueryClient()
const [copied, setCopied] = useState(false)
@@ -195,6 +400,12 @@ function ExpandedRow({
const [presetDesc, setPresetDesc] = useState('')
const [presetMsg, setPresetMsg] = useState<{ ok: boolean; text: string } | null>(null)
const presetNameRef = useRef<HTMLInputElement>(null)
const [addPersonInput, setAddPersonInput] = useState('')
const [showPersonDrop, setShowPersonDrop] = useState(false)
const [showSharePanel, setShowSharePanel] = useState(false)
const [shareIsPublic, setShareIsPublic] = useState(false)
const [shareExpiryHours, setShareExpiryHours] = useState<number | null>(null)
const [shareMaxViews, setShareMaxViews] = useState<number | null>(null)
const { data: imagesData, isLoading } = useQuery({
queryKey: ['history', row.prompt_id, 'images'],
@@ -205,9 +416,51 @@ function ExpandedRow({
},
})
const { data: personsData } = useQuery({
queryKey: ['history', row.prompt_id, 'persons'],
queryFn: () => getGenerationPersons(row.prompt_id),
})
const rowPersons = personsData?.persons ?? []
const { data: allPersonsData } = useQuery({
queryKey: ['faces', 'persons'],
queryFn: listPersons,
enabled: isAdmin,
})
const allPersons = allPersonsData?.persons ?? []
const addPersonMut = useMutation({
mutationFn: (name: string) => addGenerationPerson(row.prompt_id, name),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['history', row.prompt_id, 'persons'] })
qc.invalidateQueries({ queryKey: ['history'] })
setAddPersonInput('')
},
})
const removePersonMut = useMutation({
mutationFn: (personId: number) => removeGenerationPerson(row.prompt_id, personId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['history', row.prompt_id, 'persons'] })
qc.invalidateQueries({ queryKey: ['history'] })
},
})
const personDropOptions = allPersons.filter(
p => !rowPersons.some(rp => rp.id === p.id) &&
(!addPersonInput || p.name.toLowerCase().includes(addPersonInput.toLowerCase()))
)
const shareMut = useMutation({
mutationFn: () => createHistoryShare(row.prompt_id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['history'] }),
mutationFn: () => createHistoryShare(row.prompt_id, {
is_public: shareIsPublic,
expires_in_hours: shareExpiryHours ?? undefined,
max_views: shareMaxViews ?? undefined,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['history'] })
setShowSharePanel(false)
},
})
const revokeMut = useMutation({
@@ -244,15 +497,13 @@ function ExpandedRow({
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">
<summary className="text-xs text-gray-400 cursor-pointer hover:text-gray-300">Overrides</summary>
<pre className="text-xs bg-white/5 rounded-xl p-2 mt-1 overflow-auto max-h-32 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 ? (
@@ -261,14 +512,7 @@ function ExpandedRow({
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"
/>
)
return <video key={i} src={videoSrc} controls className="rounded-xl max-h-40" />
}
const src = `data:${img.mime_type};base64,${img.data}`
return (
@@ -276,7 +520,7 @@ function ExpandedRow({
key={i}
src={src}
alt={img.filename}
className="rounded max-h-40 cursor-pointer border border-gray-200 dark:border-gray-700"
className="rounded-xl max-h-40 cursor-pointer shadow-md hover:shadow-lg transition-shadow"
onClick={() => onLightbox(src)}
/>
)
@@ -286,54 +530,205 @@ function ExpandedRow({
<p className="text-xs text-gray-400">Files not available (may have been moved or deleted).</p>
)}
{/* Owner-only actions */}
{(isAdmin || rowPersons.length > 0) && (
<div className="space-y-1.5">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-gray-500 dark:text-gray-400">People:</span>
{rowPersons.map(p => (
<span key={p.id} className="flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-blue-500/20 text-blue-300">
{p.name}
{isAdmin && (
<button
onClick={() => removePersonMut.mutate(p.id)}
disabled={removePersonMut.isPending}
className="hover:text-white ml-0.5 disabled:opacity-50"
aria-label={`Remove ${p.name}`}
>
<X size={10} />
</button>
)}
</span>
))}
{rowPersons.length === 0 && !isAdmin && <span className="text-xs text-gray-600">None detected</span>}
</div>
{isAdmin && (
<div className="relative">
<input
type="text"
value={addPersonInput}
onChange={e => setAddPersonInput(e.target.value)}
onFocus={() => setShowPersonDrop(true)}
onBlur={() => setTimeout(() => setShowPersonDrop(false), 150)}
onKeyDown={e => {
if (e.key === 'Enter' && personDropOptions.length > 0) {
addPersonMut.mutate(personDropOptions[0].name)
setShowPersonDrop(false)
}
}}
placeholder="Tag a person…"
className="glass-input w-full text-xs py-1"
disabled={addPersonMut.isPending}
/>
{showPersonDrop && personDropOptions.length > 0 && (
<div className="absolute z-20 top-full mt-1 w-full bg-gray-900/95 backdrop-blur-xl border border-white/10 rounded-2xl p-1 max-h-40 overflow-y-auto shadow-xl">
{personDropOptions.map(p => (
<button
key={p.id}
onMouseDown={() => { addPersonMut.mutate(p.name); setShowPersonDrop(false) }}
className="w-full text-left text-xs px-3 py-1.5 rounded-lg hover:bg-white/10 text-gray-300 transition-colors"
>
{p.name}
</button>
))}
</div>
)}
</div>
)}
</div>
)}
{isOwner && (
<div className="pt-1 border-t border-gray-100 dark:border-gray-700 space-y-2">
{/* Share section */}
<div className="pt-1 border-t border-white/10 dark:border-white/5 space-y-2">
{shareUrl ? (
<div className="space-y-1">
<p className="text-xs text-gray-500 dark:text-gray-400">Share link</p>
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<p className="text-xs text-gray-500 dark:text-gray-400">Share link</p>
<span className={`text-xs px-1.5 py-0.5 rounded-full ${
row.share_is_public
? 'bg-green-500/20 text-green-300'
: 'bg-gray-500/20 text-gray-400'
}`}>
{row.share_is_public ? 'Public' : 'Private'}
</span>
</div>
<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">
<code className="text-xs bg-white/5 rounded-xl px-2 py-1 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"
className="shrink-0 text-xs px-2 py-1 rounded-lg bg-indigo-500/20 text-indigo-300 hover:bg-indigo-500/30 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"
className="btn-danger shrink-0"
>
{revokeMut.isPending ? 'Revoking…' : 'Revoke'}
</button>
</div>
<div className="flex gap-4 text-xs text-gray-500">
<span>Expires: {formatExpiry(row.share_expires_at)}</span>
{row.share_max_views != null ? (
<span>Views: {row.share_view_count ?? 0} / {row.share_max_views}</span>
) : (row.share_view_count ?? 0) > 0 ? (
<span>Views: {row.share_view_count}</span>
) : null}
</div>
</div>
) : showSharePanel ? (
<div className="space-y-2">
<p className="text-xs text-gray-400">Create share link</p>
<div className="flex gap-2 items-center flex-wrap">
<span className="text-xs text-gray-500">Visibility:</span>
<button
onClick={() => setShareIsPublic(false)}
className={`text-xs px-2 py-0.5 rounded-full transition-colors ${
!shareIsPublic
? 'bg-indigo-500/30 text-indigo-300'
: 'bg-white/10 text-gray-400 hover:bg-white/15'
}`}
>
Private
</button>
<button
onClick={() => setShareIsPublic(true)}
className={`text-xs px-2 py-0.5 rounded-full transition-colors ${
shareIsPublic
? 'bg-green-500/30 text-green-300'
: 'bg-white/10 text-gray-400 hover:bg-white/15'
}`}
>
Public
</button>
</div>
<div className="flex gap-4 flex-wrap">
<div className="flex items-center gap-1.5">
<span className="text-xs text-gray-500">Expires after:</span>
<input
type="number"
min="0"
step="any"
value={shareExpiryHours ?? ''}
onChange={e => setShareExpiryHours(e.target.value ? parseFloat(e.target.value) : null)}
placeholder="hours"
className="glass-input w-20 py-0.5 text-xs"
/>
<span className="text-xs text-gray-500">hrs</span>
{shareExpiryHours !== null && (
<button onClick={() => setShareExpiryHours(null)} className="text-xs text-gray-500 hover:text-gray-300">×</button>
)}
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs text-gray-500">Max views:</span>
<input
type="number"
min="1"
step="1"
value={shareMaxViews ?? ''}
onChange={e => setShareMaxViews(e.target.value ? parseInt(e.target.value, 10) : null)}
placeholder="unlimited"
className="glass-input w-24 py-0.5 text-xs"
/>
{shareMaxViews !== null && (
<button onClick={() => setShareMaxViews(null)} className="text-xs text-gray-500 hover:text-gray-300">×</button>
)}
</div>
</div>
{shareIsPublic && !shareExpiryHours && !shareMaxViews && (
<p className="text-xs text-amber-400"> Public shares require at least one expiry condition.</p>
)}
{shareMut.isError && (
<p className="text-xs text-red-400">{shareMut.error instanceof Error ? shareMut.error.message : 'Failed to create share'}</p>
)}
<div className="flex gap-2">
<button
onClick={() => shareMut.mutate()}
disabled={shareMut.isPending || (shareIsPublic && !shareExpiryHours && !shareMaxViews)}
className="text-xs px-2 py-1 rounded-lg bg-indigo-500/20 text-indigo-300 hover:bg-indigo-500/30 disabled:opacity-40 transition-colors"
>
{shareMut.isPending ? 'Creating…' : 'Create Link'}
</button>
<button
onClick={() => { setShowSharePanel(false); shareMut.reset() }}
className="text-xs px-2 py-1 rounded-lg bg-white/10 hover:bg-white/15 text-gray-400 hover:text-gray-300 transition-colors"
>
Cancel
</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"
onClick={() => setShowSharePanel(true)}
className="text-xs px-2 py-1 rounded-lg bg-white/10 hover:bg-white/15 text-gray-400 hover:text-gray-300 transition-colors"
>
{shareMut.isPending ? 'Creating link…' : 'Share'}
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"
className="text-xs px-2 py-1 rounded-lg bg-white/10 hover:bg-white/15 text-gray-400 hover:text-gray-300 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">
<p className="text-xs text-amber-400">
Note: workflow template is not saved load it separately before using this preset.
</p>
<div className="flex gap-2 flex-wrap">
@@ -343,33 +738,33 @@ function ExpandedRow({
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"
className="glass-input flex-1 min-w-0 py-1 text-xs"
/>
<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"
className="glass-input flex-1 min-w-0 py-1 text-xs"
/>
</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"
className="btn-primary py-1 px-2 text-xs disabled:opacity-50"
>
{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"
className="text-xs px-2 py-1 rounded-lg bg-white/10 hover:bg-white/15 text-gray-400 hover:text-gray-300 transition-colors"
>
Cancel
</button>
</div>
{presetMsg && (
<p className={`text-xs ${presetMsg.ok ? 'text-green-600 dark:text-green-400' : 'text-red-500'}`}>
<p className={`text-xs ${presetMsg.ok ? 'text-green-400' : 'text-red-400'}`}>
{presetMsg.text}
</p>
)}

View File

@@ -10,9 +10,120 @@ import {
getInputMid,
getWorkflowInputs,
getState,
listPersons,
InputImage,
PendingFace,
} from '../api/client'
import LazyImage from '../components/LazyImage'
import FaceIdentifyModal from '../components/FaceIdentifyModal'
import { X } from 'lucide-react'
/** Chip-based multi-person tag input with a filterable combobox dropdown. */
function PersonTagInput({
selected,
onChange,
allPersons,
}: {
selected: string[]
onChange: (persons: string[]) => void
allPersons: Array<{ id: number; name: string }>
}) {
const [inputVal, setInputVal] = useState('')
const [showDropdown, setShowDropdown] = useState(false)
const available = allPersons.filter(
p => !selected.includes(p.name) &&
(!inputVal || p.name.toLowerCase().includes(inputVal.toLowerCase()))
)
const add = (name: string) => {
if (!selected.includes(name)) onChange([...selected, name])
setInputVal('')
setShowDropdown(false)
}
const remove = (name: string) => onChange(selected.filter(p => p !== name))
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && available.length > 0) add(available[0].name)
if (e.key === 'Backspace' && !inputVal && selected.length > 0) {
remove(selected[selected.length - 1])
}
}
return (
<div className="space-y-1.5">
{selected.length > 0 && (
<div className="flex flex-wrap gap-1">
{selected.map(p => (
<span
key={p}
className="flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-indigo-500/20 text-indigo-300"
>
{p}
<button onClick={() => remove(p)} className="hover:text-white ml-0.5">
<X size={10} />
</button>
</span>
))}
</div>
)}
{allPersons.length > selected.length && (
<div className="relative">
<input
type="text"
value={inputVal}
onChange={e => setInputVal(e.target.value)}
onFocus={() => setShowDropdown(true)}
onBlur={() => setTimeout(() => setShowDropdown(false), 150)}
onKeyDown={handleKeyDown}
placeholder={selected.length === 0 ? 'Filter by person…' : 'Add person…'}
className="glass-input w-full text-sm"
/>
{showDropdown && available.length > 0 && (
<div className="absolute z-20 top-full mt-1 w-full bg-gray-900/95 backdrop-blur-xl border border-white/10 rounded-2xl p-1 max-h-48 overflow-y-auto shadow-xl">
{available.map(p => (
<button
key={p.id}
onMouseDown={() => add(p.name)}
className="w-full text-left text-xs px-3 py-1.5 rounded-lg hover:bg-white/10 text-gray-300 transition-colors"
>
{p.name}
</button>
))}
</div>
)}
</div>
)}
</div>
)
}
/** Return only the selected persons that appear in the image's detected_persons array. */
function getImgComboKey(img: InputImage, selected: string[]): string {
const matched = selected.filter(p => img.detected_persons?.includes(p))
return matched.join('|')
}
interface ImgSection { key: string; combo: string[]; images: InputImage[] }
/** Group images by their exclusive combination of selected persons (ascending combo size). */
function groupImagesByCombo(images: InputImage[], selected: string[]): ImgSection[] {
const groups = new Map<string, InputImage[]>()
for (const img of images) {
const key = getImgComboKey(img, selected)
if (!key) continue
if (!groups.has(key)) groups.set(key, [])
groups.get(key)!.push(img)
}
return Array.from(groups.entries())
.map(([key, imgs]) => ({ key, combo: key.split('|'), images: imgs }))
.sort((a, b) =>
a.combo.length !== b.combo.length
? a.combo.length - b.combo.length
: a.key.localeCompare(b.key)
)
}
export default function InputImagesPage() {
const qc = useQueryClient()
@@ -20,8 +131,16 @@ export default function InputImagesPage() {
const [uploading, setUploading] = useState(false)
const [uploadSlot, setUploadSlot] = useState<string | null>(null)
const [lightbox, setLightbox] = useState<string | null>(null)
const [pendingFaces, setPendingFaces] = useState<{ faces: PendingFace[]; inputId: number } | null>(null)
const [selectedPersons, setSelectedPersons] = useState<string[]>([])
const { data: images = [], isLoading } = useQuery({ queryKey: ['inputs'], queryFn: listInputs })
const { data: personsData } = useQuery({ queryKey: ['faces', 'persons'], queryFn: listPersons })
const persons = personsData?.persons ?? []
const { data: images = [], isLoading } = useQuery({
queryKey: ['inputs', selectedPersons],
queryFn: () => listInputs(selectedPersons.length ? selectedPersons : undefined),
})
const { data: inputsData } = useQuery({ queryKey: ['workflow', 'inputs'], queryFn: getWorkflowInputs })
const { data: stateData } = useQuery({ queryKey: ['state'], queryFn: getState })
@@ -47,10 +166,13 @@ export default function InputImagesPage() {
setUploading(true)
setUploadSlot(slotKey)
try {
await uploadInput(fileRef.current.files[0], slotKey)
const result = await uploadInput(fileRef.current.files[0], slotKey)
qc.invalidateQueries({ queryKey: ['inputs'] })
qc.invalidateQueries({ queryKey: ['state'] })
if (fileRef.current) fileRef.current.value = ''
if (result?.pending_faces?.length > 0) {
setPendingFaces({ faces: result.pending_faces, inputId: result.id })
}
} finally {
setUploading(false)
setUploadSlot(null)
@@ -62,26 +184,90 @@ export default function InputImagesPage() {
return String(st?.[slotKey] ?? '')
}
const isPersonFiltered = selectedPersons.length > 0
const grouped: ImgSection[] | null =
selectedPersons.length >= 2 ? groupImagesByCombo(images, selectedPersons) : null
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>
<h1 className="text-2xl font-bold tracking-tight">
<span className="bg-gradient-to-r from-indigo-400 to-violet-400 bg-clip-text text-transparent">
Input Images
</span>
</h1>
{/* Per-slot sections */}
{imageSlots.length > 0 ? (
{/* Person filter */}
{persons.length > 0 && (
<div className="glass-card p-3 max-w-sm relative z-30">
<PersonTagInput
selected={selectedPersons}
onChange={setSelectedPersons}
allPersons={persons}
/>
</div>
)}
{/* Grouped sections: 2+ selected persons */}
{grouped ? (
<div className="space-y-6">
{grouped.length === 0 ? (
<p className="text-sm text-gray-400">No images match the selected persons.</p>
) : (
grouped.map(section => (
<section key={section.key} className="space-y-2">
<div className="glass-card p-3">
<h2 className="text-sm font-semibold text-indigo-300">
{section.combo.join(' & ')}
<span className="ml-2 text-xs text-gray-400">{section.images.length} image(s)</span>
</h2>
</div>
<ImageGrid
images={section.images}
activeFilename=""
slotKey="input_image"
onActivate={(id) => activateMut.mutate({ id, slotKey: 'input_image' })}
onDelete={(id) => deleteMut.mutate(id)}
onLightbox={setLightbox}
/>
</section>
))
)}
</div>
) : isPersonFiltered ? (
/* Single-person filter: flat section */
<section className="glass-card p-4 space-y-3">
<p className="text-sm text-gray-500 dark:text-gray-400">
Filtered by: <strong className="text-gray-800 dark:text-gray-100">{selectedPersons[0]}</strong>
</p>
{images.length === 0 ? (
<p className="text-sm text-gray-400">No images found for this person.</p>
) : (
<ImageGrid
images={images}
activeFilename=""
slotKey="input_image"
onActivate={(id) => activateMut.mutate({ id, slotKey: 'input_image' })}
onDelete={(id) => deleteMut.mutate(id)}
onLightbox={setLightbox}
/>
)}
</section>
) : imageSlots.length > 0 ? (
/* Per-slot sections */
imageSlots.map(slot => {
const activeFilename = activeForSlot(slot.key)
return (
<section key={slot.key} className="space-y-2">
<section key={slot.key} className="glass-card p-4 space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
<h2 className="text-sm font-semibold text-gray-700 dark:text-gray-200">
{slot.label}
{activeFilename && (
<span className="ml-2 text-xs font-normal text-blue-500">(active: {activeFilename})</span>
<span className="ml-2 text-xs font-normal text-indigo-400">(active: {activeFilename})</span>
)}
</h2>
<label className="cursor-pointer text-xs bg-blue-600 hover:bg-blue-700 text-white rounded px-2 py-1">
<label className="cursor-pointer btn-primary py-1 px-2.5 text-xs">
{uploading && uploadSlot === slot.key ? 'Uploading…' : 'Upload'}
<input
ref={fileRef}
@@ -104,11 +290,11 @@ export default function InputImagesPage() {
)
})
) : (
/* No workflow loaded — show flat list */
<section className="space-y-2">
/* No slots defined */
<section className="glass-card p-4 space-y-3">
<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">
<h2 className="text-sm font-semibold text-gray-700 dark:text-gray-200">All images</h2>
<label className="cursor-pointer btn-primary py-1 px-2.5 text-xs">
{uploading ? 'Uploading…' : 'Upload'}
<input
ref={fileRef}
@@ -130,7 +316,7 @@ export default function InputImagesPage() {
</section>
)}
{images.length === 0 && (
{images.length === 0 && !isPersonFiltered && (
<p className="text-sm text-gray-400">No images yet. Upload one to get started.</p>
)}
@@ -141,15 +327,24 @@ export default function InputImagesPage() {
onClick={() => setLightbox(null)}
>
<button
className="absolute top-3 right-3 text-white text-2xl leading-none hover:text-gray-300"
className="absolute top-3 right-3 text-white hover:text-gray-300 transition-colors"
onClick={() => setLightbox(null)}
aria-label="Close"
>
<X size={24} />
</button>
<img src={lightbox} alt="preview" className="max-w-[90vw] max-h-[90vh] object-contain rounded" />
<img src={lightbox} alt="preview" className="max-w-[90vw] max-h-[90vh] object-contain rounded-2xl shadow-2xl" />
</div>
)}
{/* Face identification modal */}
{pendingFaces && (
<FaceIdentifyModal
pendingFaces={pendingFaces.faces}
inputId={pendingFaces.inputId}
onDone={() => setPendingFaces(null)}
/>
)}
</div>
)
}
@@ -177,8 +372,10 @@ function ImageGrid({
return (
<div
key={img.id}
className={`relative group aspect-square rounded border-2 overflow-hidden cursor-pointer ${
isActive ? 'border-blue-500' : 'border-transparent'
className={`relative group aspect-square rounded-xl border-2 overflow-hidden cursor-pointer transition-all ${
isActive
? 'border-indigo-400 shadow-[0_0_12px_rgba(99,102,241,0.5)]'
: 'border-transparent'
}`}
>
<LazyImage
@@ -190,20 +387,41 @@ function ImageGrid({
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 top-0.5 left-0.5 bg-indigo-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">
{/* Desktop hover overlay */}
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity hidden [@media(hover:hover)]: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"
className="text-[10px] bg-indigo-600 text-white rounded-lg px-1.5 py-0.5 hover:bg-indigo-500"
>
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"
className="text-[10px] bg-red-600 text-white rounded-lg px-1.5 py-0.5 hover:bg-red-500"
>
Delete
</button>
</div>
{/* Mobile bottom strip */}
<div className="absolute bottom-0 inset-x-0 bg-black/70 flex items-center justify-center gap-1 py-0.5 [@media(hover:hover)]:hidden">
{!isActive && (
<button
onClick={() => onActivate(img.id)}
className="text-[10px] bg-indigo-600 text-white rounded-lg px-1.5 py-0.5 active:bg-indigo-500"
>
Activate
</button>
)}
<button
onClick={() => onDelete(img.id)}
className="text-[10px] bg-red-600 text-white rounded-lg px-1.5 py-0.5 active:bg-red-500"
>
Delete
</button>

View File

@@ -38,37 +38,46 @@ export default function LoginPage() {
}
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>
<div className="min-h-screen flex items-center justify-center px-4">
<div className="glass-card p-8 w-full max-w-sm space-y-6">
<div className="text-center">
<h1 className="text-2xl font-bold tracking-tight">
<span className="bg-gradient-to-r from-indigo-400 to-violet-400 bg-clip-text text-transparent">
ComfyUI Bot
</span>
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{isAdmin ? 'Admin login' : 'Sign in with your invite token'}
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
{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"
className="glass-input w-full"
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"
>
{error && (
<p className="text-red-400 text-sm bg-red-500/10 border border-red-400/20 rounded-xl px-3 py-2">
{error}
</p>
)}
<button type="submit" disabled={loading} className="btn-primary w-full justify-center">
{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"
className="w-full text-center text-xs text-gray-400 hover:text-indigo-400 transition-colors"
>
{isAdmin ? 'Use invite token instead' : 'Admin login'}
{isAdmin ? 'Use invite token instead' : 'Admin login'}
</button>
</div>
</div>

View File

@@ -50,28 +50,35 @@ export default function PresetsPage() {
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>
<h1 className="text-2xl font-bold tracking-tight">
<span className="bg-gradient-to-r from-indigo-400 to-violet-400 bg-clip-text text-transparent">
Presets
</span>
</h1>
{message && (
<div className="text-sm text-blue-600 dark:text-blue-400">{message}</div>
<div className="text-sm text-indigo-400 dark:text-indigo-300">{message}</div>
)}
{/* Save current state */}
<div className="space-y-2">
<div className="glass-card p-4 space-y-3">
<p className="text-xs font-semibold uppercase tracking-widest text-indigo-400/80 dark:text-indigo-300/80">
Save current state
</p>
<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"
className="glass-input flex-1"
/>
<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"
className="btn-primary"
>
Save current state
Save
</button>
</div>
<input
@@ -79,10 +86,10 @@ export default function PresetsPage() {
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"
className="glass-input w-full"
/>
{savingError && <p className="text-red-400 text-sm">{savingError}</p>}
</div>
{savingError && <p className="text-red-500 text-sm">{savingError}</p>}
{/* Preset list */}
{isLoading ? (
@@ -90,32 +97,30 @@ export default function PresetsPage() {
) : (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">
<div className="space-y-2">
{(data?.presets ?? []).map((preset: PresetMeta) => (
<li key={preset.name} className="px-3 py-2 space-y-1">
<div key={preset.name} className="glass-card p-3 space-y-2">
<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"
className="text-sm font-medium text-gray-700 dark:text-gray-200 hover:text-indigo-400 dark:hover:text-indigo-400 transition-colors text-left"
>
{preset.name}
{preset.owner && (
<span className="ml-2 text-xs text-gray-400 dark:text-gray-500 font-normal">
{preset.owner}
</span>
<span className="ml-2 text-xs text-gray-400 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"
className="btn-primary py-1 px-2.5 text-xs disabled:opacity-50"
>
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"
className="btn-danger"
>
Delete
</button>
@@ -127,9 +132,9 @@ export default function PresetsPage() {
{expanded === preset.name && presetDetail && (
<PresetDetail data={presetDetail} />
)}
</li>
</div>
))}
</ul>
</div>
)}
</div>
)
@@ -141,11 +146,11 @@ function PresetDetail({ data }: { data: Record<string, unknown> }) {
const hasOther = Object.keys(otherOverrides).length > 0
return (
<div className="mt-2 space-y-2 text-xs">
<div className="mt-2 space-y-2 text-xs border-t border-white/10 dark:border-white/5 pt-2">
{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">
<p className="mt-0.5 bg-white/10 dark:bg-white/5 rounded-xl p-2 text-gray-700 dark:text-gray-300 whitespace-pre-wrap break-words">
{String(prompt)}
</p>
</div>
@@ -153,7 +158,7 @@ function PresetDetail({ data }: { data: Record<string, unknown> }) {
{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">
<p className="mt-0.5 bg-white/10 dark:bg-white/5 rounded-xl p-2 text-gray-700 dark:text-gray-300 whitespace-pre-wrap break-words">
{String(negative_prompt)}
</p>
</div>
@@ -173,7 +178,7 @@ function PresetDetail({ data }: { data: Record<string, unknown> }) {
<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">
<tr key={k} className="border-b border-white/5 dark:border-white/5">
<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>
@@ -183,11 +188,11 @@ function PresetDetail({ data }: { data: Record<string, unknown> }) {
</div>
)}
{!!data.workflow && (
<div className="text-green-600 dark:text-green-400">Includes workflow template</div>
<div className="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">
<summary className="cursor-pointer text-gray-400 hover:text-gray-300">Raw JSON</summary>
<pre className="mt-1 text-xs bg-white/5 rounded-xl p-2 overflow-auto max-h-48 text-gray-400">
{JSON.stringify(data, null, 2)}
</pre>
</details>

View File

@@ -20,7 +20,6 @@ export default function ServerPage() {
refetchInterval: 2000,
})
// Auto-scroll log to bottom
useEffect(() => {
if (logRef.current) {
logRef.current.scrollTop = logRef.current.scrollHeight
@@ -37,22 +36,30 @@ export default function ServerPage() {
})
const stateColor = srv?.service_state === 'SERVICE_RUNNING'
? 'text-green-600 dark:text-green-400'
? 'text-green-400'
: srv?.service_state === 'SERVICE_STOPPED'
? 'text-red-500 dark:text-red-400'
: 'text-yellow-500'
? 'text-red-400'
: 'text-yellow-400'
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>
<h1 className="text-2xl font-bold tracking-tight">
<span className="bg-gradient-to-r from-indigo-400 to-violet-400 bg-clip-text text-transparent">
Server
</span>
</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">
<div className="glass-card p-4 text-sm space-y-2">
<p className="text-xs font-semibold uppercase tracking-widest text-indigo-400/80 dark:text-indigo-300/80 mb-3">
Service status
</p>
<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'}>
HTTP:{' '}
<span className={srv?.http_reachable ? 'text-green-400' : 'text-red-400'}>
{srv == null ? '—' : srv.http_reachable ? '✅ reachable' : '❌ unreachable'}
</span>
</p>
@@ -65,32 +72,36 @@ export default function ServerPage() {
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'
className={`text-sm rounded-xl px-4 py-2 font-semibold transition-all duration-200 disabled:opacity-50 ${
a === 'stop'
? 'bg-red-600/80 hover:bg-red-600 text-white'
: a === 'restart'
? 'bg-yellow-500/80 hover:bg-yellow-500 text-white'
: 'bg-green-600/80 hover:bg-green-600 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>}
{actionMsg && <p className="text-sm text-indigo-400">{actionMsg}</p>}
{/* Log tail */}
<div className="space-y-1">
<div className="glass-card p-4 space-y-2">
<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>
<p className="text-xs font-semibold uppercase tracking-widest text-indigo-400/80 dark:text-indigo-300/80">
Log tail (last 200 lines)
</p>
<button
onClick={() => refetchLogs()}
className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
className="text-xs text-gray-400 hover:text-gray-300 transition-colors"
>
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"
className="bg-black/40 text-gray-100 text-xs rounded-xl p-3 h-72 overflow-y-auto whitespace-pre-wrap font-mono"
>
{(logsData?.lines ?? []).join('\n') || 'No log lines available.'}
</pre>

View File

@@ -4,10 +4,6 @@ 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 }>
}
@@ -29,7 +25,7 @@ export default function SharePage() {
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900">
<div className="min-h-screen flex items-center justify-center">
<p className="text-sm text-gray-400">Loading</p>
</div>
)
@@ -39,13 +35,12 @@ export default function SharePage() {
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"
>
<div className="min-h-screen flex items-center justify-center px-4">
<div className="glass-card p-8 w-full max-w-sm text-center space-y-4">
<p className="text-gray-700 dark:text-gray-300">
You need to log in to view this page.
</p>
<Link to={`/login?redirect=/share/${token}`} className="btn-primary inline-block">
Log in
</Link>
</div>
@@ -55,64 +50,39 @@ export default function SharePage() {
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 className="min-h-screen flex items-center justify-center px-4">
<div className="glass-card p-8 w-full max-w-sm text-center">
<p className="text-gray-700 dark:text-gray-300">
This share link has expired, 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 className="min-h-screen flex items-center justify-center py-8 px-4">
<div className="max-w-3xl w-full flex flex-col items-center gap-4">
{(data?.images ?? []).map((img, i) => {
if (img.mime_type.startsWith('video/')) {
return (
<video
key={i}
src={getShareFileUrl(token!, img.filename)}
controls
className="rounded-xl max-w-full shadow-lg"
/>
)
}
return (
<img
key={i}
src={`data:${img.mime_type};base64,${img.data}`}
alt=""
className="rounded-xl max-w-full object-contain shadow-lg"
/>
)
})}
</div>
</div>
)

View File

@@ -1,45 +1,147 @@
import React from 'react'
import React, { useState, useEffect } from 'react'
import { useAuth } from '../hooks/useAuth'
import { useStatus } from '../hooks/useStatus'
import { useGeneration } from '../context/GenerationContext'
export default function StatusPage() {
const { user } = useAuth()
const { status, executingNode } = useStatus({ enabled: !!user })
const { status, executingNode, connected, lastUpdatedAt } = useStatus({ enabled: !!user })
const { pendingCount } = useGeneration()
const { bot, comfy, service, upload } = status
const [secondsAgo, setSecondsAgo] = useState<number | null>(null)
useEffect(() => {
const t = setInterval(() => {
if (lastUpdatedAt !== null) {
setSecondsAgo(Math.floor((Date.now() - lastUpdatedAt) / 1000))
}
}, 1000)
return () => clearInterval(t)
}, [lastUpdatedAt])
const isGenerating = executingNode !== null || pendingCount > 0
const queueTotal = (comfy?.queue_running ?? 0) + (comfy?.queue_pending ?? 0)
const latencyColor =
!bot ? 'text-gray-400' :
bot.latency_ms < 200 ? 'text-green-400' :
bot.latency_ms < 500 ? 'text-yellow-400' : 'text-red-400'
const comfyReachable = service?.http_reachable
const promptRaw = status.overrides?.prompt ? String(status.overrides.prompt) : null
const promptPreview = promptRaw
? promptRaw.slice(0, 80) + (promptRaw.length > 80 ? '…' : '')
: null
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="max-w-2xl mx-auto space-y-4">
<h1 className="text-2xl font-bold tracking-tight">
<span className="bg-gradient-to-r from-indigo-400 to-violet-400 bg-clip-text text-transparent">
Status
</span>
</h1>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Freshness bar */}
<div className="glass-card p-3 flex items-center justify-between">
<span className={`text-sm font-medium ${connected ? 'text-green-400' : 'text-amber-400'}`}>
{connected ? '● Connected' : '○ Reconnecting…'}
</span>
<span className="text-xs text-gray-500">
{secondsAgo !== null ? `Updated ${secondsAgo}s ago` : 'Waiting for data…'}
</span>
</div>
{/* Bot */}
<Card title="Bot">
<Row label="Latency" value={bot ? `${bot.latency_ms} ms` : '—'} />
<Row label="Uptime" value={bot?.uptime ?? '—'} />
</Card>
{/* Active generation card */}
{isGenerating && (
<div className="glass-card p-4 border-l-4 border-indigo-400">
<div className="flex items-center gap-2 mb-2">
<svg
className="w-4 h-4 text-indigo-400 animate-spin shrink-0"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span className="text-sm font-semibold text-indigo-300">Generating</span>
</div>
<p className="font-mono text-lg text-gray-200 break-all">
{executingNode ?? 'Waiting in queue'}
</p>
{pendingCount > 0 && (
<p className="text-xs text-gray-400 mt-1">{pendingCount} pending in queue</p>
)}
</div>
)}
{/* 3 big-number tiles */}
<div className="grid grid-cols-3 gap-3">
{/* Queue */}
<div className="glass-card p-4 text-center">
<p className="text-xs font-semibold uppercase tracking-widest text-gray-500 mb-2">Queue</p>
<p className={`text-4xl font-bold ${queueTotal > 0 ? 'text-indigo-400' : 'text-gray-500'}`}>
{queueTotal}
</p>
<p className="text-xs text-gray-500 mt-2">
{comfy?.queue_running ?? 0}r / {comfy?.queue_pending ?? 0}p
</p>
</div>
{/* Latency */}
<div className="glass-card p-4 text-center">
<p className="text-xs font-semibold uppercase tracking-widest text-gray-500 mb-2">Latency</p>
<p className={`text-4xl font-bold ${latencyColor}`}>
{bot ? bot.latency_ms : '—'}
</p>
<p className="text-xs text-gray-500 mt-2">ms · bot</p>
</div>
{/* 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) : '—'} />
<div className="glass-card p-4 text-center">
<p className="text-xs font-semibold uppercase tracking-widest text-gray-500 mb-2">ComfyUI</p>
{comfyReachable == null ? (
<p className="text-lg font-bold text-gray-500 mt-1"></p>
) : comfyReachable ? (
<p className="text-base font-bold text-green-400 mt-1"> reachable</p>
) : (
<p className="text-base font-bold text-red-400 mt-1"> unreachable</p>
)}
<p className="text-xs text-gray-500 mt-2 truncate">{comfy?.server ?? '—'}</p>
</div>
</div>
{/* Workflow context card */}
{(comfy != null || promptPreview) && (
<div className="glass-card p-4 space-y-2">
<p className="text-xs font-semibold uppercase tracking-widest text-indigo-400/80">Workflow</p>
<p className="text-sm text-gray-300">
{comfy?.workflow_loaded ? '✓ Workflow loaded' : '✗ No workflow loaded'}
</p>
{promptPreview && (
<p className="text-sm text-gray-400 font-mono break-all">
<span className="text-gray-500 mr-1">prompt:</span>
{promptPreview}
</p>
)}
</div>
)}
{/* Detail cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Card title="Bot">
<Row label="Uptime" value={bot?.uptime ?? '—'} />
<Row label="Total generated" value={String(comfy?.total_generated ?? 0)} />
<Row label="Last seed" value={comfy?.last_seed != null ? String(comfy.last_seed) : '—'} />
</Card>
{/* Service */}
<Card title="Service">
<Row label="State" value={service?.state ?? '—'} />
<Row label="Workflow" value={comfy?.workflow_loaded ? '✓ loaded' : '✗ not loaded'} />
<Row label="Server" value={comfy?.server ?? '—'} />
</Card>
{/* Auto-upload */}
<Card title="Auto-upload">
<Row label="Configured" value={upload?.configured ? '✓' : '✗'} />
<Row label="Running" value={upload?.running ? '⏳ yes' : 'idle'} />
@@ -47,21 +149,16 @@ export default function StatusPage() {
<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>
<div className="glass-card p-4">
<p className="text-xs font-semibold uppercase tracking-widest text-indigo-400/80 dark:text-indigo-300/80 mb-3">
{title}
</p>
<dl className="space-y-1">{children}</dl>
</div>
)

View File

@@ -60,15 +60,21 @@ export default function WorkflowPage() {
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>
<h1 className="text-2xl font-bold tracking-tight">
<span className="bg-gradient-to-r from-indigo-400 to-violet-400 bg-clip-text text-transparent">
Workflow
</span>
</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>
<div className="glass-card p-4 text-sm space-y-1">
<p className="text-xs font-semibold uppercase tracking-widest text-indigo-400/80 dark:text-indigo-300/80 mb-2">
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-600 dark:text-gray-400">{wf.last_workflow_file ?? '(loaded from state)'}</p>
<p className="text-gray-500 dark:text-gray-500">{wf.node_count} node(s) detected</p>
</>
) : (
<p className="text-gray-400">No workflow loaded</p>
@@ -76,12 +82,12 @@ export default function WorkflowPage() {
</div>
{message && (
<div className="text-sm text-blue-600 dark:text-blue-400">{message}</div>
<div className="text-sm text-indigo-400 dark:text-indigo-300">{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">
<label className="cursor-pointer btn-primary">
{uploading ? 'Uploading…' : 'Upload workflow JSON'}
<input
ref={fileRef}
@@ -91,58 +97,78 @@ export default function WorkflowPage() {
onChange={handleUpload}
/>
</label>
<span className="text-xs text-gray-400">Uploads to workflows/ folder</span>
<span className="text-xs text-gray-400">Saves 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' : ''}`}>
<p className="text-xs font-semibold uppercase tracking-widest text-indigo-400/80 dark:text-indigo-300/80">
Available workflows
</p>
<div className="glass-card overflow-hidden p-0">
{(filesData?.files ?? []).map((f, idx) => (
<div
key={f}
className={`flex items-center justify-between px-4 py-2.5 text-sm ${
idx < (filesData?.files ?? []).length - 1
? 'border-b border-white/10 dark:border-white/5'
: ''
}`}
>
<span className={`text-gray-700 dark:text-gray-300 ${
wf?.last_workflow_file === f ? 'font-semibold text-indigo-400' : ''
}`}>
{f}
{wf?.last_workflow_file === f && <span className="ml-1 text-xs">(active)</span>}
{wf?.last_workflow_file === f && (
<span className="ml-1.5 text-xs text-indigo-400/70">(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"
className="btn-secondary text-xs py-1 px-2 disabled:opacity-50"
>
{loadingFile === f ? 'Loading…' : 'Load'}
</button>
</li>
</div>
))}
</ul>
</div>
</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>
<p className="text-xs font-semibold uppercase tracking-widest text-indigo-400/80 dark:text-indigo-300/80">
Discovered inputs ({allInputs.length})
</p>
<div className="glass-card overflow-hidden p-0 overflow-x-auto">
<table className="w-full text-xs border-collapse">
<thead>
<tr className="bg-white/20 dark:bg-white/5">
<th className="text-left p-2.5 border-b border-white/10 dark:border-white/5 font-medium text-gray-600 dark:text-gray-400">Key</th>
<th className="text-left p-2.5 border-b border-white/10 dark:border-white/5 font-medium text-gray-600 dark:text-gray-400">Label</th>
<th className="text-left p-2.5 border-b border-white/10 dark:border-white/5 font-medium text-gray-600 dark:text-gray-400">Type</th>
<th className="text-left p-2.5 border-b border-white/10 dark:border-white/5 font-medium text-gray-600 dark:text-gray-400">Common</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{allInputs.map((inp, idx) => (
<tr
key={inp.key}
className={`hover:bg-white/10 dark:hover:bg-white/5 transition-colors ${
idx < allInputs.length - 1 ? 'border-b border-white/5 dark:border-white/5' : ''
}`}
>
<td className="p-2.5 font-mono text-gray-600 dark:text-gray-400">{inp.key}</td>
<td className="p-2.5 text-gray-700 dark:text-gray-300">{inp.label}</td>
<td className="p-2.5 text-gray-500 dark:text-gray-400">{inp.input_type}</td>
<td className="p-2.5 text-gray-500 dark:text-gray-400">{inp.is_common ? '✓' : ''}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}

View File

@@ -3,7 +3,22 @@ export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
darkMode: 'class',
theme: {
extend: {},
extend: {
animation: {
'fade-in': 'fade-in 0.25s ease-out',
'slide-up': 'slide-up 0.3s ease-out',
},
keyframes: {
'fade-in': {
from: { opacity: '0', transform: 'translateY(8px)' },
to: { opacity: '1', transform: 'translateY(0)' },
},
'slide-up': {
from: { opacity: '0', transform: 'translateY(16px)' },
to: { opacity: '1', transform: 'translateY(0)' },
},
},
},
},
plugins: [],
}