feat(genre): separate persistence for Home and Favorites contexts

- Refactor genreFilterStore to support multiple contexts
- Each context (home, favorites) has independent selection state
- Separate localStorage keys: qbz_genre_filter_home, qbz_genre_filter_favorites
- GenreFilterButton/Popup accept context prop
- FavoritesView uses 'favorites' context
This commit is contained in:
vicrodh committed 2026-02-02 09:55:08 -06:00
1 parent 65b42295e9
commit 2c886fae7a
4 files changed
+164 -98

No files matched your search

+10 -7
View File
@@ -9,14 +9,16 @@
getSelectedGenreNames, getSelectedGenreNames,
loadGenres, loadGenres,
subscribe as subscribeGenre, subscribe as subscribeGenre,
type GenreInfo type GenreInfo,
type GenreFilterContext
} from '$lib/stores/genreFilterStore'; } from '$lib/stores/genreFilterStore';
interface Props { interface Props {
onFilterChange?: () => void; onFilterChange?: () => void;
context?: GenreFilterContext;
} }
let { onFilterChange }: Props = $props(); let { onFilterChange, context = 'home' }: Props = $props();
let isOpen = $state(false); let isOpen = $state(false);
let buttonEl: HTMLButtonElement | null = null; let buttonEl: HTMLButtonElement | null = null;
@@ -29,25 +31,25 @@
loadGenres(); loadGenres();
// Initialize state // Initialize state
hasFilter = hasActiveFilter(); hasFilter = hasActiveFilter(context);
updateSelectedName(); updateSelectedName();
initialized = true; initialized = true;
// Subscribe to filter changes // Subscribe to filter changes for this context
const unsubscribe = subscribeGenre(() => { const unsubscribe = subscribeGenre(() => {
hasFilter = hasActiveFilter(); hasFilter = hasActiveFilter(context);
updateSelectedName(); updateSelectedName();
// Only notify parent after initialization to prevent infinite loops // Only notify parent after initialization to prevent infinite loops
if (initialized) { if (initialized) {
onFilterChange?.(); onFilterChange?.();
} }
}); }, context);
return unsubscribe; return unsubscribe;
}); });
function updateSelectedName() { function updateSelectedName() {
const selectedNames = getSelectedGenreNames(); const selectedNames = getSelectedGenreNames(context);
if (selectedNames.length === 1) { if (selectedNames.length === 1) {
selectedGenreName = selectedNames[0]; selectedGenreName = selectedNames[0];
} else if (selectedNames.length > 1) { } else if (selectedNames.length > 1) {
@@ -84,6 +86,7 @@
<GenreFilterPopup <GenreFilterPopup
{isOpen} {isOpen}
{context}
onClose={closePopup} onClose={closePopup}
anchorEl={buttonEl} anchorEl={buttonEl}
/> />
+12 -10
View File
@@ -11,16 +11,18 @@
setRememberSelection, setRememberSelection,
getGenreFilterState, getGenreFilterState,
subscribe as subscribeGenre, subscribe as subscribeGenre,
type GenreInfo type GenreInfo,
type GenreFilterContext
} from '$lib/stores/genreFilterStore'; } from '$lib/stores/genreFilterStore';
interface Props { interface Props {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
anchorEl?: HTMLElement | null; anchorEl?: HTMLElement | null;
context?: GenreFilterContext;
} }
let { isOpen, onClose, anchorEl = null }: Props = $props(); let { isOpen, onClose, anchorEl = null, context = 'home' }: Props = $props();
let genres = $state<GenreInfo[]>([]); let genres = $state<GenreInfo[]>([]);
let selectedIds = $state<Set<number>>(new Set()); let selectedIds = $state<Set<number>>(new Set());
@@ -28,17 +30,17 @@
let popupEl: HTMLDivElement | null = null; let popupEl: HTMLDivElement | null = null;
let popupStyle = $state(''); let popupStyle = $state('');
// Subscribe to store changes // Subscribe to store changes for this context
$effect(() => { $effect(() => {
const unsubscribe = subscribeGenre(() => { const unsubscribe = subscribeGenre(() => {
const state = getGenreFilterState(); const state = getGenreFilterState(context);
genres = state.availableGenres; genres = state.availableGenres;
selectedIds = state.selectedGenreIds; selectedIds = state.selectedGenreIds;
rememberSelection = state.rememberSelection; rememberSelection = state.rememberSelection;
}); }, context);
// Initial load // Initial load
const state = getGenreFilterState(); const state = getGenreFilterState(context);
genres = state.availableGenres; genres = state.availableGenres;
selectedIds = state.selectedGenreIds; selectedIds = state.selectedGenreIds;
rememberSelection = state.rememberSelection; rememberSelection = state.rememberSelection;
@@ -76,16 +78,16 @@
} }
function handleGenreClick(genreId: number) { function handleGenreClick(genreId: number) {
toggleGenre(genreId); toggleGenre(genreId, context);
} }
function handleClearAll() { function handleClearAll() {
clearSelection(); clearSelection(context);
onClose(); onClose();
} }
function handleRememberToggle() { function handleRememberToggle() {
setRememberSelection(!rememberSelection); setRememberSelection(!rememberSelection, context);
} }
function handleClickOutside(event: MouseEvent) { function handleClickOutside(event: MouseEvent) {
@@ -163,7 +165,7 @@
class="clear-btn" class="clear-btn"
onclick={handleClearAll} onclick={handleClearAll}
type="button" type="button"
disabled={!hasActiveFilter()} disabled={!hasActiveFilter(context)}
> >
Clear filter Clear filter
</button> </button>
@@ -19,10 +19,13 @@
import GenreFilterButton from '../GenreFilterButton.svelte'; import GenreFilterButton from '../GenreFilterButton.svelte';
import { import {
hasActiveFilter as hasGenreFilter, hasActiveFilter as hasGenreFilter,
getSelectedGenreNames getSelectedGenreNames,
type GenreFilterContext
} from '$lib/stores/genreFilterStore'; } from '$lib/stores/genreFilterStore';
import type { FavoritesPreferences, QobuzAlbum } from '$lib/types'; import type { FavoritesPreferences, QobuzAlbum } from '$lib/types';
const GENRE_CONTEXT: GenreFilterContext = 'favorites';
interface FavoriteAlbum { interface FavoriteAlbum {
id: string; id: string;
title: string; title: string;
@@ -415,8 +418,8 @@
let albums = favoriteAlbums; let albums = favoriteAlbums;
// Filter by genre // Filter by genre (using favorites context)
const selectedGenreNames = getSelectedGenreNames(); const selectedGenreNames = getSelectedGenreNames(GENRE_CONTEXT);
if (selectedGenreNames.length > 0) { if (selectedGenreNames.length > 0) {
albums = albums.filter(a => albums = albums.filter(a =>
a.genre && selectedGenreNames.some(genreName => a.genre && selectedGenreNames.some(genreName =>
@@ -1252,7 +1255,7 @@
<div class="toolbar"> <div class="toolbar">
{#if activeTab === 'albums'} {#if activeTab === 'albums'}
<div class="toolbar-controls"> <div class="toolbar-controls">
<GenreFilterButton onFilterChange={handleGenreFilterChange} /> <GenreFilterButton context={GENRE_CONTEXT} onFilterChange={handleGenreFilterChange} />
<div class="dropdown-container"> <div class="dropdown-container">
<button class="control-btn" onclick={() => (showAlbumGroupMenu = !showAlbumGroupMenu)}> <button class="control-btn" onclick={() => (showAlbumGroupMenu = !showAlbumGroupMenu)}>
<span> <span>
+135 -77
View File
@@ -1,6 +1,6 @@
/** /**
* Genre filter store for Home page filtering * Genre filter store with context support
* Persists selected genres to localStorage * Each context (home, favorites) has independent persistence
*/ */
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
@@ -12,162 +12,220 @@ export interface GenreInfo {
slug?: string; slug?: string;
} }
export type GenreFilterContext = 'home' | 'favorites';
interface ContextState {
selectedGenreIds: Set<number>;
rememberSelection: boolean;
listeners: Set<() => void>;
}
interface GenreFilterState { interface GenreFilterState {
availableGenres: GenreInfo[]; availableGenres: GenreInfo[];
selectedGenreIds: Set<number>;
isLoading: boolean; isLoading: boolean;
rememberSelection: boolean; contexts: Record<GenreFilterContext, ContextState>;
} }
const STORAGE_KEY = 'qbz_genre_filter'; const STORAGE_KEYS: Record<GenreFilterContext, string> = {
home: 'qbz_genre_filter_home',
let state: GenreFilterState = { favorites: 'qbz_genre_filter_favorites',
availableGenres: [],
selectedGenreIds: new Set(),
isLoading: false,
rememberSelection: true,
}; };
const listeners: Set<() => void> = new Set(); // Default context for backwards compatibility
let currentContext: GenreFilterContext = 'home';
function notify() { const state: GenreFilterState = {
listeners.forEach((fn) => fn()); availableGenres: [],
isLoading: false,
contexts: {
home: {
selectedGenreIds: new Set(),
rememberSelection: true,
listeners: new Set(),
},
favorites: {
selectedGenreIds: new Set(),
rememberSelection: true,
listeners: new Set(),
},
},
};
function getContextState(context?: GenreFilterContext): ContextState {
return state.contexts[context ?? currentContext];
} }
function saveToStorage() { function notify(context?: GenreFilterContext) {
if (!state.rememberSelection) return; const ctx = getContextState(context);
ctx.listeners.forEach((fn) => fn());
}
function saveToStorage(context?: GenreFilterContext) {
const ctx = context ?? currentContext;
const ctxState = getContextState(ctx);
if (!ctxState.rememberSelection) return;
try { try {
const data = { const data = {
selectedGenreIds: Array.from(state.selectedGenreIds), selectedGenreIds: Array.from(ctxState.selectedGenreIds),
rememberSelection: state.rememberSelection, rememberSelection: ctxState.rememberSelection,
}; };
localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); localStorage.setItem(STORAGE_KEYS[ctx], JSON.stringify(data));
} catch (e) { } catch (e) {
console.error('Failed to save genre filter state:', e); console.error(`Failed to save genre filter state for ${ctx}:`, e);
} }
} }
function loadFromStorage() { function loadFromStorage(context: GenreFilterContext) {
try { try {
const stored = localStorage.getItem(STORAGE_KEY); const stored = localStorage.getItem(STORAGE_KEYS[context]);
if (stored) { if (stored) {
const data = JSON.parse(stored); const data = JSON.parse(stored);
state.selectedGenreIds = new Set(data.selectedGenreIds || []); state.contexts[context].selectedGenreIds = new Set(data.selectedGenreIds || []);
state.rememberSelection = data.rememberSelection ?? true; state.contexts[context].rememberSelection = data.rememberSelection ?? true;
} }
} catch (e) { } catch (e) {
console.error('Failed to load genre filter state:', e); console.error(`Failed to load genre filter state for ${context}:`, e);
} }
} }
export function setContext(context: GenreFilterContext): void {
currentContext = context;
}
export function getContext(): GenreFilterContext {
return currentContext;
}
export async function loadGenres(): Promise<void> { export async function loadGenres(): Promise<void> {
if (state.availableGenres.length > 0) return; // Already loaded if (state.availableGenres.length > 0) return; // Already loaded
state.isLoading = true; state.isLoading = true;
notify(); // Notify all contexts
notify('home');
notify('favorites');
try { try {
const genres = await invoke<GenreInfo[]>('get_genres', {}); const genres = await invoke<GenreInfo[]>('get_genres', {});
state.availableGenres = genres; state.availableGenres = genres;
// Load saved selection after genres are available // Load saved selections for all contexts
loadFromStorage(); loadFromStorage('home');
loadFromStorage('favorites');
// Validate saved selection against available genres // Validate saved selections against available genres
const validIds = new Set(genres.map((g) => g.id)); const validIds = new Set(genres.map((g) => g.id));
const validSelection = new Set<number>(); for (const ctx of ['home', 'favorites'] as GenreFilterContext[]) {
state.selectedGenreIds.forEach((id) => { const ctxState = state.contexts[ctx];
if (validIds.has(id)) { const validSelection = new Set<number>();
validSelection.add(id); ctxState.selectedGenreIds.forEach((id) => {
} if (validIds.has(id)) {
}); validSelection.add(id);
state.selectedGenreIds = validSelection; }
});
ctxState.selectedGenreIds = validSelection;
}
} catch (e) { } catch (e) {
console.error('Failed to load genres:', e); console.error('Failed to load genres:', e);
state.availableGenres = []; state.availableGenres = [];
} finally { } finally {
state.isLoading = false; state.isLoading = false;
notify(); notify('home');
notify('favorites');
} }
} }
export function getGenreFilterState(): GenreFilterState { export function getGenreFilterState(context?: GenreFilterContext) {
return { ...state, selectedGenreIds: new Set(state.selectedGenreIds) }; const ctx = getContextState(context);
return {
availableGenres: state.availableGenres,
selectedGenreIds: new Set(ctx.selectedGenreIds),
isLoading: state.isLoading,
rememberSelection: ctx.rememberSelection,
};
} }
export function getAvailableGenres(): GenreInfo[] { export function getAvailableGenres(): GenreInfo[] {
return state.availableGenres; return state.availableGenres;
} }
export function getSelectedGenreIds(): Set<number> { export function getSelectedGenreIds(context?: GenreFilterContext): Set<number> {
return new Set(state.selectedGenreIds); return new Set(getContextState(context).selectedGenreIds);
} }
export function getSelectedGenreId(): number | undefined { export function getSelectedGenreId(context?: GenreFilterContext): number | undefined {
// For API calls that only support single genre_id const ids = Array.from(getContextState(context).selectedGenreIds);
const ids = Array.from(state.selectedGenreIds);
return ids.length === 1 ? ids[0] : undefined; return ids.length === 1 ? ids[0] : undefined;
} }
export function getSelectedGenreName(): string | undefined { export function getSelectedGenreName(context?: GenreFilterContext): string | undefined {
const id = getSelectedGenreId(); const id = getSelectedGenreId(context);
if (!id) return undefined; if (!id) return undefined;
const genre = state.availableGenres.find(g => g.id === id); const genre = state.availableGenres.find(g => g.id === id);
return genre?.name; return genre?.name;
} }
export function getSelectedGenreNames(): string[] { export function getSelectedGenreNames(context?: GenreFilterContext): string[] {
const ids = Array.from(state.selectedGenreIds); const ids = Array.from(getContextState(context).selectedGenreIds);
return ids return ids
.map(id => state.availableGenres.find(g => g.id === id)?.name) .map(id => state.availableGenres.find(g => g.id === id)?.name)
.filter((name): name is string => !!name); .filter((name): name is string => !!name);
} }
export function isGenreSelected(genreId: number): boolean { export function isGenreSelected(genreId: number, context?: GenreFilterContext): boolean {
return state.selectedGenreIds.has(genreId); return getContextState(context).selectedGenreIds.has(genreId);
} }
export function hasActiveFilter(): boolean { export function hasActiveFilter(context?: GenreFilterContext): boolean {
return state.selectedGenreIds.size > 0; return getContextState(context).selectedGenreIds.size > 0;
} }
export function toggleGenre(genreId: number): void { export function toggleGenre(genreId: number, context?: GenreFilterContext): void {
if (state.selectedGenreIds.has(genreId)) { const ctx = context ?? currentContext;
state.selectedGenreIds.delete(genreId); const ctxState = getContextState(ctx);
if (ctxState.selectedGenreIds.has(genreId)) {
ctxState.selectedGenreIds.delete(genreId);
} else { } else {
state.selectedGenreIds.add(genreId); ctxState.selectedGenreIds.add(genreId);
} }
saveToStorage(); saveToStorage(ctx);
notify(); notify(ctx);
} }
export function selectGenre(genreId: number): void { export function selectGenre(genreId: number, context?: GenreFilterContext): void {
state.selectedGenreIds.clear(); const ctx = context ?? currentContext;
state.selectedGenreIds.add(genreId); const ctxState = getContextState(ctx);
saveToStorage(); ctxState.selectedGenreIds.clear();
notify(); ctxState.selectedGenreIds.add(genreId);
saveToStorage(ctx);
notify(ctx);
} }
export function clearSelection(): void { export function clearSelection(context?: GenreFilterContext): void {
state.selectedGenreIds.clear(); const ctx = context ?? currentContext;
saveToStorage(); const ctxState = getContextState(ctx);
notify(); ctxState.selectedGenreIds.clear();
saveToStorage(ctx);
notify(ctx);
} }
export function setRememberSelection(remember: boolean): void { export function setRememberSelection(remember: boolean, context?: GenreFilterContext): void {
state.rememberSelection = remember; const ctx = context ?? currentContext;
const ctxState = getContextState(ctx);
ctxState.rememberSelection = remember;
if (remember) { if (remember) {
saveToStorage(); saveToStorage(ctx);
} else { } else {
localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(STORAGE_KEYS[ctx]);
} }
notify(); notify(ctx);
} }
export function subscribe(callback: () => void): () => void { export function subscribe(callback: () => void, context?: GenreFilterContext): () => void {
listeners.add(callback); const ctxState = getContextState(context);
return () => listeners.delete(callback); ctxState.listeners.add(callback);
return () => ctxState.listeners.delete(callback);
} }
// Initialize by loading from storage // Initialize by loading from storage for all contexts
loadFromStorage(); loadFromStorage('home');
loadFromStorage('favorites');