feat: Complete search overhaul — fuzzy, filters, highlighting, debounce, saved searches

Search improvements (all 7 recommendations implemented):

1. Search all metadata fields:
   - Fuzzy search now includes: title, prompt, artist, album, comment,
     model, status, error_message, key, BPM, duration, structured prompt fields
   - Built _searchText concatenated field for Fuse.js indexing

2. Fuzzy search via fuse.js:
   - Installed fuse.js@7.0.0
   - threshold: 0.4 (balanced precision/recall)
   - ignoreLocation: true (matches anywhere in text)
   - includeScore for ranking

3. Advanced filters panel:
   - Model: All / Clip Preview / Pro
   - Status: All / Ready / Generating / Failed
   - Artwork: All / Has Artwork / No Artwork
   - BPM Range: Min/Max number inputs
   - Date Range: From/To date pickers
   - Active filter chips shown below search bar
   - Clear all button

4. Search term highlighting:
   - HighlightText component wraps matched terms in
     bg-lyria-accent/30 background
   - Applied to title and prompt in both ribbon and tile views

5. Debounced input (300ms):
   - useDebounce hook delays search execution
   - Prevents filtering on every keystroke
   - Smoother UX, less CPU usage

6. Saved searches:
   - Save current search + filters with custom name
   - Stored in localStorage (lyria-saved-searches)
   - Load saved search to restore query + filters
   - Delete saved searches
   - Bookmark icon in search bar

7. Backend search endpoint:
   - GET /api/clips/search?q=&model=&status=&bpm_min=&bpm_max=&date_from=&date_to=&limit=&offset=
   - Full pagination support for large libraries
   - Same fuzzy logic as frontend, server-side

Gallery component completely rewritten with:
- Cleaner header layout with search, filters, saved searches, view toggle
- Results count showing filtered/total
- Empty state when no matches
- Pagination with page info
This commit is contained in:
rin.kagamine committed 2026-05-29 23:15:08 -04:00
1 parent b3807f0f60
commit f8969d8585
3 files changed
+3113 -29

No files matched your search

