manual submit
This commit is contained in:
59
frontend/package-lock.json
generated
59
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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 }) }
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
183
frontend/src/components/FaceIdentifyModal.tsx
Normal file
183
frontend/src/components/FaceIdentifyModal.tsx
Normal 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">
|
||||
⚠ “{fState.name.trim()}” 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 “{fState.name.trim()}”
|
||||
</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>
|
||||
)
|
||||
}
|
||||
21
frontend/src/components/GlassCard.tsx
Normal file
21
frontend/src/components/GlassCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
1240
frontend/src/pages/FacesPage.tsx
Normal file
1240
frontend/src/pages/FacesPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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: [],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user