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
|
key?: string | null
|
||||||
artist?: string | null
|
artist?: string | null
|
||||||
album?: string | null
|
album?: string | null
|
||||||
|
genre?: string | null
|
||||||
comment?: string | null
|
comment?: string | null
|
||||||
prompt_structure?: Record<string, string> | null
|
prompt_structure?: Record<string, string> | null
|
||||||
created_at: string
|
created_at: string
|
||||||
@@ -46,13 +47,6 @@ interface SearchFilters {
|
|||||||
dateTo: string
|
dateTo: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SavedSearch {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
query: string
|
|
||||||
filters: SearchFilters
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_FILTERS: SearchFilters = {
|
const DEFAULT_FILTERS: SearchFilters = {
|
||||||
model: 'all',
|
model: 'all',
|
||||||
status: 'all',
|
status: 'all',
|
||||||
@@ -101,18 +95,28 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o
|
|||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [filters, setFilters] = useState<SearchFilters>(DEFAULT_FILTERS)
|
const [filters, setFilters] = useState<SearchFilters>(DEFAULT_FILTERS)
|
||||||
const [showFilters, setShowFilters] = useState(false)
|
const [showFilters, setShowFilters] = useState(false)
|
||||||
const [showSaved, setShowSaved] = useState(false)
|
const [showFavorites, setShowFavorites] = useState(false)
|
||||||
const debouncedSearch = useDebounce(search, 300)
|
const debouncedSearch = useDebounce(search, 300)
|
||||||
|
|
||||||
// Saved searches
|
// Favorites
|
||||||
const [savedSearches, setSavedSearches] = useState<SavedSearch[]>(() => {
|
const [favorites, setFavorites] = useState<Set<string>>(() => {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem('kithara-saved-searches')
|
const raw = localStorage.getItem('kithara-favorites')
|
||||||
return raw ? JSON.parse(raw) : []
|
return raw ? new Set(JSON.parse(raw)) : new Set()
|
||||||
} catch {
|
} 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
|
// Pagination + view
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
@@ -132,6 +136,7 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o
|
|||||||
clip.prompt,
|
clip.prompt,
|
||||||
clip.artist,
|
clip.artist,
|
||||||
clip.album,
|
clip.album,
|
||||||
|
clip.genre,
|
||||||
clip.comment,
|
clip.comment,
|
||||||
clip.model,
|
clip.model,
|
||||||
clip.status,
|
clip.status,
|
||||||
@@ -154,7 +159,7 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o
|
|||||||
})
|
})
|
||||||
}, [clipsWithSearchText])
|
}, [clipsWithSearchText])
|
||||||
|
|
||||||
// Apply search + filters
|
// Apply search + filters + favorites
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
let results = clipsWithSearchText
|
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.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.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
|
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
|
return true
|
||||||
})
|
})
|
||||||
}, [clipsWithSearchText, fuse, debouncedSearch, filters])
|
}, [clipsWithSearchText, fuse, debouncedSearch, filters, showFavorites, favorites])
|
||||||
|
|
||||||
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)
|
||||||
@@ -184,34 +191,7 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o
|
|||||||
// Reset page when search/filters change
|
// Reset page when search/filters change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPage(1)
|
setPage(1)
|
||||||
}, [debouncedSearch, filters])
|
}, [debouncedSearch, filters, showFavorites])
|
||||||
|
|
||||||
// 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])
|
|
||||||
|
|
||||||
const clearFilters = () => {
|
const clearFilters = () => {
|
||||||
setFilters(DEFAULT_FILTERS)
|
setFilters(DEFAULT_FILTERS)
|
||||||
@@ -326,14 +306,18 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Saved searches button */}
|
{/* Favorites button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowSaved(!showSaved)}
|
onClick={() => setShowFavorites(!showFavorites)}
|
||||||
className="p-1.5 rounded transition-colors text-kithara-muted hover:text-white border border-white/10"
|
className={`p-1.5 rounded transition-colors ${
|
||||||
title="Saved searches"
|
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">
|
<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="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -398,9 +382,9 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Results count */}
|
{/* Results count */}
|
||||||
{(search || hasActiveFilters) && (
|
{(search || hasActiveFilters || showFavorites) && (
|
||||||
<p className="text-xs text-kithara-muted mt-1.5">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -494,46 +478,16 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o
|
|||||||
>
|
>
|
||||||
Reset all filters
|
Reset all filters
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
onClick={saveSearch}
|
|
||||||
className="text-xs text-kithara-accent hover:text-kithara-accent/80 transition-colors"
|
|
||||||
>
|
|
||||||
Save this search 💾
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* 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-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>
|
</div>
|
||||||
) : view === 'ribbons' ? (
|
) : view === 'ribbons' ? (
|
||||||
/* ── RIBBON VIEW ── */
|
/* ── RIBBON VIEW ── */
|
||||||
@@ -546,6 +500,19 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o
|
|||||||
currentClipId === clip.id ? 'bg-white/5' : ''
|
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">
|
<div className="shrink-0 relative group/art">
|
||||||
{clip.artwork_filename ? (
|
{clip.artwork_filename ? (
|
||||||
<img
|
<img
|
||||||
@@ -746,6 +713,18 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{editingId === clip.id ? (
|
{editingId === clip.id ? (
|
||||||
@@ -900,4 +879,4 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user