Files
comfy-discord-web/frontend/src/pages/HistoryPage.tsx
Khoa (Revenovich) Tran Gia 6004b000a7 manual submit
2026-03-07 21:49:16 +07:00

778 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
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
prompt_id: string
source: string
user_label?: string
overrides: Record<string, unknown>
seed?: number
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. */
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
}
/** 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 [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, 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
if (isLoading && !searchInput) return <div className="text-sm text-gray-400">Loading history</div>
return (
<div className="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">
History
</span>
</h1>
{/* 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>}
{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>
) : 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>
<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 */}
{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 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-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)
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 [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'],
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 { 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, {
is_public: shareIsPublic,
expires_in_hours: shareExpiryHours ?? undefined,
max_views: shareMaxViews ?? undefined,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['history'] })
setShowSharePanel(false)
},
})
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">
<details>
<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>
{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-xl max-h-40" />
}
const src = `data:${img.mime_type};base64,${img.data}`
return (
<img
key={i}
src={src}
alt={img.filename}
className="rounded-xl max-h-40 cursor-pointer shadow-md hover:shadow-lg transition-shadow"
onClick={() => onLightbox(src)}
/>
)
})}
</div>
) : (
<p className="text-xs text-gray-400">Files not available (may have been moved or deleted).</p>
)}
{(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-white/10 dark:border-white/5 space-y-2">
{shareUrl ? (
<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-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-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="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={() => 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"
>
Share
</button>
)}
{!showSavePreset ? (
<button
onClick={() => { setShowSavePreset(true); setPresetMsg(null); setTimeout(() => presetNameRef.current?.focus(), 50) }}
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-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="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="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="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-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-400' : 'text-red-400'}`}>
{presetMsg.text}
</p>
)}
</div>
)}
</div>
)}
</div>
)
}