diff --git a/src/lib/components/GenreFilterPopup.svelte b/src/lib/components/GenreFilterPopup.svelte index 29f75c216..cb13081ab 100644 --- a/src/lib/components/GenreFilterPopup.svelte +++ b/src/lib/components/GenreFilterPopup.svelte @@ -1,16 +1,19 @@ @@ -178,7 +219,7 @@ Filter by genre - @@ -192,67 +233,142 @@ onclick={handleRememberToggle} type="button" aria-pressed={rememberSelection} + title="Remember selection" >
- Show all sub-genres + Advanced view
+ {#if showAllGenres} +
+ + + {#if searchQuery} + + {/if} +
+ {/if} +
{#if showAllGenres} - - {#each parentGenres as parent (parent.id)} - {@const children = getChildGenres(parent.id)} - {@const parentState = getParentState(parent.id)} -
- - {#if children.length > 0} -
- {#each children as child (child.id)} - - {/each} + {:else} + + {/if} + +
- {/if} -
- {/each} + + {#if isExpanded && hasChildren} +
+ {#each parentNode.children as childNode (childNode.genre.id)} + {@const childState = getNodeState(childNode)} + {@const childExpanded = expandedNodes.has(childNode.genre.id)} + {@const childHasChildren = childNode.children.length > 0} + +
+
+ {#if childHasChildren} + + {:else} + + {/if} + + +
+ + {#if childExpanded && childHasChildren} +
+ {#each childNode.children as grandchildNode (grandchildNode.genre.id)} +
+ + +
+ {/each} +
+ {/if} +
+ {/each} +
+ {/if} +
+ {/each} + {:else}
@@ -300,7 +416,8 @@ } .genre-popup.expanded { - max-height: 650px; + width: 630px; + max-height: 700px; } .popup-header { @@ -385,6 +502,46 @@ transform: translateX(16px); } + .search-row { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + border-bottom: 1px solid var(--border-subtle); + color: var(--text-muted); + } + + .search-input { + flex: 1; + background: transparent; + border: none; + color: var(--text-primary); + font-size: 13px; + outline: none; + } + + .search-input::placeholder { + color: var(--text-muted); + } + + .clear-search { + width: 20px; + height: 20px; + border: none; + background: var(--bg-tertiary); + color: var(--text-muted); + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + } + + .clear-search:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + .genres-container { flex: 1; overflow-y: auto; @@ -439,8 +596,7 @@ white-space: nowrap; } - .genre-card.selected .genre-name, - .child-card.selected .genre-name { + .genre-card.selected .genre-name { color: white; } @@ -472,46 +628,103 @@ transform: translate(-50%, -60%) rotate(45deg); } - /* Hierarchical view */ - .genre-group { - margin-bottom: 12px; + /* Tree view */ + .genre-tree { + display: flex; + flex-direction: column; + gap: 2px; } - .genre-group:last-child { - margin-bottom: 0; + .tree-node { + display: flex; + flex-direction: column; } - .parent-row { - width: 100%; + .node-row { display: flex; align-items: center; - gap: 10px; - padding: 8px 12px; - background: var(--bg-secondary); - border: 1px solid var(--border-subtle); + gap: 4px; + padding: 6px 8px; border-radius: 6px; - cursor: pointer; - transition: all 150ms ease; + transition: background 150ms ease; } - .parent-row:hover { + .node-row:hover { background: var(--bg-hover); - border-color: var(--text-muted); } - .parent-row.selected { + .node-row.selected { background: var(--accent-primary); - border-color: var(--accent-primary); } - .parent-row.partial { - border-color: var(--accent-primary); - } - - .parent-row.selected:hover { + .node-row.selected:hover { background: var(--accent-hover); } + .node-row.partial { + background: rgba(var(--accent-primary-rgb, 59, 130, 246), 0.15); + } + + .node-row.level-0 { + font-weight: 600; + } + + .node-row.level-1 { + margin-left: 20px; + font-size: 12px; + } + + .node-row.level-2 { + margin-left: 40px; + font-size: 11px; + } + + .expand-btn { + width: 20px; + height: 20px; + border: none; + background: transparent; + color: var(--text-muted); + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } + + .expand-btn:hover { + background: var(--bg-tertiary); + color: var(--text-primary); + } + + .node-row.selected .expand-btn { + color: rgba(255, 255, 255, 0.7); + } + + .node-row.selected .expand-btn:hover { + background: rgba(255, 255, 255, 0.1); + color: white; + } + + .expand-placeholder { + width: 20px; + flex-shrink: 0; + } + + .node-content { + flex: 1; + display: flex; + align-items: center; + gap: 8px; + background: none; + border: none; + cursor: pointer; + text-align: left; + padding: 0; + min-width: 0; + } + .check-box { flex-shrink: 0; width: 16px; @@ -526,6 +739,11 @@ transition: all 150ms ease; } + .check-box.small { + width: 14px; + height: 14px; + } + .check-box.checked { border-color: white; background: white; @@ -538,81 +756,45 @@ color: white; } - .parent-name { - flex: 1; - font-size: 13px; - font-weight: 600; - color: var(--text-primary); - text-align: left; + .node-row.selected .check-box { + border-color: white; } - .parent-row.selected .parent-name { + .node-row.selected .check-box.checked { + background: white; + color: var(--accent-primary); + } + + .node-name { + flex: 1; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .node-row.selected .node-name { color: white; } - .child-count { - font-size: 11px; + .descendant-count { + font-size: 10px; color: var(--text-muted); background: var(--bg-tertiary); padding: 2px 6px; border-radius: 10px; + flex-shrink: 0; } - .parent-row.selected .child-count { + .node-row.selected .descendant-count { background: rgba(255, 255, 255, 0.2); color: rgba(255, 255, 255, 0.8); } - .children-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 6px; - margin-top: 8px; - padding-left: 26px; - } - - .child-card { - position: relative; - height: 32px; - border-radius: 6px; - border: 1px solid var(--border-subtle); - cursor: pointer; - overflow: hidden; - background: var(--bg-tertiary); + .children-container { display: flex; - align-items: center; - justify-content: space-between; - padding: 0 8px; - transition: all 150ms ease; - } - - .child-card:hover { - background: var(--bg-hover); - border-color: var(--text-muted); - } - - .child-card.selected { - background: var(--accent-primary); - border-color: var(--accent-primary); - } - - .child-card.selected:hover { - background: var(--accent-hover); - border-color: var(--accent-hover); - } - - .child-card .genre-name { - font-size: 10px; - } - - .child-card .check-circle { - width: 12px; - height: 12px; - } - - .child-card .check-circle.checked::after { - width: 3px; - height: 6px; + flex-direction: column; + gap: 2px; } .popup-footer { diff --git a/src/lib/stores/genreFilterStore.ts b/src/lib/stores/genreFilterStore.ts index e11556036..a53a28948 100644 --- a/src/lib/stores/genreFilterStore.ts +++ b/src/lib/stores/genreFilterStore.ts @@ -1,5 +1,5 @@ /** - * Genre filter store with context support + * Genre filter store with context support and 3-level hierarchy * Each context (home, favorites) has independent persistence */ @@ -13,6 +13,11 @@ export interface GenreInfo { parentId?: number; } +export interface GenreTreeNode { + genre: GenreInfo; + children: GenreTreeNode[]; +} + export type GenreFilterContext = 'home' | 'favorites'; interface ContextState { @@ -24,6 +29,7 @@ interface ContextState { interface GenreFilterState { parentGenres: GenreInfo[]; allGenres: GenreInfo[]; + genreTree: GenreTreeNode[]; childrenByParent: Map; isLoading: boolean; contexts: Record; @@ -34,12 +40,12 @@ const STORAGE_KEYS: Record = { favorites: 'qbz_genre_filter_favorites', }; -// Default context for backwards compatibility let currentContext: GenreFilterContext = 'home'; const state: GenreFilterState = { parentGenres: [], allGenres: [], + genreTree: [], childrenByParent: new Map(), isLoading: false, contexts: { @@ -102,55 +108,68 @@ export function getContext(): GenreFilterContext { } export async function loadGenres(): Promise { - if (state.parentGenres.length > 0) return; // Already loaded + if (state.parentGenres.length > 0) return; state.isLoading = true; - // Notify all contexts notify('home'); notify('favorites'); try { - // Fetch top-level genres first + // Fetch top-level genres const parentGenres = await invoke('get_genres', {}); - // Fetch sub-genres for each parent in parallel const childrenByParent = new Map(); - const subGenrePromises = parentGenres.map(async (parent) => { - try { - const children = await invoke('get_genres', { parentId: parent.id }); - // Tag children with their parent ID - const taggedChildren = children.map(c => ({ ...c, parentId: parent.id })); - childrenByParent.set(parent.id, taggedChildren); - return taggedChildren; - } catch { - childrenByParent.set(parent.id, []); - return []; + const allGenresList: GenreInfo[] = [...parentGenres]; + const genreTree: GenreTreeNode[] = []; + + // Fetch level 2 and level 3 genres + for (const parent of parentGenres) { + const level2Children = await invoke('get_genres', { parentId: parent.id }); + const taggedLevel2 = level2Children.map(c => ({ ...c, parentId: parent.id })); + childrenByParent.set(parent.id, taggedLevel2); + allGenresList.push(...taggedLevel2); + + const parentNode: GenreTreeNode = { genre: parent, children: [] }; + + // Fetch level 3 for each level 2 + for (const child of taggedLevel2) { + const level3Children = await invoke('get_genres', { parentId: child.id }); + const taggedLevel3 = level3Children.map(gc => ({ ...gc, parentId: child.id })); + + if (taggedLevel3.length > 0) { + childrenByParent.set(child.id, taggedLevel3); + allGenresList.push(...taggedLevel3); + } + + const childNode: GenreTreeNode = { + genre: child, + children: taggedLevel3.map(gc => ({ genre: gc, children: [] })) + }; + parentNode.children.push(childNode); } - }); - const subGenreResults = await Promise.all(subGenrePromises); - const allSubGenres = subGenreResults.flat(); + genreTree.push(parentNode); + } - // Combine all genres and remove duplicates by ID - const allGenresRaw = [...parentGenres, ...allSubGenres]; - const allGenres = Array.from( - new Map(allGenresRaw.map(g => [g.id, g])).values() + // Remove duplicates and sort + const uniqueGenres = Array.from( + new Map(allGenresList.map(g => [g.id, g])).values() ); - - // Sort alphabetically by name - allGenres.sort((a, b) => a.name.localeCompare(b.name)); + uniqueGenres.sort((a, b) => a.name.localeCompare(b.name)); parentGenres.sort((a, b) => a.name.localeCompare(b.name)); + genreTree.sort((a, b) => a.genre.name.localeCompare(b.genre.name)); state.parentGenres = parentGenres; - state.allGenres = allGenres; + state.allGenres = uniqueGenres; + state.genreTree = genreTree; state.childrenByParent = childrenByParent; - // Load saved selections for all contexts + // Load saved selections loadFromStorage('home'); loadFromStorage('favorites'); - // Validate saved selections against available genres - const validIds = new Set(allGenres.map((g) => g.id)); + // Validate saved selections + const validIds = new Set(uniqueGenres.map((g) => g.id)); for (const ctx of ['home', 'favorites'] as GenreFilterContext[]) { const ctxState = state.contexts[ctx]; const validSelection = new Set(); @@ -165,6 +184,7 @@ export async function loadGenres(): Promise { console.error('Failed to load genres:', e); state.parentGenres = []; state.allGenres = []; + state.genreTree = []; state.childrenByParent = new Map(); } finally { state.isLoading = false; @@ -178,6 +198,7 @@ export function getGenreFilterState(context?: GenreFilterContext) { return { availableGenres: state.parentGenres, allGenres: state.allGenres, + genreTree: state.genreTree, selectedGenreIds: new Set(ctx.selectedGenreIds), isLoading: state.isLoading, rememberSelection: ctx.rememberSelection, @@ -192,10 +213,34 @@ export function getAllGenres(): GenreInfo[] { return state.allGenres; } +export function getGenreTree(): GenreTreeNode[] { + return state.genreTree; +} + export function getChildGenres(parentId: number): GenreInfo[] { return state.childrenByParent.get(parentId) || []; } +/** Get all descendant IDs for a genre (children + grandchildren) */ +export function getAllDescendantIds(genreId: number): number[] { + const descendants: number[] = []; + const children = state.childrenByParent.get(genreId) || []; + + for (const child of children) { + descendants.push(child.id); + // Also get grandchildren + const grandchildren = state.childrenByParent.get(child.id) || []; + descendants.push(...grandchildren.map(gc => gc.id)); + } + + return descendants; +} + +/** Count total descendants (children + grandchildren) */ +export function countDescendants(genreId: number): number { + return getAllDescendantIds(genreId).length; +} + export function getSelectedGenreIds(context?: GenreFilterContext): Set { return new Set(getContextState(context).selectedGenreIds); } @@ -220,27 +265,25 @@ export function getSelectedGenreNames(context?: GenreFilterContext): string[] { } /** - * Get all genre names to filter by, including children of selected parent genres. - * Use this for client-side filtering to ensure albums with sub-genres are included. + * Get all genre names to filter by, including all descendants of selected genres. + * Traverses up to 3 levels deep. */ export function getFilterGenreNames(context?: GenreFilterContext): string[] { const selectedIds = Array.from(getContextState(context).selectedGenreIds); const names = new Set(); for (const id of selectedIds) { - // Add the selected genre's name const genre = state.allGenres.find((g: GenreInfo) => g.id === id); if (genre?.name) { names.add(genre.name); } - // If it's a parent genre, also add all its children's names - const children = state.childrenByParent.get(id); - if (children) { - for (const child of children) { - if (child.name) { - names.add(child.name); - } + // Add all descendants (children and grandchildren) + const descendantIds = getAllDescendantIds(id); + for (const descId of descendantIds) { + const descGenre = state.allGenres.find((g: GenreInfo) => g.id === descId); + if (descGenre?.name) { + names.add(descGenre.name); } } } @@ -268,6 +311,23 @@ export function toggleGenre(genreId: number, context?: GenreFilterContext): void notify(ctx); } +/** Select or deselect multiple genres at once */ +export function setGenresSelected(genreIds: number[], selected: boolean, context?: GenreFilterContext): void { + const ctx = context ?? currentContext; + const ctxState = getContextState(ctx); + + for (const id of genreIds) { + if (selected) { + ctxState.selectedGenreIds.add(id); + } else { + ctxState.selectedGenreIds.delete(id); + } + } + + saveToStorage(ctx); + notify(ctx); +} + export function selectGenre(genreId: number, context?: GenreFilterContext): void { const ctx = context ?? currentContext; const ctxState = getContextState(ctx);