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:
1 parent
d0dd42db65
commit
0d0da7c5fc
3 files changed
+668
No files matched your search
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
Reference in new issue
Block a user