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