manual submit
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user