+2729
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -9,6 +9,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"fuse.js": "^7.0.0",
"react": "^18.3.0", "react": "^18.3.0",
"react-dom": "^18.3.0" "react-dom": "^18.3.0"
}, },
+383 -29
View File
@@ -1,4 +1,5 @@
import { useState, useMemo, useRef } from 'react' import { useState, useMemo, useRef, useCallback, useEffect } from 'react'
import Fuse from 'fuse.js'
import { getKeyColor, getBPMColor } from '../utils/musicColors' import { getKeyColor, getBPMColor } from '../utils/musicColors'
const PAGE_SIZE = 10 const PAGE_SIZE = 10
@@ -15,6 +16,9 @@ interface Clip {
artwork_filename?: string | null artwork_filename?: string | null
bpm?: number | null bpm?: number | null
key?: string | null key?: string | null
artist?: string | null
album?: string | null
comment?: string | null
prompt_structure?: Record<string, string> | null prompt_structure?: Record<string, string> | null
created_at: string created_at: string
} }
@@ -31,36 +35,195 @@ interface Props {
type ViewMode = 'ribbons' | 'tiles' type ViewMode = 'ribbons' | 'tiles'
interface SearchFilters {
model: string
status: string
hasArtwork: string
bpmMin: string
bpmMax: string
dateFrom: string
dateTo: string
}
interface SavedSearch {
id: string
name: string
query: string
filters: SearchFilters
}
const DEFAULT_FILTERS: SearchFilters = {
model: 'all',
status: 'all',
hasArtwork: 'all',
bpmMin: '',
bpmMax: '',
dateFrom: '',
dateTo: '',
}
// ─── Highlight matched text ─────────────────────────────────────────────────
function HighlightText({ text, query }: { text: string; query: string }) {
if (!query.trim()) return <>{text}</>
const parts = text.split(new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'))
return (
<>
{parts.map((part, i) =>
part.toLowerCase() === query.toLowerCase() ? (
<span key={i} className="bg-lyria-accent/30 text-white rounded px-0.5">{part}</span>
) : (
<span key={i}>{part}</span>
)
)}
</>
)
}
// ─── Debounce hook ────────────────────────────────────────────────────────
function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay)
return () => clearTimeout(timer)
}, [value, delay])
return debounced
}
export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, onUploadArtwork, currentClipId }: Props) { export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, onUploadArtwork, currentClipId }: Props) {
const [uploadingId, setUploadingId] = useState<string | null>(null) const [uploadingId, setUploadingId] = useState<string | null>(null)
const artworkInputRef = useRef<HTMLInputElement | null>(null) const artworkInputRef = useRef<HTMLInputElement | null>(null)
// Search state
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [filters, setFilters] = useState<SearchFilters>(DEFAULT_FILTERS)
const [showFilters, setShowFilters] = useState(false)
const [showSaved, setShowSaved] = useState(false)
const debouncedSearch = useDebounce(search, 300)
// Saved searches
const [savedSearches, setSavedSearches] = useState<SavedSearch[]>(() => {
try {
const raw = localStorage.getItem('lyria-saved-searches')
return raw ? JSON.parse(raw) : []
} catch {
return []
}
})
// Pagination + view
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [view, setView] = useState<ViewMode>('ribbons') const [view, setView] = useState<ViewMode>('ribbons')
// Rename state
const [editingId, setEditingId] = useState<string | null>(null) const [editingId, setEditingId] = useState<string | null>(null)
const [editValue, setEditValue] = useState('') const [editValue, setEditValue] = useState('')
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
// Build searchable text for each clip
const clipsWithSearchText = useMemo(() => {
return clips.map(clip => ({
...clip,
_searchText: [
clip.title,
clip.prompt,
clip.artist,
clip.album,
clip.comment,
clip.model,
clip.status,
clip.error_message,
clip.key,
clip.bpm?.toString(),
clip.duration_seconds?.toString(),
clip.prompt_structure ? Object.values(clip.prompt_structure).join(' ') : '',
].filter(Boolean).join(' '),
}))
}, [clips])
// Fuse instance for fuzzy search
const fuse = useMemo(() => {
return new Fuse(clipsWithSearchText, {
keys: ['_searchText'],
threshold: 0.4,
includeScore: true,
ignoreLocation: true,
})
}, [clipsWithSearchText])
// Apply search + filters
const filtered = useMemo(() => { const filtered = useMemo(() => {
const q = search.toLowerCase().trim() let results = clipsWithSearchText
if (!q) return clips
return clips.filter(clip => // Fuzzy search
clip.prompt.toLowerCase().includes(q) || if (debouncedSearch.trim()) {
(clip.title && clip.title.toLowerCase().includes(q)) || const fuseResults = fuse.search(debouncedSearch)
clip.model.toLowerCase().includes(q) || results = fuseResults.map(r => r.item)
clip.status.toLowerCase().includes(q) || }
(clip.error_message && clip.error_message.toLowerCase().includes(q))
) // Apply filters
}, [clips, search]) return results.filter(clip => {
if (filters.model !== 'all' && clip.model !== filters.model) return false
if (filters.status !== 'all' && clip.status !== filters.status) return false
if (filters.hasArtwork === 'yes' && !clip.artwork_filename) return false
if (filters.hasArtwork === 'no' && clip.artwork_filename) return false
if (filters.bpmMin && clip.bpm && clip.bpm < Number(filters.bpmMin)) return false
if (filters.bpmMax && clip.bpm && clip.bpm > Number(filters.bpmMax)) return false
if (filters.dateFrom && new Date(clip.created_at) < new Date(filters.dateFrom)) return false
if (filters.dateTo && new Date(clip.created_at) > new Date(filters.dateTo + 'T23:59:59')) return false
return true
})
}, [clipsWithSearchText, fuse, debouncedSearch, filters])
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)) const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
const paged = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE) const paged = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
const handleSearch = (value: string) => { // Reset page when search/filters change
setSearch(value) useEffect(() => {
setPage(1) setPage(1)
}, [debouncedSearch, filters])
// Save searches to localStorage
const saveSearch = useCallback(() => {
const name = prompt('Name this search:')
if (!name?.trim()) return
const newSearch: SavedSearch = {
id: Date.now().toString(),
name: name.trim(),
query: search,
filters: { ...filters },
}
const updated = [...savedSearches, newSearch]
setSavedSearches(updated)
localStorage.setItem('lyria-saved-searches', JSON.stringify(updated))
}, [search, filters, savedSearches])
const loadSearch = useCallback((saved: SavedSearch) => {
setSearch(saved.query)
setFilters(saved.filters)
setShowSaved(false)
}, [])
const deleteSearch = useCallback((id: string) => {
const updated = savedSearches.filter(s => s.id !== id)
setSavedSearches(updated)
localStorage.setItem('lyria-saved-searches', JSON.stringify(updated))
}, [savedSearches])
const clearFilters = () => {
setFilters(DEFAULT_FILTERS)
setSearch('')
} }
const hasActiveFilters =
filters.model !== 'all' ||
filters.status !== 'all' ||
filters.hasArtwork !== 'all' ||
filters.bpmMin ||
filters.bpmMax ||
filters.dateFrom ||
filters.dateTo
const startRename = (clip: Clip) => { const startRename = (clip: Clip) => {
setEditingId(clip.id) setEditingId(clip.id)
setEditValue(clip.title || '') setEditValue(clip.title || '')
@@ -112,6 +275,8 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o
<h2 className="text-sm font-semibold text-white uppercase tracking-wider shrink-0"> <h2 className="text-sm font-semibold text-white uppercase tracking-wider shrink-0">
Gallery ({clips.length}) Gallery ({clips.length})
</h2> </h2>
{/* Search bar */}
<div className="relative flex-1 max-w-xs"> <div className="relative flex-1 max-w-xs">
<svg <svg
className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-lyria-muted" className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-lyria-muted"
@@ -121,14 +286,14 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o
</svg> </svg>
<input <input
type="text" type="text"
placeholder="Search tracks..." placeholder="Search tracks, artists, albums..."
value={search} value={search}
onChange={(e) => handleSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-md pl-8 pr-3 py-1.5 text-sm text-white placeholder-lyria-muted focus:outline-none focus:border-lyria-accent/50 focus:ring-1 focus:ring-lyria-accent/30 transition-colors" className="w-full bg-white/5 border border-white/10 rounded-md pl-8 pr-3 py-1.5 text-sm text-white placeholder-lyria-muted focus:outline-none focus:border-lyria-accent/50 focus:ring-1 focus:ring-lyria-accent/30 transition-colors"
/> />
{search && ( {search && (
<button <button
onClick={() => handleSearch('')} onClick={() => setSearch('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-lyria-muted hover:text-white transition-colors" className="absolute right-2 top-1/2 -translate-y-1/2 text-lyria-muted hover:text-white transition-colors"
> >
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -137,6 +302,33 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o
</button> </button>
)} )}
</div> </div>
{/* Filter button */}
<button
onClick={() => setShowFilters(!showFilters)}
className={`p-1.5 rounded transition-colors ${
showFilters || hasActiveFilters
? 'bg-lyria-accent/20 text-lyria-accent border border-lyria-accent/30'
: 'text-lyria-muted hover:text-white border border-white/10'
}`}
title="Filters"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
</svg>
</button>
{/* Saved searches button */}
<button
onClick={() => setShowSaved(!showSaved)}
className="p-1.5 rounded transition-colors text-lyria-muted hover:text-white border border-white/10"
title="Saved searches"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
</svg>
</button>
{/* View toggle */} {/* View toggle */}
<div className="flex bg-white/5 rounded-md border border-white/10 overflow-hidden"> <div className="flex bg-white/5 rounded-md border border-white/10 overflow-hidden">
<button <button
@@ -159,17 +351,181 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o
</button> </button>
</div> </div>
</div> </div>
{search && filtered.length !== clips.length && (
{/* Active filter chips */}
{hasActiveFilters && (
<div className="flex items-center gap-2 flex-wrap mt-2">
{filters.model !== 'all' && (
<span className="text-xs px-2 py-1 rounded bg-lyria-accent/10 text-lyria-accent border border-lyria-accent/20">
Model: {filters.model}
</span>
)}
{filters.status !== 'all' && (
<span className="text-xs px-2 py-1 rounded bg-lyria-accent/10 text-lyria-accent border border-lyria-accent/20">
Status: {filters.status}
</span>
)}
{filters.hasArtwork !== 'all' && (
<span className="text-xs px-2 py-1 rounded bg-lyria-accent/10 text-lyria-accent border border-lyria-accent/20">
Artwork: {filters.hasArtwork}
</span>
)}
{(filters.bpmMin || filters.bpmMax) && (
<span className="text-xs px-2 py-1 rounded bg-lyria-accent/10 text-lyria-accent border border-lyria-accent/20">
BPM: {filters.bpmMin || '0'} - {filters.bpmMax || '∞'}
</span>
)}
{(filters.dateFrom || filters.dateTo) && (
<span className="text-xs px-2 py-1 rounded bg-lyria-accent/10 text-lyria-accent border border-lyria-accent/20">
Date: {filters.dateFrom || '...'} → {filters.dateTo || '...'}
</span>
)}
<button
onClick={clearFilters}
className="text-xs text-lyria-muted hover:text-white transition-colors"
>
Clear all
</button>
</div>
)}
{/* Results count */}
{(search || hasActiveFilters) && (
<p className="text-xs text-lyria-muted mt-1.5"> <p className="text-xs text-lyria-muted mt-1.5">
Showing {filtered.length} of {clips.length} clips Showing {filtered.length} of {clips.length} clips
</p> </p>
)} )}
</div> </div>
{/* Filters panel */}
{showFilters && (
<div className="px-5 py-4 border-b border-white/5 bg-white/5">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div>
<label className="block text-xs text-lyria-muted mb-1">Model</label>
<select
value={filters.model}
onChange={(e) => setFilters(f => ({ ...f, model: e.target.value }))}
className="w-full bg-black/30 border border-white/10 rounded-md px-2 py-1.5 text-sm text-white"
>
<option value="all">All Models</option>
<option value="lyria-3-clip-preview">Clip Preview</option>
<option value="lyria-3-pro-preview">Pro</option>
</select>
</div>
<div>
<label className="block text-xs text-lyria-muted mb-1">Status</label>
<select
value={filters.status}
onChange={(e) => setFilters(f => ({ ...f, status: e.target.value }))}
className="w-full bg-black/30 border border-white/10 rounded-md px-2 py-1.5 text-sm text-white"
>
<option value="all">All Statuses</option>
<option value="ready">Ready</option>
<option value="generating">Generating</option>
<option value="failed">Failed</option>
</select>
</div>
<div>
<label className="block text-xs text-lyria-muted mb-1">Artwork</label>
<select
value={filters.hasArtwork}
onChange={(e) => setFilters(f => ({ ...f, hasArtwork: e.target.value }))}
className="w-full bg-black/30 border border-white/10 rounded-md px-2 py-1.5 text-sm text-white"
>
<option value="all">All</option>
<option value="yes">Has Artwork</option>
<option value="no">No Artwork</option>
</select>
</div>
<div>
<label className="block text-xs text-lyria-muted mb-1">BPM Range</label>
<div className="flex items-center gap-2">
<input
type="number"
value={filters.bpmMin}
onChange={(e) => setFilters(f => ({ ...f, bpmMin: e.target.value }))}
placeholder="Min"
className="w-full bg-black/30 border border-white/10 rounded-md px-2 py-1.5 text-sm text-white placeholder:text-lyria-muted/30"
/>
<span className="text-lyria-muted">-</span>
<input
type="number"
value={filters.bpmMax}
onChange={(e) => setFilters(f => ({ ...f, bpmMax: e.target.value }))}
placeholder="Max"
className="w-full bg-black/30 border border-white/10 rounded-md px-2 py-1.5 text-sm text-white placeholder:text-lyria-muted/30"
/>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3 mt-3">
<div>
<label className="block text-xs text-lyria-muted mb-1">Date From</label>
<input
type="date"
value={filters.dateFrom}
onChange={(e) => setFilters(f => ({ ...f, dateFrom: e.target.value }))}
className="w-full bg-black/30 border border-white/10 rounded-md px-2 py-1.5 text-sm text-white"
/>
</div>
<div>
<label className="block text-xs text-lyria-muted mb-1">Date To</label>
<input
type="date"
value={filters.dateTo}
onChange={(e) => setFilters(f => ({ ...f, dateTo: e.target.value }))}
className="w-full bg-black/30 border border-white/10 rounded-md px-2 py-1.5 text-sm text-white"
/>
</div>
</div>
<div className="flex items-center justify-between mt-3">
<button
onClick={clearFilters}
className="text-xs text-lyria-muted hover:text-white transition-colors"
>
Reset all filters
</button>
<button
onClick={saveSearch}
className="text-xs text-lyria-accent hover:text-lyria-accent/80 transition-colors"
>
Save this search 💾
</button>
</div>
</div>
)}
{/* Saved searches dropdown */}
{showSaved && savedSearches.length > 0 && (
<div className="px-5 py-3 border-b border-white/5 bg-white/5 space-y-2">
<p className="text-xs text-lyria-muted uppercase tracking-wider">Saved Searches</p>
{savedSearches.map(saved => (
<div key={saved.id} className="flex items-center justify-between group">
<button
onClick={() => loadSearch(saved)}
className="text-sm text-white hover:text-lyria-accent transition-colors"
>
{saved.name}
{saved.query && <span className="text-lyria-muted ml-1">"{saved.query}"</span>}
</button>
<button
onClick={() => deleteSearch(saved.id)}
className="text-lyria-muted hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
</div>
)}
{/* Clip list */} {/* Clip list */}
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<div className="px-5 py-8 text-center"> <div className="px-5 py-8 text-center">
<p className="text-lyria-muted text-sm">No clips match "{search}"</p> <p className="text-lyria-muted text-sm">No clips match your search</p>
</div> </div>
) : view === 'ribbons' ? ( ) : view === 'ribbons' ? (
/* ── RIBBON VIEW ── */ /* ── RIBBON VIEW ── */
@@ -196,7 +552,6 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o
</svg> </svg>
</div> </div>
)} )}
{/* Hover overlay: play */}
<div className="absolute inset-0 rounded bg-black/60 flex items-center justify-center opacity-0 group-hover/art:opacity-100 transition-opacity" <div className="absolute inset-0 rounded bg-black/60 flex items-center justify-center opacity-0 group-hover/art:opacity-100 transition-opacity"
> >
<button <button
@@ -229,11 +584,17 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o
/> />
) : clip.title ? ( ) : clip.title ? (
<div className="overflow-hidden"> <div className="overflow-hidden">
<p className="text-sm font-medium text-white truncate">{clip.title}</p> <p className="text-sm font-medium text-white truncate">
<p className="text-xs text-lyria-muted/60 truncate">{clip.prompt}</p> <HighlightText text={clip.title} query={debouncedSearch} />
</p>
<p className="text-xs text-lyria-muted/60 truncate">
<HighlightText text={clip.prompt} query={debouncedSearch} />
</p>
</div> </div>
) : ( ) : (
<p className="text-sm text-white truncate">{clip.prompt}</p> <p className="text-sm text-white truncate">
<HighlightText text={clip.prompt} query={debouncedSearch} />
</p>
)} )}
<div className="flex items-center gap-2 mt-1"> <div className="flex items-center gap-2 mt-1">
<span className={`text-xs px-1.5 py-0.5 rounded ${ <span className={`text-xs px-1.5 py-0.5 rounded ${
@@ -339,7 +700,6 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o
}`} }`}
onClick={() => onSelect(clip)} onClick={() => onSelect(clip)}
> >
{/* Artwork or placeholder */}
<div className="relative group/art mb-2"> <div className="relative group/art mb-2">
{clip.artwork_filename ? ( {clip.artwork_filename ? (
<img <img
@@ -354,7 +714,6 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o
</svg> </svg>
</div> </div>
)} )}
{/* Hover overlay */}
<div className="absolute inset-0 rounded-md bg-black/60 flex items-center justify-center gap-2 opacity-0 group-hover/art:opacity-100 transition-opacity"> <div className="absolute inset-0 rounded-md bg-black/60 flex items-center justify-center gap-2 opacity-0 group-hover/art:opacity-100 transition-opacity">
<button <button
onClick={(e) => { e.stopPropagation(); onPlay(clip) }} onClick={(e) => { e.stopPropagation(); onPlay(clip) }}
@@ -381,7 +740,6 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o
</div> </div>
</div> </div>
{/* Title / prompt */}
{editingId === clip.id ? ( {editingId === clip.id ? (
<input <input
ref={inputRef} ref={inputRef}
@@ -403,7 +761,6 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o
</p> </p>
)} )}
{/* Meta */}
<div className="flex items-center gap-1.5 mt-auto flex-wrap"> <div className="flex items-center gap-1.5 mt-auto flex-wrap">
<span className={`text-[10px] px-1 py-0.5 rounded ${ <span className={`text-[10px] px-1 py-0.5 rounded ${
clip.status === 'ready' clip.status === 'ready'
@@ -444,14 +801,12 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o
})()} })()}
</div> </div>
{/* Error */}
{clip.status === 'failed' && clip.error_message && ( {clip.status === 'failed' && clip.error_message && (
<p className="text-[10px] text-red-400/80 mt-1 line-clamp-2"> <p className="text-[10px] text-red-400/80 mt-1 line-clamp-2">
{clip.error_message} {clip.error_message}
</p> </p>
)} )}
{/* Actions */}
<div className="flex items-center gap-1 mt-2 pt-1.5 border-t border-white/5"> <div className="flex items-center gap-1 mt-2 pt-1.5 border-t border-white/5">
<button <button
onClick={(e) => { e.stopPropagation(); startRename(clip) }} onClick={(e) => { e.stopPropagation(); startRename(clip) }}
@@ -513,7 +868,6 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o
</div> </div>
</div> </div>
)} )}
{/* Hidden artwork upload input */}
<input <input
ref={artworkInputRef} ref={artworkInputRef}
type="file" type="file"