Initial commit — ComfyUI Discord bot + web UI
Full source for the-third-rev: Discord bot (discord.py), FastAPI web UI (React/TS/Vite/Tailwind), ComfyUI integration, generation history DB, preset manager, workflow inspector, and all supporting modules. Excluded from tracking: .env, invite_tokens.json, *.db (SQLite), current-workflow-changes.json, user_settings/, presets/, logs/, web-static/ (build output), frontend/node_modules/. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
382
frontend/src/pages/HistoryPage.tsx
Normal file
382
frontend/src/pages/HistoryPage.tsx
Normal file
@@ -0,0 +1,382 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { getHistory, createHistoryShare, revokeHistoryShare, savePresetFromHistory } from '../api/client'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
|
||||
interface HistoryRow {
|
||||
id: number
|
||||
prompt_id: string
|
||||
source: string
|
||||
user_label?: string
|
||||
overrides: Record<string, unknown>
|
||||
seed?: number
|
||||
file_paths?: string[]
|
||||
created_at: string
|
||||
share_token?: string | null
|
||||
}
|
||||
|
||||
/** Debounce a value by `delay` ms. */
|
||||
function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debounced, setDebounced] = useState(value)
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebounced(value), delay)
|
||||
return () => clearTimeout(t)
|
||||
}, [value, delay])
|
||||
return debounced
|
||||
}
|
||||
|
||||
export default function HistoryPage() {
|
||||
const [lightbox, setLightbox] = useState<string | null>(null)
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const [searchInput, setSearchInput] = useState('')
|
||||
const { user } = useAuth()
|
||||
|
||||
const debouncedQ = useDebounce(searchInput.trim(), 300)
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['history', debouncedQ],
|
||||
queryFn: () => getHistory(debouncedQ || undefined),
|
||||
refetchInterval: 10_000,
|
||||
})
|
||||
|
||||
const rows: HistoryRow[] = ((data?.history ?? []) as unknown as HistoryRow[])
|
||||
|
||||
const isSearching = searchInput.trim() !== debouncedQ
|
||||
|
||||
if (isLoading && !searchInput) return <div className="text-sm text-gray-400">Loading history…</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-xl font-bold text-gray-800 dark:text-gray-100">History</h1>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={searchInput}
|
||||
onChange={e => setSearchInput(e.target.value)}
|
||||
placeholder="Search by prompt, checkpoint, seed…"
|
||||
className="w-full border border-gray-300 dark:border-gray-600 rounded px-3 py-2 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
{searchInput && (
|
||||
<button
|
||||
onClick={() => setSearchInput('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-xs px-1"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isSearching && <p className="text-xs text-gray-400">Searching…</p>}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-gray-400">Loading…</div>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">
|
||||
{debouncedQ ? `No results for "${debouncedQ}".` : 'No generation history yet.'}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop table — hidden on mobile */}
|
||||
<div className="hidden sm:block overflow-x-auto">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-gray-400 border-b border-gray-200 dark:border-gray-700">
|
||||
<th className="pb-2 pr-4">Time</th>
|
||||
<th className="pb-2 pr-4">Source</th>
|
||||
<th className="pb-2 pr-4">User</th>
|
||||
<th className="pb-2 pr-4">Seed</th>
|
||||
<th className="pb-2">Files</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(row => (
|
||||
<React.Fragment key={row.prompt_id}>
|
||||
<tr
|
||||
className="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/50 cursor-pointer"
|
||||
onClick={() => setExpandedId(expandedId === row.prompt_id ? null : row.prompt_id)}
|
||||
>
|
||||
<td className="py-2 pr-4 text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||
{new Date(row.created_at).toLocaleString()}
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${row.source === 'web' ? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300' : 'bg-purple-100 dark:bg-purple-900 text-purple-700 dark:text-purple-300'}`}>
|
||||
{row.source}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-gray-600 dark:text-gray-400">{row.user_label ?? '—'}</td>
|
||||
<td className="py-2 pr-4 font-mono text-gray-700 dark:text-gray-300">{row.seed ?? '—'}</td>
|
||||
<td className="py-2 text-gray-500 dark:text-gray-400">{(row.file_paths ?? []).length} file(s)</td>
|
||||
</tr>
|
||||
{expandedId === row.prompt_id && (
|
||||
<tr className="bg-gray-50 dark:bg-gray-800/50">
|
||||
<td colSpan={5} className="px-3 py-3">
|
||||
<ExpandedRow row={row} onLightbox={setLightbox} currentUserLabel={user?.label ?? null} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile card list — hidden on sm+ */}
|
||||
<div className="sm:hidden space-y-2">
|
||||
{rows.map(row => {
|
||||
const isExpanded = expandedId === row.prompt_id
|
||||
const dt = new Date(row.created_at)
|
||||
const timeStr = dt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
const dateStr = dt.toLocaleDateString([], { month: 'short', day: 'numeric' })
|
||||
return (
|
||||
<div key={row.prompt_id} className="border border-gray-200 dark:border-gray-700 rounded bg-white dark:bg-gray-800">
|
||||
<button
|
||||
className="w-full text-left px-3 py-2 space-y-1"
|
||||
onClick={() => setExpandedId(isExpanded ? null : row.prompt_id)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">{timeStr} · {dateStr}</span>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${row.source === 'web' ? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300' : 'bg-purple-100 dark:bg-purple-900 text-purple-700 dark:text-purple-300'}`}>
|
||||
{row.source}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-gray-600 dark:text-gray-400">
|
||||
<span>User: {row.user_label ?? '—'}</span>
|
||||
<span>Seed: {row.seed ?? '—'}</span>
|
||||
<span>{(row.file_paths ?? []).length} file(s)</span>
|
||||
<span className="ml-auto text-gray-400">{isExpanded ? '▲' : '▼'}</span>
|
||||
</div>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3 border-t border-gray-100 dark:border-gray-700 pt-2">
|
||||
<ExpandedRow row={row} onLightbox={setLightbox} currentUserLabel={user?.label ?? null} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Lightbox */}
|
||||
{lightbox && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/80 flex items-center justify-center z-50"
|
||||
onClick={() => setLightbox(null)}
|
||||
>
|
||||
<button
|
||||
className="absolute top-3 right-3 text-white text-2xl leading-none hover:text-gray-300"
|
||||
onClick={() => setLightbox(null)}
|
||||
aria-label="Close"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<img src={lightbox} alt="preview" className="max-w-[90vw] max-h-[90vh] object-contain rounded" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ExpandedRow({
|
||||
row,
|
||||
onLightbox,
|
||||
currentUserLabel,
|
||||
}: {
|
||||
row: HistoryRow
|
||||
onLightbox: (url: string) => void
|
||||
currentUserLabel: string | null
|
||||
}) {
|
||||
const qc = useQueryClient()
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [showSavePreset, setShowSavePreset] = useState(false)
|
||||
const [presetName, setPresetName] = useState('')
|
||||
const [presetDesc, setPresetDesc] = useState('')
|
||||
const [presetMsg, setPresetMsg] = useState<{ ok: boolean; text: string } | null>(null)
|
||||
const presetNameRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const { data: imagesData, isLoading } = useQuery({
|
||||
queryKey: ['history', row.prompt_id, 'images'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/history/${row.prompt_id}/images`, { credentials: 'include' })
|
||||
if (!res.ok) throw new Error(`${res.status}`)
|
||||
return res.json() as Promise<{ images: Array<{ filename: string; data: string | null; mime_type: string }> }>
|
||||
},
|
||||
})
|
||||
|
||||
const shareMut = useMutation({
|
||||
mutationFn: () => createHistoryShare(row.prompt_id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['history'] }),
|
||||
})
|
||||
|
||||
const revokeMut = useMutation({
|
||||
mutationFn: () => revokeHistoryShare(row.prompt_id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['history'] }),
|
||||
})
|
||||
|
||||
const savePresetMut = useMutation({
|
||||
mutationFn: () => savePresetFromHistory(row.prompt_id, presetName.trim(), presetDesc.trim() || undefined),
|
||||
onSuccess: (res) => {
|
||||
qc.invalidateQueries({ queryKey: ['presets'] })
|
||||
setPresetMsg({ ok: true, text: `Saved as preset "${res.name}"` })
|
||||
setPresetName('')
|
||||
setPresetDesc('')
|
||||
},
|
||||
onError: (err) => {
|
||||
setPresetMsg({ ok: false, text: err instanceof Error ? err.message : String(err) })
|
||||
},
|
||||
})
|
||||
|
||||
const shareUrl = row.share_token
|
||||
? `${window.location.origin}/share/${row.share_token}`
|
||||
: null
|
||||
|
||||
const isOwner = currentUserLabel !== null && row.user_label === currentUserLabel
|
||||
|
||||
const handleCopy = () => {
|
||||
if (!shareUrl) return
|
||||
navigator.clipboard.writeText(shareUrl).then(() => {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Overrides */}
|
||||
<details>
|
||||
<summary className="text-xs text-gray-400 cursor-pointer">Overrides</summary>
|
||||
<pre className="text-xs bg-gray-100 dark:bg-gray-700 rounded p-2 mt-1 overflow-auto max-h-32 text-gray-600 dark:text-gray-400">
|
||||
{JSON.stringify(row.overrides, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
{/* Images */}
|
||||
{isLoading ? (
|
||||
<p className="text-xs text-gray-400">Loading images…</p>
|
||||
) : (imagesData?.images ?? []).length > 0 ? (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{(imagesData?.images ?? []).map((img, i) => {
|
||||
const isVideo = img.mime_type.startsWith('video/')
|
||||
if (isVideo) {
|
||||
const videoSrc = `/api/history/${row.prompt_id}/file/${encodeURIComponent(img.filename)}`
|
||||
return (
|
||||
<video
|
||||
key={i}
|
||||
src={videoSrc}
|
||||
controls
|
||||
className="rounded max-h-40"
|
||||
/>
|
||||
)
|
||||
}
|
||||
const src = `data:${img.mime_type};base64,${img.data}`
|
||||
return (
|
||||
<img
|
||||
key={i}
|
||||
src={src}
|
||||
alt={img.filename}
|
||||
className="rounded max-h-40 cursor-pointer border border-gray-200 dark:border-gray-700"
|
||||
onClick={() => onLightbox(src)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-400">Files not available (may have been moved or deleted).</p>
|
||||
)}
|
||||
|
||||
{/* Owner-only actions */}
|
||||
{isOwner && (
|
||||
<div className="pt-1 border-t border-gray-100 dark:border-gray-700 space-y-2">
|
||||
{/* Share section */}
|
||||
{shareUrl ? (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Share link</p>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<code className="text-xs bg-gray-100 dark:bg-gray-700 rounded px-2 py-1 text-gray-700 dark:text-gray-300 break-all select-all flex-1 min-w-0">
|
||||
{shareUrl}
|
||||
</code>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="shrink-0 text-xs px-2 py-1 rounded bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 hover:bg-blue-200 dark:hover:bg-blue-800 transition-colors"
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => revokeMut.mutate()}
|
||||
disabled={revokeMut.isPending}
|
||||
className="shrink-0 text-xs px-2 py-1 rounded bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300 hover:bg-red-200 dark:hover:bg-red-800 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{revokeMut.isPending ? 'Revoking…' : 'Revoke'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => shareMut.mutate()}
|
||||
disabled={shareMut.isPending}
|
||||
className="text-xs px-2 py-1 rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{shareMut.isPending ? 'Creating link…' : 'Share'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Save-as-preset section */}
|
||||
{!showSavePreset ? (
|
||||
<button
|
||||
onClick={() => { setShowSavePreset(true); setPresetMsg(null); setTimeout(() => presetNameRef.current?.focus(), 50) }}
|
||||
className="text-xs px-2 py-1 rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Save as preset
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Save overrides as preset</p>
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||
Note: workflow template is not saved — load it separately before using this preset.
|
||||
</p>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<input
|
||||
ref={presetNameRef}
|
||||
type="text"
|
||||
value={presetName}
|
||||
onChange={e => setPresetName(e.target.value)}
|
||||
placeholder="Preset name"
|
||||
className="flex-1 min-w-0 border border-gray-300 dark:border-gray-600 rounded px-2 py-1 text-xs bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={presetDesc}
|
||||
onChange={e => setPresetDesc(e.target.value)}
|
||||
placeholder="Description (optional)"
|
||||
className="flex-1 min-w-0 border border-gray-300 dark:border-gray-600 rounded px-2 py-1 text-xs bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => savePresetMut.mutate()}
|
||||
disabled={!presetName.trim() || savePresetMut.isPending}
|
||||
className="text-xs px-2 py-1 rounded bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white transition-colors"
|
||||
>
|
||||
{savePresetMut.isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowSavePreset(false); setPresetMsg(null); setPresetName(''); setPresetDesc('') }}
|
||||
className="text-xs px-2 py-1 rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{presetMsg && (
|
||||
<p className={`text-xs ${presetMsg.ok ? 'text-green-600 dark:text-green-400' : 'text-red-500'}`}>
|
||||
{presetMsg.text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user