778 lines
31 KiB
TypeScript
778 lines
31 KiB
TypeScript
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>
|
||
)
|
||
}
|