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:
1 parent
6ec5cf4b1b
commit
ba4d4a95fe
1 file changed
+64
-85
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user