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">
|
<script lang="ts">
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
import { SlidersHorizontal, X, Minus, Check } from 'lucide-svelte';
|
import { SlidersHorizontal, X, Minus, Check, ChevronRight, ChevronDown, Search } from 'lucide-svelte';
|
||||||
import {
|
import {
|
||||||
getAvailableGenres,
|
|
||||||
getChildGenres,
|
getChildGenres,
|
||||||
|
getAllDescendantIds,
|
||||||
|
countDescendants,
|
||||||
toggleGenre,
|
toggleGenre,
|
||||||
|
setGenresSelected,
|
||||||
clearSelection,
|
clearSelection,
|
||||||
hasActiveFilter,
|
hasActiveFilter,
|
||||||
setRememberSelection,
|
setRememberSelection,
|
||||||
getGenreFilterState,
|
getGenreFilterState,
|
||||||
subscribe as subscribeGenre,
|
subscribe as subscribeGenre,
|
||||||
type GenreInfo,
|
type GenreInfo,
|
||||||
|
type GenreTreeNode,
|
||||||
type GenreFilterContext
|
type GenreFilterContext
|
||||||
} from '$lib/stores/genreFilterStore';
|
} from '$lib/stores/genreFilterStore';
|
||||||
|
|
||||||
@@ -27,27 +30,28 @@
|
|||||||
let { isOpen, onClose, anchorEl = null, context = 'home', align = 'left' }: Props = $props();
|
let { isOpen, onClose, anchorEl = null, context = 'home', align = 'left' }: Props = $props();
|
||||||
|
|
||||||
let parentGenres = $state<GenreInfo[]>([]);
|
let parentGenres = $state<GenreInfo[]>([]);
|
||||||
let allGenres = $state<GenreInfo[]>([]);
|
let genreTree = $state<GenreTreeNode[]>([]);
|
||||||
let selectedIds = $state<Set<number>>(new Set());
|
let selectedIds = $state<Set<number>>(new Set());
|
||||||
let rememberSelection = $state(true);
|
let rememberSelection = $state(true);
|
||||||
let showAllGenres = $state(false);
|
let showAllGenres = $state(false);
|
||||||
|
let searchQuery = $state('');
|
||||||
|
let expandedNodes = $state<Set<number>>(new Set());
|
||||||
let popupEl: HTMLDivElement | null = null;
|
let popupEl: HTMLDivElement | null = null;
|
||||||
let popupStyle = $state('');
|
let popupStyle = $state('');
|
||||||
|
|
||||||
// Subscribe to store changes for this context
|
// Subscribe to store changes
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const unsubscribe = subscribeGenre(() => {
|
const unsubscribe = subscribeGenre(() => {
|
||||||
const state = getGenreFilterState(context);
|
const state = getGenreFilterState(context);
|
||||||
parentGenres = state.availableGenres;
|
parentGenres = state.availableGenres;
|
||||||
allGenres = state.allGenres;
|
genreTree = state.genreTree;
|
||||||
selectedIds = state.selectedGenreIds;
|
selectedIds = state.selectedGenreIds;
|
||||||
rememberSelection = state.rememberSelection;
|
rememberSelection = state.rememberSelection;
|
||||||
}, context);
|
}, context);
|
||||||
|
|
||||||
// Initial load
|
|
||||||
const state = getGenreFilterState(context);
|
const state = getGenreFilterState(context);
|
||||||
parentGenres = state.availableGenres;
|
parentGenres = state.availableGenres;
|
||||||
allGenres = state.allGenres;
|
genreTree = state.genreTree;
|
||||||
selectedIds = state.selectedGenreIds;
|
selectedIds = state.selectedGenreIds;
|
||||||
rememberSelection = state.rememberSelection;
|
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() {
|
async function positionPopup() {
|
||||||
await tick();
|
await tick();
|
||||||
if (!anchorEl || !popupEl) return;
|
if (!anchorEl || !popupEl) return;
|
||||||
@@ -92,48 +124,38 @@
|
|||||||
toggleGenre(genreId, context);
|
toggleGenre(genreId, context);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get parent selection state: 'all' | 'none' | 'partial'
|
// Get selection state for a node: 'all' | 'none' | 'partial'
|
||||||
function getParentState(parentId: number): 'all' | 'none' | 'partial' {
|
function getNodeState(node: GenreTreeNode): 'all' | 'none' | 'partial' {
|
||||||
const children = getChildGenres(parentId);
|
const allIds = [node.genre.id, ...getAllDescendantIds(node.genre.id)];
|
||||||
if (children.length === 0) {
|
const selectedCount = allIds.filter(id => selectedIds.has(id)).length;
|
||||||
// No children, just check parent itself
|
|
||||||
return selectedIds.has(parentId) ? 'all' : 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedCount = children.filter(c => selectedIds.has(c.id)).length;
|
|
||||||
if (selectedCount === 0) return 'none';
|
if (selectedCount === 0) return 'none';
|
||||||
if (selectedCount === children.length) return 'all';
|
if (selectedCount === allIds.length) return 'all';
|
||||||
return 'partial';
|
return 'partial';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle parent: if all selected, deselect all; otherwise select all
|
// Toggle a parent node: select/deselect all descendants
|
||||||
function handleParentClick(parentId: number) {
|
function handleNodeToggle(node: GenreTreeNode) {
|
||||||
const children = getChildGenres(parentId);
|
const currentState = getNodeState(node);
|
||||||
const currentState = getParentState(parentId);
|
const allIds = [node.genre.id, ...getAllDescendantIds(node.genre.id)];
|
||||||
|
|
||||||
if (children.length === 0) {
|
|
||||||
// No children, just toggle parent
|
|
||||||
toggleGenre(parentId, context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentState === 'all') {
|
if (currentState === 'all') {
|
||||||
// Deselect all children
|
setGenresSelected(allIds, false, context);
|
||||||
for (const child of children) {
|
|
||||||
if (selectedIds.has(child.id)) {
|
|
||||||
toggleGenre(child.id, context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Select all children
|
setGenresSelected(allIds, true, context);
|
||||||
for (const child of children) {
|
|
||||||
if (!selectedIds.has(child.id)) {
|
|
||||||
toggleGenre(child.id, 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() {
|
function handleClearAll() {
|
||||||
clearSelection(context);
|
clearSelection(context);
|
||||||
onClose();
|
onClose();
|
||||||
@@ -162,6 +184,25 @@
|
|||||||
onClose();
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:window onkeydown={handleKeydown} />
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
@@ -178,7 +219,7 @@
|
|||||||
<SlidersHorizontal size={16} />
|
<SlidersHorizontal size={16} />
|
||||||
<span>Filter by genre</span>
|
<span>Filter by genre</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="close-btn" onclick={onClose} type="button">
|
<button class="close-btn" onclick={onClose} type="button" title="Close">
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -192,67 +233,142 @@
|
|||||||
onclick={handleRememberToggle}
|
onclick={handleRememberToggle}
|
||||||
type="button"
|
type="button"
|
||||||
aria-pressed={rememberSelection}
|
aria-pressed={rememberSelection}
|
||||||
|
title="Remember selection"
|
||||||
>
|
>
|
||||||
<span class="toggle-thumb"></span>
|
<span class="toggle-thumb"></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="option-item">
|
<div class="option-item">
|
||||||
<span>Show all sub-genres</span>
|
<span>Advanced view</span>
|
||||||
<button
|
<button
|
||||||
class="toggle-switch"
|
class="toggle-switch"
|
||||||
class:active={showAllGenres}
|
class:active={showAllGenres}
|
||||||
onclick={() => showAllGenres = !showAllGenres}
|
onclick={() => showAllGenres = !showAllGenres}
|
||||||
type="button"
|
type="button"
|
||||||
aria-pressed={showAllGenres}
|
aria-pressed={showAllGenres}
|
||||||
|
title="Toggle advanced view"
|
||||||
>
|
>
|
||||||
<span class="toggle-thumb"></span>
|
<span class="toggle-thumb"></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="genres-container">
|
||||||
{#if showAllGenres}
|
{#if showAllGenres}
|
||||||
<!-- Hierarchical view -->
|
<!-- Hierarchical tree view -->
|
||||||
{#each parentGenres as parent (parent.id)}
|
<div class="genre-tree">
|
||||||
{@const children = getChildGenres(parent.id)}
|
{#each filteredTree as parentNode (parentNode.genre.id)}
|
||||||
{@const parentState = getParentState(parent.id)}
|
{@const parentState = getNodeState(parentNode)}
|
||||||
<div class="genre-group">
|
{@const isExpanded = expandedNodes.has(parentNode.genre.id)}
|
||||||
<button
|
{@const hasChildren = parentNode.children.length > 0}
|
||||||
class="parent-row"
|
{@const descendantCount = countDescendants(parentNode.genre.id)}
|
||||||
class:selected={parentState === 'all'}
|
|
||||||
class:partial={parentState === 'partial'}
|
<div class="tree-node">
|
||||||
onclick={() => handleParentClick(parent.id)}
|
<div class="node-row level-0" class:selected={parentState === 'all'} class:partial={parentState === 'partial'}>
|
||||||
type="button"
|
{#if hasChildren}
|
||||||
>
|
<button class="expand-btn" onclick={() => toggleExpanded(parentNode.genre.id)} type="button" title={isExpanded ? 'Collapse' : 'Expand'}>
|
||||||
<span class="check-box" class:checked={parentState === 'all'} class:partial={parentState === 'partial'}>
|
{#if isExpanded}
|
||||||
{#if parentState === 'all'}
|
<ChevronDown size={14} />
|
||||||
<Check size={10} strokeWidth={3} />
|
{:else}
|
||||||
{:else if parentState === 'partial'}
|
<ChevronRight size={14} />
|
||||||
<Minus size={10} strokeWidth={3} />
|
{/if}
|
||||||
{/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>
|
|
||||||
</button>
|
</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>
|
</div>
|
||||||
{/if}
|
|
||||||
</div>
|
{#if isExpanded && hasChildren}
|
||||||
{/each}
|
<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}
|
{:else}
|
||||||
<!-- Simple grid view (parents only) -->
|
<!-- Simple grid view (parents only) -->
|
||||||
<div class="genres-grid">
|
<div class="genres-grid">
|
||||||
@@ -300,7 +416,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.genre-popup.expanded {
|
.genre-popup.expanded {
|
||||||
max-height: 650px;
|
width: 630px;
|
||||||
|
max-height: 700px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.popup-header {
|
.popup-header {
|
||||||
@@ -385,6 +502,46 @@
|
|||||||
transform: translateX(16px);
|
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 {
|
.genres-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -439,8 +596,7 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.genre-card.selected .genre-name,
|
.genre-card.selected .genre-name {
|
||||||
.child-card.selected .genre-name {
|
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,46 +628,103 @@
|
|||||||
transform: translate(-50%, -60%) rotate(45deg);
|
transform: translate(-50%, -60%) rotate(45deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hierarchical view */
|
/* Tree view */
|
||||||
.genre-group {
|
.genre-tree {
|
||||||
margin-bottom: 12px;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.genre-group:last-child {
|
.tree-node {
|
||||||
margin-bottom: 0;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.parent-row {
|
.node-row {
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 4px;
|
||||||
padding: 8px 12px;
|
padding: 6px 8px;
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
transition: background 150ms ease;
|
||||||
transition: all 150ms ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.parent-row:hover {
|
.node-row:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
border-color: var(--text-muted);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.parent-row.selected {
|
.node-row.selected {
|
||||||
background: var(--accent-primary);
|
background: var(--accent-primary);
|
||||||
border-color: var(--accent-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.parent-row.partial {
|
.node-row.selected:hover {
|
||||||
border-color: var(--accent-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.parent-row.selected:hover {
|
|
||||||
background: var(--accent-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 {
|
.check-box {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 16px;
|
width: 16px;
|
||||||
@@ -526,6 +739,11 @@
|
|||||||
transition: all 150ms ease;
|
transition: all 150ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.check-box.small {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.check-box.checked {
|
.check-box.checked {
|
||||||
border-color: white;
|
border-color: white;
|
||||||
background: white;
|
background: white;
|
||||||
@@ -538,81 +756,45 @@
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.parent-name {
|
.node-row.selected .check-box {
|
||||||
flex: 1;
|
border-color: white;
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
text-align: left;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.child-count {
|
.descendant-count {
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.parent-row.selected .child-count {
|
.node-row.selected .descendant-count {
|
||||||
background: rgba(255, 255, 255, 0.2);
|
background: rgba(255, 255, 255, 0.2);
|
||||||
color: rgba(255, 255, 255, 0.8);
|
color: rgba(255, 255, 255, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
.children-grid {
|
.children-container {
|
||||||
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);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
gap: 2px;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.popup-footer {
|
.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
|
* Each context (home, favorites) has independent persistence
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -13,6 +13,11 @@ export interface GenreInfo {
|
|||||||
parentId?: number;
|
parentId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GenreTreeNode {
|
||||||
|
genre: GenreInfo;
|
||||||
|
children: GenreTreeNode[];
|
||||||
|
}
|
||||||
|
|
||||||
export type GenreFilterContext = 'home' | 'favorites';
|
export type GenreFilterContext = 'home' | 'favorites';
|
||||||
|
|
||||||
interface ContextState {
|
interface ContextState {
|
||||||
@@ -24,6 +29,7 @@ interface ContextState {
|
|||||||
interface GenreFilterState {
|
interface GenreFilterState {
|
||||||
parentGenres: GenreInfo[];
|
parentGenres: GenreInfo[];
|
||||||
allGenres: GenreInfo[];
|
allGenres: GenreInfo[];
|
||||||
|
genreTree: GenreTreeNode[];
|
||||||
childrenByParent: Map<number, GenreInfo[]>;
|
childrenByParent: Map<number, GenreInfo[]>;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
contexts: Record<GenreFilterContext, ContextState>;
|
contexts: Record<GenreFilterContext, ContextState>;
|
||||||
@@ -34,12 +40,12 @@ const STORAGE_KEYS: Record<GenreFilterContext, string> = {
|
|||||||
favorites: 'qbz_genre_filter_favorites',
|
favorites: 'qbz_genre_filter_favorites',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Default context for backwards compatibility
|
|
||||||
let currentContext: GenreFilterContext = 'home';
|
let currentContext: GenreFilterContext = 'home';
|
||||||
|
|
||||||
const state: GenreFilterState = {
|
const state: GenreFilterState = {
|
||||||
parentGenres: [],
|
parentGenres: [],
|
||||||
allGenres: [],
|
allGenres: [],
|
||||||
|
genreTree: [],
|
||||||
childrenByParent: new Map(),
|
childrenByParent: new Map(),
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
contexts: {
|
contexts: {
|
||||||
@@ -102,55 +108,68 @@ export function getContext(): GenreFilterContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function loadGenres(): Promise<void> {
|
export async function loadGenres(): Promise<void> {
|
||||||
if (state.parentGenres.length > 0) return; // Already loaded
|
if (state.parentGenres.length > 0) return;
|
||||||
|
|
||||||
state.isLoading = true;
|
state.isLoading = true;
|
||||||
// Notify all contexts
|
|
||||||
notify('home');
|
notify('home');
|
||||||
notify('favorites');
|
notify('favorites');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch top-level genres first
|
// Fetch top-level genres
|
||||||
const parentGenres = await invoke<GenreInfo[]>('get_genres', {});
|
const parentGenres = await invoke<GenreInfo[]>('get_genres', {});
|
||||||
|
|
||||||
// Fetch sub-genres for each parent in parallel
|
|
||||||
const childrenByParent = new Map<number, GenreInfo[]>();
|
const childrenByParent = new Map<number, GenreInfo[]>();
|
||||||
const subGenrePromises = parentGenres.map(async (parent) => {
|
const allGenresList: GenreInfo[] = [...parentGenres];
|
||||||
try {
|
const genreTree: GenreTreeNode[] = [];
|
||||||
const children = await invoke<GenreInfo[]>('get_genres', { parentId: parent.id });
|
|
||||||
// Tag children with their parent ID
|
// Fetch level 2 and level 3 genres
|
||||||
const taggedChildren = children.map(c => ({ ...c, parentId: parent.id }));
|
for (const parent of parentGenres) {
|
||||||
childrenByParent.set(parent.id, taggedChildren);
|
const level2Children = await invoke<GenreInfo[]>('get_genres', { parentId: parent.id });
|
||||||
return taggedChildren;
|
const taggedLevel2 = level2Children.map(c => ({ ...c, parentId: parent.id }));
|
||||||
} catch {
|
childrenByParent.set(parent.id, taggedLevel2);
|
||||||
childrenByParent.set(parent.id, []);
|
allGenresList.push(...taggedLevel2);
|
||||||
return [];
|
|
||||||
|
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);
|
genreTree.push(parentNode);
|
||||||
const allSubGenres = subGenreResults.flat();
|
}
|
||||||
|
|
||||||
// Combine all genres and remove duplicates by ID
|
// Remove duplicates and sort
|
||||||
const allGenresRaw = [...parentGenres, ...allSubGenres];
|
const uniqueGenres = Array.from(
|
||||||
const allGenres = Array.from(
|
new Map(allGenresList.map(g => [g.id, g])).values()
|
||||||
new Map(allGenresRaw.map(g => [g.id, g])).values()
|
|
||||||
);
|
);
|
||||||
|
uniqueGenres.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
// Sort alphabetically by name
|
|
||||||
allGenres.sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
parentGenres.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.parentGenres = parentGenres;
|
||||||
state.allGenres = allGenres;
|
state.allGenres = uniqueGenres;
|
||||||
|
state.genreTree = genreTree;
|
||||||
state.childrenByParent = childrenByParent;
|
state.childrenByParent = childrenByParent;
|
||||||
|
|
||||||
// Load saved selections for all contexts
|
// Load saved selections
|
||||||
loadFromStorage('home');
|
loadFromStorage('home');
|
||||||
loadFromStorage('favorites');
|
loadFromStorage('favorites');
|
||||||
|
|
||||||
// Validate saved selections against available genres
|
// Validate saved selections
|
||||||
const validIds = new Set(allGenres.map((g) => g.id));
|
const validIds = new Set(uniqueGenres.map((g) => g.id));
|
||||||
for (const ctx of ['home', 'favorites'] as GenreFilterContext[]) {
|
for (const ctx of ['home', 'favorites'] as GenreFilterContext[]) {
|
||||||
const ctxState = state.contexts[ctx];
|
const ctxState = state.contexts[ctx];
|
||||||
const validSelection = new Set<number>();
|
const validSelection = new Set<number>();
|
||||||
@@ -165,6 +184,7 @@ export async function loadGenres(): Promise<void> {
|
|||||||
console.error('Failed to load genres:', e);
|
console.error('Failed to load genres:', e);
|
||||||
state.parentGenres = [];
|
state.parentGenres = [];
|
||||||
state.allGenres = [];
|
state.allGenres = [];
|
||||||
|
state.genreTree = [];
|
||||||
state.childrenByParent = new Map();
|
state.childrenByParent = new Map();
|
||||||
} finally {
|
} finally {
|
||||||
state.isLoading = false;
|
state.isLoading = false;
|
||||||
@@ -178,6 +198,7 @@ export function getGenreFilterState(context?: GenreFilterContext) {
|
|||||||
return {
|
return {
|
||||||
availableGenres: state.parentGenres,
|
availableGenres: state.parentGenres,
|
||||||
allGenres: state.allGenres,
|
allGenres: state.allGenres,
|
||||||
|
genreTree: state.genreTree,
|
||||||
selectedGenreIds: new Set(ctx.selectedGenreIds),
|
selectedGenreIds: new Set(ctx.selectedGenreIds),
|
||||||
isLoading: state.isLoading,
|
isLoading: state.isLoading,
|
||||||
rememberSelection: ctx.rememberSelection,
|
rememberSelection: ctx.rememberSelection,
|
||||||
@@ -192,10 +213,34 @@ export function getAllGenres(): GenreInfo[] {
|
|||||||
return state.allGenres;
|
return state.allGenres;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getGenreTree(): GenreTreeNode[] {
|
||||||
|
return state.genreTree;
|
||||||
|
}
|
||||||
|
|
||||||
export function getChildGenres(parentId: number): GenreInfo[] {
|
export function getChildGenres(parentId: number): GenreInfo[] {
|
||||||
return state.childrenByParent.get(parentId) || [];
|
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> {
|
export function getSelectedGenreIds(context?: GenreFilterContext): Set<number> {
|
||||||
return new Set(getContextState(context).selectedGenreIds);
|
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.
|
* Get all genre names to filter by, including all descendants of selected genres.
|
||||||
* Use this for client-side filtering to ensure albums with sub-genres are included.
|
* Traverses up to 3 levels deep.
|
||||||
*/
|
*/
|
||||||
export function getFilterGenreNames(context?: GenreFilterContext): string[] {
|
export function getFilterGenreNames(context?: GenreFilterContext): string[] {
|
||||||
const selectedIds = Array.from(getContextState(context).selectedGenreIds);
|
const selectedIds = Array.from(getContextState(context).selectedGenreIds);
|
||||||
const names = new Set<string>();
|
const names = new Set<string>();
|
||||||
|
|
||||||
for (const id of selectedIds) {
|
for (const id of selectedIds) {
|
||||||
// Add the selected genre's name
|
|
||||||
const genre = state.allGenres.find((g: GenreInfo) => g.id === id);
|
const genre = state.allGenres.find((g: GenreInfo) => g.id === id);
|
||||||
if (genre?.name) {
|
if (genre?.name) {
|
||||||
names.add(genre.name);
|
names.add(genre.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If it's a parent genre, also add all its children's names
|
// Add all descendants (children and grandchildren)
|
||||||
const children = state.childrenByParent.get(id);
|
const descendantIds = getAllDescendantIds(id);
|
||||||
if (children) {
|
for (const descId of descendantIds) {
|
||||||
for (const child of children) {
|
const descGenre = state.allGenres.find((g: GenreInfo) => g.id === descId);
|
||||||
if (child.name) {
|
if (descGenre?.name) {
|
||||||
names.add(child.name);
|
names.add(descGenre.name);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -268,6 +311,23 @@ export function toggleGenre(genreId: number, context?: GenreFilterContext): void
|
|||||||
notify(ctx);
|
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 {
|
export function selectGenre(genreId: number, context?: GenreFilterContext): void {
|
||||||
const ctx = context ?? currentContext;
|
const ctx = context ?? currentContext;
|
||||||
const ctxState = getContextState(ctx);
|
const ctxState = getContextState(ctx);
|
||||||
|
|||||||
Reference in new issue
Block a user