feat: replace Saved Searches with Favorites (heart icon)

- Removed entire SavedSearch system (interface, state, localStorage, dropdown)
- Added favorites system: Set of clip IDs stored in localStorage under
  kithara-favorites
- Heart icon on each track (ribbon + tile view) toggles favorite
- Heart button in toolbar filters view to favorites only
- Filled heart when favorited, outline when not
- Empty state: 'No favorites yet. Heart a track to add it here!'
- Removed 'Save this search' button from filters panel
This commit is contained in:
rin.kagamine committed 2026-05-31 11:25:20 -04:00
1 parent 6ec5cf4b1b
commit ba4d4a95fe
1 file changed
+64 -85
+64 -85
View File
@@ -19,6 +19,7 @@ interface Clip {
key?: string | null
artist?: string | null
album?: string | null
genre?: string | null
comment?: string | null
prompt_structure?: Record<string, string> | null
created_at: string
@@ -46,13 +47,6 @@ interface SearchFilters {
dateTo: string
}
interface SavedSearch {
id: string
name: string
query: string
filters: SearchFilters
}
const DEFAULT_FILTERS: SearchFilters = {
model: 'all',
status: 'all',
@@ -101,18 +95,28 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o
const [search, setSearch] = useState('')
const [filters, setFilters] = useState<SearchFilters>(DEFAULT_FILTERS)
const [showFilters, setShowFilters] = useState(false)
const [showSaved, setShowSaved] = useState(false)
const [showFavorites, setShowFavorites] = useState(false)
const debouncedSearch = useDebounce(search, 300)
// Saved searches
const [savedSearches, setSavedSearches] = useState<SavedSearch[]>(() => {
// Favorites
const [favorites, setFavorites] = useState<Set<string>>(() => {
try {
const raw = localStorage.getItem('kithara-saved-searches')
return raw ? JSON.parse(raw) : []
const raw = localStorage.getItem('kithara-favorites')
return raw ? new Set(JSON.parse(raw)) : new Set()
} catch {
return []
return new Set()
}
})
const toggleFavorite = useCallback((id: string) => {
setFavorites(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
localStorage.setItem('kithara-favorites', JSON.stringify([...next]))
return next
})
}, [])
// Pagination + view
const [page, setPage] = useState(1)
@@ -132,6 +136,7 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o
clip.prompt,
clip.artist,
clip.album,
clip.genre,
clip.comment,
clip.model,
clip.status,
@@ -154,7 +159,7 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o
})
}, [clipsWithSearchText])
// Apply search + filters
// Apply search + filters + favorites
const filtered = useMemo(() => {
let results = clipsWithSearchText
@@ -174,9 +179,11 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o
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
// Favorites filter
if (showFavorites && !favorites.has(clip.id)) return false
return true
})
}, [clipsWithSearchText, fuse, debouncedSearch, filters])
}, [clipsWithSearchText, fuse, debouncedSearch, filters, showFavorites, favorites])
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
const paged = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
@@ -184,34 +191,7 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o
// 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('kithara-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('kithara-saved-searches', JSON.stringify(updated))
}, [savedSearches])
}, [debouncedSearch, filters, showFavorites])
const clearFilters = () => {
setFilters(DEFAULT_FILTERS)
@@ -326,14 +306,18 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o
</svg>
</button>
{/* Saved searches button */}
{/* Favorites button */}
<button
onClick={() => setShowSaved(!showSaved)}
className="p-1.5 rounded transition-colors text-kithara-muted hover:text-white border border-white/10"
title="Saved searches"
onClick={() => setShowFavorites(!showFavorites)}
className={`p-1.5 rounded transition-colors ${
showFavorites
? 'bg-red-500/20 text-red-400 border border-red-400/30'
: 'text-kithara-muted hover:text-white border border-white/10'
}`}
title={showFavorites ? 'Show all tracks' : 'Show favorites only'}
>
<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 className="w-4 h-4" fill={showFavorites ? 'currentColor' : 'none'} stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
</button>
@@ -398,9 +382,9 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o
)}
{/* Results count */}
{(search || hasActiveFilters) && (
{(search || hasActiveFilters || showFavorites) && (
<p className="text-xs text-kithara-muted mt-1.5">
Showing {filtered.length} of {clips.length} clips
{showFavorites ? `♥ ${filtered.length} of ${clips.length} clips` : `Showing ${filtered.length} of ${clips.length} clips`}
</p>
)}
</div>
@@ -494,46 +478,16 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o
>
Reset all filters
</button>
<button
onClick={saveSearch}
className="text-xs text-kithara-accent hover:text-kithara-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-kithara-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-kithara-accent transition-colors"
>
{saved.name}
{saved.query && <span className="text-kithara-muted ml-1">"{saved.query}"</span>}
</button>
<button
onClick={() => deleteSearch(saved.id)}
className="text-kithara-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-kithara-muted text-sm">No clips match your search</p>
<p className="text-kithara-muted text-sm">
{showFavorites ? 'No favorites yet. Heart a track to add it here!' : 'No clips match your search'}
</p>
</div>
) : view === 'ribbons' ? (
/* ── RIBBON VIEW ── */
@@ -546,6 +500,19 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o
currentClipId === clip.id ? 'bg-white/5' : ''
}`}
>
{/* Heart */}
<button
onClick={(e) => { e.stopPropagation(); toggleFavorite(clip.id) }}
className={`shrink-0 transition-colors ${
favorites.has(clip.id) ? 'text-red-400 hover:text-red-300' : 'text-kithara-muted/40 hover:text-red-400'
}`}
title={favorites.has(clip.id) ? 'Remove from favorites' : 'Add to favorites'}
>
<svg className="w-4 h-4" fill={favorites.has(clip.id) ? 'currentColor' : 'none'} stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
</button>
<div className="shrink-0 relative group/art">
{clip.artwork_filename ? (
<img
@@ -746,6 +713,18 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o
</svg>
</button>
</div>
{/* Heart on tile */}
<button
onClick={(e) => { e.stopPropagation(); toggleFavorite(clip.id) }}
className={`absolute top-1.5 right-1.5 transition-colors ${
favorites.has(clip.id) ? 'text-red-400 hover:text-red-300' : 'text-white/40 hover:text-red-400 opacity-0 group-hover/art:opacity-100'
}`}
title={favorites.has(clip.id) ? 'Remove from favorites' : 'Add to favorites'}
>
<svg className="w-5 h-5" fill={favorites.has(clip.id) ? 'currentColor' : 'none'} stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
</button>
</div>
{editingId === clip.id ? (
@@ -900,4 +879,4 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o
/>
</div>
)
}
}