manual submit

This commit is contained in:
Khoa (Revenovich) Tran Gia
2026-03-07 21:49:16 +07:00
parent 1748cbf8d2
commit 6004b000a7
39 changed files with 5794 additions and 614 deletions

View File

@@ -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>
)}