import { getGenres } from '../api/subsonicGenres'; import type { GenreFilterOption } from '../utils/library/albumBrowseLoad'; import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { Check, Filter, X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import FilterQuickClear from './FilterQuickClear'; import { tooltipAttrs } from './tooltipAttrs'; type GenreRow = GenreFilterOption; function mergeGenreRows( catalogGenres: GenreFilterOption[], selected: string[], ): GenreRow[] { const byGenre = new Map(); for (const { genre, count } of catalogGenres) byGenre.set(genre, count); for (const genre of selected) { if (!byGenre.has(genre)) byGenre.set(genre, 0); } return [...byGenre.entries()] .map(([genre, count]) => ({ genre, count })) .sort((a, b) => b.count - a.count || a.genre.localeCompare(b.genre)); } interface GenreFilterBarProps { selected: string[]; onSelectionChange: (selected: string[]) => void; /** * When set, only these genres are listed (e.g. from the current non-genre filters). * `undefined` = full server genre list from `getGenres`. */ catalogGenres?: GenreFilterOption[] | null; } export default function GenreFilterBar({ selected, onSelectionChange, catalogGenres, }: GenreFilterBarProps) { const { t } = useTranslation(); const [open, setOpen] = useState(false); const [genreRows, setGenreRows] = useState([]); const [search, setSearch] = useState(''); const [popStyle, setPopStyle] = useState({}); const triggerRef = useRef(null); const popRef = useRef(null); const inputRef = useRef(null); useEffect(() => { if (catalogGenres != null) { setGenreRows(mergeGenreRows(catalogGenres, selected)); return; } let cancelled = false; getGenres().then(data => { if (cancelled) return; const rows: GenreRow[] = data .map(g => ({ genre: g.value, count: g.albumCount ?? 0 })) .sort((a, b) => b.count - a.count || a.genre.localeCompare(b.genre)); setGenreRows(mergeGenreRows(rows, selected)); }); return () => { cancelled = true; }; }, [catalogGenres, selected]); const selectedSet = useMemo(() => new Set(selected), [selected]); const filteredGenres = useMemo(() => { const q = search.trim().toLowerCase(); if (!q) return genreRows; return genreRows.filter(({ genre }) => genre.toLowerCase().includes(q)); }, [genreRows, search]); const updatePopStyle = () => { if (!triggerRef.current) return; const rect = triggerRef.current.getBoundingClientRect(); const MARGIN = 6; const WIDTH = 280; const MAX_H = 360; const spaceBelow = window.innerHeight - rect.bottom - MARGIN; const spaceAbove = rect.top - MARGIN; const useAbove = spaceBelow < 160 && spaceAbove > spaceBelow; const left = Math.min( Math.max(rect.left, 8), window.innerWidth - WIDTH - 8, ); setPopStyle({ position: 'fixed', left, width: WIDTH, ...(useAbove ? { bottom: window.innerHeight - rect.top + MARGIN } : { top: rect.bottom + MARGIN }), maxHeight: Math.min(MAX_H, useAbove ? spaceAbove : spaceBelow), zIndex: 99998, }); }; useLayoutEffect(() => { if (!open) return; updatePopStyle(); setTimeout(() => inputRef.current?.focus(), 0); }, [open]); useEffect(() => { if (!open) return; const onResize = () => updatePopStyle(); window.addEventListener('resize', onResize); window.addEventListener('scroll', onResize, true); return () => { window.removeEventListener('resize', onResize); window.removeEventListener('scroll', onResize, true); }; }, [open]); useEffect(() => { if (!open) return; const onDown = (e: MouseEvent) => { if ( !triggerRef.current?.contains(e.target as Node) && !popRef.current?.contains(e.target as Node) ) setOpen(false); }; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); }; document.addEventListener('mousedown', onDown); document.addEventListener('keydown', onKey); return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey); }; }, [open]); const toggle = (genre: string) => { if (selectedSet.has(genre)) onSelectionChange(selected.filter(s => s !== genre)); else onSelectionChange([...selected, genre]); }; const clear = () => { onSelectionChange([]); setSearch(''); }; const count = selected.length; return ( <> {open && createPortal(
setSearch(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && filteredGenres.length > 0) { toggle(filteredGenres[0].genre); } }} />
{filteredGenres.length === 0 ? (
{t('common.filterNoGenres')}
) : ( filteredGenres.map(({ genre, count: albumCount }) => { const isSel = selectedSet.has(genre); return (
toggle(genre)} role="option" aria-selected={isSel} > {isSel && } {genre} {albumCount}
); }) )}
{count > 0 && (
)}
, document.body, )} ); }