feat(genre): 3-level hierarchy with collapsible tree and search
Store changes: - Load all 3 levels of genres (parent → child → grandchild) - Add genreTree for hierarchical structure - getAllDescendantIds() for cascade selection - setGenresSelected() for batch operations Popup changes: - Advanced view with collapsible tree structure - Search box filters and auto-expands matching nodes - Click parent checkbox selects/deselects all descendants - Partial selection shows indeterminate state - Expanded size: 630px wide, 700px tall - Descendant count badges on expandable nodes
This commit is contained in:
1 parent
3155627669
commit
62e8ba7892
2 files changed
+445
-203
No files matched your search
@@ -1,16 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { SlidersHorizontal, X, Minus, Check } from 'lucide-svelte';
|
||||
import { SlidersHorizontal, X, Minus, Check, ChevronRight, ChevronDown, Search } from 'lucide-svelte';
|
||||
import {
|
||||
getAvailableGenres,
|
||||
getChildGenres,
|
||||
getAllDescendantIds,
|
||||
countDescendants,
|
||||
toggleGenre,
|
||||
setGenresSelected,
|
||||
clearSelection,
|
||||
hasActiveFilter,
|
||||
setRememberSelection,
|
||||
getGenreFilterState,
|
||||
subscribe as subscribeGenre,
|
||||
type GenreInfo,
|
||||
type GenreTreeNode,
|
||||
type GenreFilterContext
|
||||
} from '$lib/stores/genreFilterStore';
|
||||
|
||||
@@ -27,27 +30,28 @@
|
||||
let { isOpen, onClose, anchorEl = null, context = 'home', align = 'left' }: Props = $props();
|
||||
|
||||
let parentGenres = $state<GenreInfo[]>([]);
|
||||
let allGenres = $state<GenreInfo[]>([]);
|
||||
let genreTree = $state<GenreTreeNode[]>([]);
|
||||
let selectedIds = $state<Set<number>>(new Set());
|
||||
let rememberSelection = $state(true);
|
||||
let showAllGenres = $state(false);
|
||||
let searchQuery = $state('');
|
||||
let expandedNodes = $state<Set<number>>(new Set());
|
||||
let popupEl: HTMLDivElement | null = null;
|
||||
let popupStyle = $state('');
|
||||
|
||||
// Subscribe to store changes for this context
|
||||
// Subscribe to store changes
|
||||
$effect(() => {
|
||||
const unsubscribe = subscribeGenre(() => {
|
||||
const state = getGenreFilterState(context);
|
||||
parentGenres = state.availableGenres;
|
||||
allGenres = state.allGenres;
|
||||
genreTree = state.genreTree;
|
||||
selectedIds = state.selectedGenreIds;
|
||||
rememberSelection = state.rememberSelection;
|
||||
}, context);
|
||||
|
||||
// Initial load
|
||||
const state = getGenreFilterState(context);
|
||||
parentGenres = state.availableGenres;
|
||||
allGenres = state.allGenres;
|
||||
genreTree = state.genreTree;
|
||||
selectedIds = state.selectedGenreIds;
|
||||
rememberSelection = state.rememberSelection;
|
||||
|
||||
@@ -61,6 +65,34 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-expand nodes when searching
|
||||
$effect(() => {
|
||||
if (searchQuery.trim()) {
|
||||
const matching = new Set<number>();
|
||||
for (const parent of genreTree) {
|
||||
if (nodeMatchesSearch(parent)) {
|
||||
matching.add(parent.genre.id);
|
||||
for (const child of parent.children) {
|
||||
if (nodeMatchesSearch(child) || child.children.some(gc => genreMatchesSearch(gc.genre))) {
|
||||
matching.add(child.genre.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expandedNodes = matching;
|
||||
}
|
||||
});
|
||||
|
||||
function genreMatchesSearch(genre: GenreInfo): boolean {
|
||||
if (!searchQuery.trim()) return true;
|
||||
return genre.name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
}
|
||||
|
||||
function nodeMatchesSearch(node: GenreTreeNode): boolean {
|
||||
if (genreMatchesSearch(node.genre)) return true;
|
||||
return node.children.some(child => nodeMatchesSearch(child));
|
||||
}
|
||||
|
||||
async function positionPopup() {
|
||||
await tick();
|
||||
if (!anchorEl || !popupEl) return;
|
||||
@@ -92,48 +124,38 @@
|
||||
toggleGenre(genreId, context);
|
||||
}
|
||||
|
||||
// Get parent selection state: 'all' | 'none' | 'partial'
|
||||
function getParentState(parentId: number): 'all' | 'none' | 'partial' {
|
||||
const children = getChildGenres(parentId);
|
||||
if (children.length === 0) {
|
||||
// No children, just check parent itself
|
||||
return selectedIds.has(parentId) ? 'all' : 'none';
|
||||
}
|
||||
// Get selection state for a node: 'all' | 'none' | 'partial'
|
||||
function getNodeState(node: GenreTreeNode): 'all' | 'none' | 'partial' {
|
||||
const allIds = [node.genre.id, ...getAllDescendantIds(node.genre.id)];
|
||||
const selectedCount = allIds.filter(id => selectedIds.has(id)).length;
|
||||
|
||||
const selectedCount = children.filter(c => selectedIds.has(c.id)).length;
|
||||
if (selectedCount === 0) return 'none';
|
||||
if (selectedCount === children.length) return 'all';
|
||||
if (selectedCount === allIds.length) return 'all';
|
||||
return 'partial';
|
||||
}
|
||||
|
||||
// Toggle parent: if all selected, deselect all; otherwise select all
|
||||
function handleParentClick(parentId: number) {
|
||||
const children = getChildGenres(parentId);
|
||||
const currentState = getParentState(parentId);
|
||||
|
||||
if (children.length === 0) {
|
||||
// No children, just toggle parent
|
||||
toggleGenre(parentId, context);
|
||||
return;
|
||||
}
|
||||
// Toggle a parent node: select/deselect all descendants
|
||||
function handleNodeToggle(node: GenreTreeNode) {
|
||||
const currentState = getNodeState(node);
|
||||
const allIds = [node.genre.id, ...getAllDescendantIds(node.genre.id)];
|
||||
|
||||
if (currentState === 'all') {
|
||||
// Deselect all children
|
||||
for (const child of children) {
|
||||
if (selectedIds.has(child.id)) {
|
||||
toggleGenre(child.id, context);
|
||||
}
|
||||
}
|
||||
setGenresSelected(allIds, false, context);
|
||||
} else {
|
||||
// Select all children
|
||||
for (const child of children) {
|
||||
if (!selectedIds.has(child.id)) {
|
||||
toggleGenre(child.id, context);
|
||||
}
|
||||
}
|
||||
setGenresSelected(allIds, true, context);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleExpanded(genreId: number) {
|
||||
const newExpanded = new Set(expandedNodes);
|
||||
if (newExpanded.has(genreId)) {
|
||||
newExpanded.delete(genreId);
|
||||
} else {
|
||||
newExpanded.add(genreId);
|
||||
}
|
||||
expandedNodes = newExpanded;
|
||||
}
|
||||
|
||||
function handleClearAll() {
|
||||
clearSelection(context);
|
||||
onClose();
|
||||
@@ -162,6 +184,25 @@
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
// Filter tree based on search
|
||||
function filterTree(nodes: GenreTreeNode[]): GenreTreeNode[] {
|
||||
if (!searchQuery.trim()) return nodes;
|
||||
|
||||
return nodes
|
||||
.filter(node => nodeMatchesSearch(node))
|
||||
.map(node => ({
|
||||
...node,
|
||||
children: node.children
|
||||
.filter(child => nodeMatchesSearch(child))
|
||||
.map(child => ({
|
||||
...child,
|
||||
children: child.children.filter(gc => genreMatchesSearch(gc.genre))
|
||||
}))
|
||||
}));
|
||||
}
|
||||
|
||||
let filteredTree = $derived(filterTree(genreTree));
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
@@ -178,7 +219,7 @@
|
||||
<SlidersHorizontal size={16} />
|
||||
<span>Filter by genre</span>
|
||||
</div>
|
||||
<button class="close-btn" onclick={onClose} type="button">
|
||||
<button class="close-btn" onclick={onClose} type="button" title="Close">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -192,67 +233,142 @@
|
||||
onclick={handleRememberToggle}
|
||||
type="button"
|
||||
aria-pressed={rememberSelection}
|
||||
title="Remember selection"
|
||||
>
|
||||
<span class="toggle-thumb"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="option-item">
|
||||
<span>Show all sub-genres</span>
|
||||
<span>Advanced view</span>
|
||||
<button
|
||||
class="toggle-switch"
|
||||
class:active={showAllGenres}
|
||||
onclick={() => showAllGenres = !showAllGenres}
|
||||
type="button"
|
||||
aria-pressed={showAllGenres}
|
||||
title="Toggle advanced view"
|
||||
>
|
||||
<span class="toggle-thumb"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showAllGenres}
|
||||
<div class="search-row">
|
||||
<Search size={14} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search genres..."
|
||||
bind:value={searchQuery}
|
||||
class="search-input"
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<button class="clear-search" onclick={() => searchQuery = ''} type="button" title="Clear search">
|
||||
<X size={12} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="genres-container">
|
||||
{#if showAllGenres}
|
||||
<!-- Hierarchical view -->
|
||||
{#each parentGenres as parent (parent.id)}
|
||||
{@const children = getChildGenres(parent.id)}
|
||||
{@const parentState = getParentState(parent.id)}
|
||||
<div class="genre-group">
|
||||
<button
|
||||
class="parent-row"
|
||||
class:selected={parentState === 'all'}
|
||||
class:partial={parentState === 'partial'}
|
||||
onclick={() => handleParentClick(parent.id)}
|
||||
type="button"
|
||||
>
|
||||
<span class="check-box" class:checked={parentState === 'all'} class:partial={parentState === 'partial'}>
|
||||
{#if parentState === 'all'}
|
||||
<Check size={10} strokeWidth={3} />
|
||||
{:else if parentState === 'partial'}
|
||||
<Minus size={10} strokeWidth={3} />
|
||||
{/if}
|
||||
</span>
|
||||
<span class="parent-name">{parent.name}</span>
|
||||
{#if children.length > 0}
|
||||
<span class="child-count">{children.length}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if children.length > 0}
|
||||
<div class="children-grid">
|
||||
{#each children as child (child.id)}
|
||||
<button
|
||||
class="child-card"
|
||||
class:selected={selectedIds.has(child.id)}
|
||||
onclick={() => handleGenreClick(child.id)}
|
||||
type="button"
|
||||
>
|
||||
<span class="genre-name">{child.name}</span>
|
||||
<span class="check-circle" class:checked={selectedIds.has(child.id)}></span>
|
||||
<!-- Hierarchical tree view -->
|
||||
<div class="genre-tree">
|
||||
{#each filteredTree as parentNode (parentNode.genre.id)}
|
||||
{@const parentState = getNodeState(parentNode)}
|
||||
{@const isExpanded = expandedNodes.has(parentNode.genre.id)}
|
||||
{@const hasChildren = parentNode.children.length > 0}
|
||||
{@const descendantCount = countDescendants(parentNode.genre.id)}
|
||||
|
||||
<div class="tree-node">
|
||||
<div class="node-row level-0" class:selected={parentState === 'all'} class:partial={parentState === 'partial'}>
|
||||
{#if hasChildren}
|
||||
<button class="expand-btn" onclick={() => toggleExpanded(parentNode.genre.id)} type="button" title={isExpanded ? 'Collapse' : 'Expand'}>
|
||||
{#if isExpanded}
|
||||
<ChevronDown size={14} />
|
||||
{:else}
|
||||
<ChevronRight size={14} />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{:else}
|
||||
<span class="expand-placeholder"></span>
|
||||
{/if}
|
||||
|
||||
<button class="node-content" onclick={() => handleNodeToggle(parentNode)} type="button">
|
||||
<span class="check-box" class:checked={parentState === 'all'} class:partial={parentState === 'partial'}>
|
||||
{#if parentState === 'all'}
|
||||
<Check size={10} strokeWidth={3} />
|
||||
{:else if parentState === 'partial'}
|
||||
<Minus size={10} strokeWidth={3} />
|
||||
{/if}
|
||||
</span>
|
||||
<span class="node-name">{parentNode.genre.name}</span>
|
||||
{#if descendantCount > 0}
|
||||
<span class="descendant-count">{descendantCount}</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if isExpanded && hasChildren}
|
||||
<div class="children-container">
|
||||
{#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}
|
||||
|
||||
<div class="tree-node">
|
||||
<div class="node-row level-1" class:selected={childState === 'all'} class:partial={childState === 'partial'}>
|
||||
{#if childHasChildren}
|
||||
<button class="expand-btn" onclick={() => toggleExpanded(childNode.genre.id)} type="button" title={childExpanded ? 'Collapse' : 'Expand'}>
|
||||
{#if childExpanded}
|
||||
<ChevronDown size={12} />
|
||||
{:else}
|
||||
<ChevronRight size={12} />
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="expand-placeholder"></span>
|
||||
{/if}
|
||||
|
||||
<button class="node-content" onclick={() => childHasChildren ? handleNodeToggle(childNode) : handleGenreClick(childNode.genre.id)} type="button">
|
||||
<span class="check-box small" class:checked={childState === 'all' || (!childHasChildren && selectedIds.has(childNode.genre.id))} class:partial={childState === 'partial'}>
|
||||
{#if childState === 'all' || (!childHasChildren && selectedIds.has(childNode.genre.id))}
|
||||
<Check size={8} strokeWidth={3} />
|
||||
{:else if childState === 'partial'}
|
||||
<Minus size={8} strokeWidth={3} />
|
||||
{/if}
|
||||
</span>
|
||||
<span class="node-name">{childNode.genre.name}</span>
|
||||
{#if childHasChildren}
|
||||
<span class="descendant-count">{childNode.children.length}</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if childExpanded && childHasChildren}
|
||||
<div class="children-container">
|
||||
{#each childNode.children as grandchildNode (grandchildNode.genre.id)}
|
||||
<div class="node-row level-2" class:selected={selectedIds.has(grandchildNode.genre.id)}>
|
||||
<span class="expand-placeholder"></span>
|
||||
<button class="node-content" onclick={() => handleGenreClick(grandchildNode.genre.id)} type="button">
|
||||
<span class="check-box small" class:checked={selectedIds.has(grandchildNode.genre.id)}>
|
||||
{#if selectedIds.has(grandchildNode.genre.id)}
|
||||
<Check size={8} strokeWidth={3} />
|
||||
{/if}
|
||||
</span>
|
||||
<span class="node-name">{grandchildNode.genre.name}</span>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Simple grid view (parents only) -->
|
||||
<div class="genres-grid">
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<number, GenreInfo[]>;
|
||||
isLoading: boolean;
|
||||
contexts: Record<GenreFilterContext, ContextState>;
|
||||
@@ -34,12 +40,12 @@ const STORAGE_KEYS: Record<GenreFilterContext, string> = {
|
||||
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<void> {
|
||||
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<GenreInfo[]>('get_genres', {});
|
||||
|
||||
// Fetch sub-genres for each parent in parallel
|
||||
const childrenByParent = new Map<number, GenreInfo[]>();
|
||||
const subGenrePromises = parentGenres.map(async (parent) => {
|
||||
try {
|
||||
const children = await invoke<GenreInfo[]>('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<GenreInfo[]>('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<GenreInfo[]>('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<number>();
|
||||
@@ -165,6 +184,7 @@ export async function loadGenres(): Promise<void> {
|
||||
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<number> {
|
||||
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<string>();
|
||||
|
||||
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);
|
||||
|
||||
Reference in new issue
Block a user