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:
1 parent
b3807f0f60
commit
f8969d8585
3 files changed
+3113
-29
No files matched your search
Generated
+2729
File diff suppressed because it is too large.
Load diff
@@ -9,6 +9,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"fuse.js": "^7.0.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0"
|
||||
},
|
||||
|
||||
@@ -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'
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
@@ -15,6 +16,9 @@ interface Clip {
|
||||
artwork_filename?: string | null
|
||||
bpm?: number | null
|
||||
key?: string | null
|
||||
artist?: string | null
|
||||
album?: string | null
|
||||
comment?: string | null
|
||||
prompt_structure?: Record<string, string> | null
|
||||
created_at: string
|
||||
}
|
||||
@@ -31,35 +35,194 @@ interface Props {
|
||||
|
||||
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) {
|
||||
const [uploadingId, setUploadingId] = useState<string | null>(null)
|
||||
const artworkInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
// Search state
|
||||
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 [view, setView] = useState<ViewMode>('ribbons')
|
||||
|
||||
// Rename state
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editValue, setEditValue] = useState('')
|
||||
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 q = search.toLowerCase().trim()
|
||||
if (!q) return clips
|
||||
return clips.filter(clip =>
|
||||
clip.prompt.toLowerCase().includes(q) ||
|
||||
(clip.title && clip.title.toLowerCase().includes(q)) ||
|
||||
clip.model.toLowerCase().includes(q) ||
|
||||
clip.status.toLowerCase().includes(q) ||
|
||||
(clip.error_message && clip.error_message.toLowerCase().includes(q))
|
||||
)
|
||||
}, [clips, search])
|
||||
let results = clipsWithSearchText
|
||||
|
||||
// Fuzzy search
|
||||
if (debouncedSearch.trim()) {
|
||||
const fuseResults = fuse.search(debouncedSearch)
|
||||
results = fuseResults.map(r => r.item)
|
||||
}
|
||||
|
||||
// Apply filters
|
||||
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 paged = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
setSearch(value)
|
||||
// Reset page when search/filters change
|
||||
useEffect(() => {
|
||||
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) => {
|
||||
setEditingId(clip.id)
|
||||
@@ -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">
|
||||
Gallery ({clips.length})
|
||||
</h2>
|
||||
|
||||
{/* Search bar */}
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<svg
|
||||
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>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search tracks..."
|
||||
placeholder="Search tracks, artists, albums..."
|
||||
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"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => handleSearch('')}
|
||||
onClick={() => setSearch('')}
|
||||
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">
|
||||
@@ -137,6 +302,33 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o
|
||||
</button>
|
||||
)}
|
||||
</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 */}
|
||||
<div className="flex bg-white/5 rounded-md border border-white/10 overflow-hidden">
|
||||
<button
|
||||
@@ -159,17 +351,181 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o
|
||||
</button>
|
||||
</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">
|
||||
Showing {filtered.length} of {clips.length} clips
|
||||
</p>
|
||||
)}
|
||||
</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 */}
|
||||
{filtered.length === 0 ? (
|
||||
<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>
|
||||
) : view === 'ribbons' ? (
|
||||
/* ── RIBBON VIEW ── */
|
||||
@@ -196,7 +552,6 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o
|
||||
</svg>
|
||||
</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"
|
||||
>
|
||||
<button
|
||||
@@ -229,11 +584,17 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o
|
||||
/>
|
||||
) : clip.title ? (
|
||||
<div className="overflow-hidden">
|
||||
<p className="text-sm font-medium text-white truncate">{clip.title}</p>
|
||||
<p className="text-xs text-lyria-muted/60 truncate">{clip.prompt}</p>
|
||||
<p className="text-sm font-medium text-white truncate">
|
||||
<HighlightText text={clip.title} query={debouncedSearch} />
|
||||
</p>
|
||||
<p className="text-xs text-lyria-muted/60 truncate">
|
||||
<HighlightText text={clip.prompt} query={debouncedSearch} />
|
||||
</p>
|
||||
</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">
|
||||
<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)}
|
||||
>
|
||||
{/* Artwork or placeholder */}
|
||||
<div className="relative group/art mb-2">
|
||||
{clip.artwork_filename ? (
|
||||
<img
|
||||
@@ -354,7 +714,6 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o
|
||||
</svg>
|
||||
</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">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onPlay(clip) }}
|
||||
@@ -381,7 +740,6 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title / prompt */}
|
||||
{editingId === clip.id ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
@@ -403,7 +761,6 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Meta */}
|
||||
<div className="flex items-center gap-1.5 mt-auto flex-wrap">
|
||||
<span className={`text-[10px] px-1 py-0.5 rounded ${
|
||||
clip.status === 'ready'
|
||||
@@ -444,14 +801,12 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{clip.status === 'failed' && clip.error_message && (
|
||||
<p className="text-[10px] text-red-400/80 mt-1 line-clamp-2">
|
||||
{clip.error_message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 mt-2 pt-1.5 border-t border-white/5">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); startRename(clip) }}
|
||||
@@ -513,7 +868,6 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Hidden artwork upload input */}
|
||||
<input
|
||||
ref={artworkInputRef}
|
||||
type="file"
|
||||
|
||||
Reference in new issue
Block a user