feat(ui): add genre filter store and components

- Add genreFilterStore.ts with localStorage persistence
- Add GenreFilterButton.svelte for filter trigger
- Add GenreFilterPopup.svelte with colored genre cards
- Support "Remember my selection" toggle
- Single-genre selection (API limitation)
This commit is contained in:
vicrodh committed 2026-02-02 08:51:37 -06:00
1 parent d0dd42db65
commit 0d0da7c5fc
3 files changed
+668

No files matched your search

+129
View File
@@ -0,0 +1,129 @@
<script lang="ts">
import { SlidersHorizontal } from 'lucide-svelte';
import GenreFilterPopup from './GenreFilterPopup.svelte';
import {
hasActiveFilter,
getAvailableGenres,
getSelectedGenreIds,
loadGenres,
subscribe as subscribeGenre,
type GenreInfo
} from '$lib/stores/genreFilterStore';
interface Props {
onFilterChange?: () => void;
}
let { onFilterChange }: Props = $props();
let isOpen = $state(false);
let buttonEl: HTMLButtonElement | null = null;
let hasFilter = $state(false);
let selectedGenreName = $state<string | null>(null);
// Subscribe to filter changes
$effect(() => {
const unsubscribe = subscribeGenre(() => {
hasFilter = hasActiveFilter();
updateSelectedName();
onFilterChange?.();
});
hasFilter = hasActiveFilter();
updateSelectedName();
return unsubscribe;
});
// Load genres on mount
$effect(() => {
loadGenres();
});
function updateSelectedName() {
const selectedIds = getSelectedGenreIds();
if (selectedIds.size === 1) {
const genres = getAvailableGenres();
const selectedId = Array.from(selectedIds)[0];
const genre = genres.find(g => g.id === selectedId);
selectedGenreName = genre?.name ?? null;
} else {
selectedGenreName = null;
}
}
function togglePopup() {
isOpen = !isOpen;
}
function closePopup() {
isOpen = false;
}
</script>
<div class="genre-filter-wrapper">
<button
class="genre-filter-btn"
class:active={hasFilter}
bind:this={buttonEl}
onclick={togglePopup}
type="button"
>
<SlidersHorizontal size={14} />
{#if selectedGenreName}
<span class="filter-label">{selectedGenreName}</span>
{:else}
<span class="filter-label">Filter by genre</span>
{/if}
</button>
<GenreFilterPopup
{isOpen}
onClose={closePopup}
anchorEl={buttonEl}
/>
</div>
<style>
.genre-filter-wrapper {
position: relative;
}
.genre-filter-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: 1px solid var(--border-subtle);
border-radius: 6px;
background: transparent;
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
transition: all 150ms ease;
}
.genre-filter-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
border-color: var(--border-subtle);
}
.genre-filter-btn.active {
background: var(--accent-primary);
color: white;
border-color: var(--accent-primary);
}
.genre-filter-btn.active:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.filter-label {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+378
View File
@@ -0,0 +1,378 @@
<script lang="ts">
import { tick } from 'svelte';
import { SlidersHorizontal, X } from 'lucide-svelte';
import {
getAvailableGenres,
getSelectedGenreIds,
isGenreSelected,
toggleGenre,
clearSelection,
hasActiveFilter,
setRememberSelection,
getGenreFilterState,
subscribe as subscribeGenre,
type GenreInfo
} from '$lib/stores/genreFilterStore';
interface Props {
isOpen: boolean;
onClose: () => void;
anchorEl?: HTMLElement | null;
}
let { isOpen, onClose, anchorEl = null }: Props = $props();
let genres = $state<GenreInfo[]>([]);
let selectedIds = $state<Set<number>>(new Set());
let rememberSelection = $state(true);
let popupEl: HTMLDivElement | null = null;
let popupStyle = $state('');
// Subscribe to store changes
$effect(() => {
const unsubscribe = subscribeGenre(() => {
const state = getGenreFilterState();
genres = state.availableGenres;
selectedIds = state.selectedGenreIds;
rememberSelection = state.rememberSelection;
});
// Initial load
const state = getGenreFilterState();
genres = state.availableGenres;
selectedIds = state.selectedGenreIds;
rememberSelection = state.rememberSelection;
return unsubscribe;
});
// Position popup when opening
$effect(() => {
if (isOpen && anchorEl) {
positionPopup();
}
});
async function positionPopup() {
await tick();
if (!anchorEl || !popupEl) return;
const anchorRect = anchorEl.getBoundingClientRect();
const popupRect = popupEl.getBoundingClientRect();
let left = anchorRect.right - popupRect.width;
let top = anchorRect.bottom + 8;
// Keep within viewport
if (left < 8) left = 8;
if (left + popupRect.width > window.innerWidth - 8) {
left = window.innerWidth - popupRect.width - 8;
}
if (top + popupRect.height > window.innerHeight - 8) {
top = anchorRect.top - popupRect.height - 8;
}
popupStyle = `left: ${left}px; top: ${top}px;`;
}
function handleGenreClick(genreId: number) {
toggleGenre(genreId);
}
function handleClearAll() {
clearSelection();
}
function handleRememberToggle() {
setRememberSelection(!rememberSelection);
}
function handleClickOutside(event: MouseEvent) {
if (popupEl && !popupEl.contains(event.target as Node) &&
anchorEl && !anchorEl.contains(event.target as Node)) {
onClose();
}
}
// Close on click outside
$effect(() => {
if (isOpen) {
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}
});
// Close on escape
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
onClose();
}
}
// Default genre colors if not provided by API
const defaultColors: Record<string, string> = {
'Pop/Rock': '#e74c3c',
'Jazz': '#f39c12',
'Classical': '#9b59b6',
'Electronic': '#3498db',
'Soul/Funk/R&B': '#e91e63',
'Hip-Hop/Rap': '#607d8b',
'Metal': '#455a64',
'Blues': '#1976d2',
'Latin': '#ff9800',
'Country': '#8d6e63',
'World': '#00897b',
'Soundtracks': '#5c6bc0',
'Folk': '#689f38',
'Reggae': '#4caf50',
'R&B': '#ad1457',
'Flamenco': '#d84315',
};
function getGenreColor(genre: GenreInfo): string {
if (genre.color) return genre.color;
// Try to find a matching default color
for (const [key, color] of Object.entries(defaultColors)) {
if (genre.name.toLowerCase().includes(key.toLowerCase())) {
return color;
}
}
return '#6b7280'; // Default gray
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if isOpen}
<div class="genre-popup" bind:this={popupEl} style={popupStyle}>
<div class="popup-header">
<div class="header-title">
<SlidersHorizontal size={16} />
<span>Filter by genre</span>
</div>
<button class="close-btn" onclick={onClose} type="button">
<X size={16} />
</button>
</div>
<div class="remember-row">
<span>Remember my selection</span>
<button
class="toggle-switch"
class:active={rememberSelection}
onclick={handleRememberToggle}
type="button"
aria-pressed={rememberSelection}
>
<span class="toggle-thumb"></span>
</button>
</div>
<div class="genres-grid">
{#each genres as genre (genre.id)}
<button
class="genre-card"
class:selected={selectedIds.has(genre.id)}
style="--genre-color: {getGenreColor(genre)}"
onclick={() => handleGenreClick(genre.id)}
type="button"
>
<span class="genre-name">{genre.name}</span>
<span class="check-circle" class:checked={selectedIds.has(genre.id)}></span>
</button>
{/each}
</div>
{#if hasActiveFilter()}
<div class="popup-footer">
<button class="clear-btn" onclick={handleClearAll} type="button">
Clear filter
</button>
</div>
{/if}
</div>
{/if}
<style>
.genre-popup {
position: fixed;
z-index: 10000;
width: 320px;
max-height: 480px;
background: var(--bg-primary);
border: 1px solid var(--border-subtle);
border-radius: 12px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
overflow: hidden;
display: flex;
flex-direction: column;
}
.popup-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
}
.header-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.close-btn {
width: 28px;
height: 28px;
border: none;
background: transparent;
color: var(--text-muted);
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.close-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.remember-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
font-size: 12px;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-subtle);
}
.toggle-switch {
width: 36px;
height: 20px;
border-radius: 10px;
background: var(--bg-tertiary);
border: none;
cursor: pointer;
position: relative;
transition: background 150ms ease;
}
.toggle-switch.active {
background: var(--accent-primary);
}
.toggle-thumb {
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: white;
transition: transform 150ms ease;
}
.toggle-switch.active .toggle-thumb {
transform: translateX(16px);
}
.genres-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
padding: 12px;
overflow-y: auto;
max-height: 320px;
}
.genre-card {
position: relative;
aspect-ratio: 1.2;
border-radius: 8px;
border: none;
cursor: pointer;
overflow: hidden;
background: linear-gradient(135deg, var(--genre-color) 0%, color-mix(in srgb, var(--genre-color) 60%, black) 100%);
display: flex;
align-items: flex-end;
padding: 8px;
transition: transform 150ms ease, box-shadow 150ms ease;
}
.genre-card:hover {
transform: scale(1.03);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.genre-card.selected {
box-shadow: 0 0 0 2px var(--accent-primary), 0 4px 12px rgba(0, 0, 0, 0.3);
}
.genre-name {
font-size: 11px;
font-weight: 600;
color: white;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
line-height: 1.2;
text-align: left;
}
.check-circle {
position: absolute;
top: 6px;
right: 6px;
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.6);
background: transparent;
transition: all 150ms ease;
}
.check-circle.checked {
border-color: white;
background: white;
}
.check-circle.checked::after {
content: '';
position: absolute;
top: 2px;
left: 5px;
width: 4px;
height: 8px;
border: solid var(--accent-primary);
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.popup-footer {
padding: 12px 16px;
border-top: 1px solid var(--border-subtle);
}
.clear-btn {
width: 100%;
padding: 8px 16px;
border: none;
border-radius: 6px;
background: var(--bg-tertiary);
color: var(--text-secondary);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: background 150ms ease, color 150ms ease;
}
.clear-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
</style>
+161
View File
@@ -0,0 +1,161 @@
/**
* Genre filter store for Home page filtering
* Persists selected genres to localStorage
*/
import { invoke } from '@tauri-apps/api/core';
export interface GenreInfo {
id: number;
name: string;
color?: string;
slug?: string;
}
interface GenreFilterState {
availableGenres: GenreInfo[];
selectedGenreIds: Set<number>;
isLoading: boolean;
rememberSelection: boolean;
}
const STORAGE_KEY = 'qbz_genre_filter';
let state: GenreFilterState = {
availableGenres: [],
selectedGenreIds: new Set(),
isLoading: false,
rememberSelection: true,
};
const listeners: Set<() => void> = new Set();
function notify() {
listeners.forEach((fn) => fn());
}
function saveToStorage() {
if (!state.rememberSelection) return;
try {
const data = {
selectedGenreIds: Array.from(state.selectedGenreIds),
rememberSelection: state.rememberSelection,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
} catch (e) {
console.error('Failed to save genre filter state:', e);
}
}
function loadFromStorage() {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const data = JSON.parse(stored);
state.selectedGenreIds = new Set(data.selectedGenreIds || []);
state.rememberSelection = data.rememberSelection ?? true;
}
} catch (e) {
console.error('Failed to load genre filter state:', e);
}
}
export async function loadGenres(): Promise<void> {
if (state.availableGenres.length > 0) return; // Already loaded
state.isLoading = true;
notify();
try {
const genres = await invoke<GenreInfo[]>('get_genres', {});
state.availableGenres = genres;
// Load saved selection after genres are available
loadFromStorage();
// Validate saved selection against available genres
const validIds = new Set(genres.map((g) => g.id));
const validSelection = new Set<number>();
state.selectedGenreIds.forEach((id) => {
if (validIds.has(id)) {
validSelection.add(id);
}
});
state.selectedGenreIds = validSelection;
} catch (e) {
console.error('Failed to load genres:', e);
state.availableGenres = [];
} finally {
state.isLoading = false;
notify();
}
}
export function getGenreFilterState(): GenreFilterState {
return { ...state, selectedGenreIds: new Set(state.selectedGenreIds) };
}
export function getAvailableGenres(): GenreInfo[] {
return state.availableGenres;
}
export function getSelectedGenreIds(): Set<number> {
return new Set(state.selectedGenreIds);
}
export function getSelectedGenreId(): number | undefined {
// For API calls that only support single genre_id
const ids = Array.from(state.selectedGenreIds);
return ids.length === 1 ? ids[0] : undefined;
}
export function isGenreSelected(genreId: number): boolean {
return state.selectedGenreIds.has(genreId);
}
export function hasActiveFilter(): boolean {
return state.selectedGenreIds.size > 0;
}
export function toggleGenre(genreId: number): void {
if (state.selectedGenreIds.has(genreId)) {
state.selectedGenreIds.delete(genreId);
} else {
// For now, only allow single selection (API limitation)
state.selectedGenreIds.clear();
state.selectedGenreIds.add(genreId);
}
saveToStorage();
notify();
}
export function selectGenre(genreId: number): void {
state.selectedGenreIds.clear();
state.selectedGenreIds.add(genreId);
saveToStorage();
notify();
}
export function clearSelection(): void {
state.selectedGenreIds.clear();
saveToStorage();
notify();
}
export function setRememberSelection(remember: boolean): void {
state.rememberSelection = remember;
if (remember) {
saveToStorage();
} else {
localStorage.removeItem(STORAGE_KEY);
}
notify();
}
export function subscribe(callback: () => void): () => void {
listeners.add(callback);
return () => listeners.delete(callback);
}
// Initialize by loading from storage
loadFromStorage();