diff --git a/src/components/GenreFilterBar.tsx b/src/components/GenreFilterBar.tsx deleted file mode 100644 index 67ce5655..00000000 --- a/src/components/GenreFilterBar.tsx +++ /dev/null @@ -1,238 +0,0 @@ -import { getGenres } from '@/lib/api/subsonicGenres'; -import type { GenreFilterOption } from '@/lib/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 '@/ui/FilterQuickClear'; -import { tooltipAttrs } from '@/ui/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) { - // React Compiler set-state-in-effect rule: state set from an async result resolved in this effect. - // eslint-disable-next-line react-hooks/set-state-in-effect - 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, - )} - - ); -} diff --git a/src/features/album/pages/Albums.tsx b/src/features/album/pages/Albums.tsx index 901291b0..664fe35d 100644 --- a/src/features/album/pages/Albums.tsx +++ b/src/features/album/pages/Albums.tsx @@ -8,7 +8,7 @@ import { useLibraryCoverPrefetch } from '@/cover/useLibraryCoverPrefetch'; import { useAuthStore } from '@/store/authStore'; import { clampLibraryGridMaxColumns } from '@/store/authStoreHelpers'; import { computeCardGridColumnCount } from '@/utils/cardGridLayout'; -import GenreFilterBar from '@/components/GenreFilterBar'; +import GenreFilterBar from '@/ui/GenreFilterBar'; import YearFilterButton from '@/ui/YearFilterButton'; import StarFilterButton from '@/ui/StarFilterButton'; import LosslessFilterButton from '@/ui/LosslessFilterButton'; diff --git a/src/features/album/pages/NewReleases.tsx b/src/features/album/pages/NewReleases.tsx index 036d3f56..3bce90c2 100644 --- a/src/features/album/pages/NewReleases.tsx +++ b/src/features/album/pages/NewReleases.tsx @@ -8,7 +8,7 @@ import { useEffect, useLayoutEffect, useState, useCallback, useRef, useMemo } fr import { Download, HardDriveDownload } from 'lucide-react'; import SelectionToggleButton from '@/ui/SelectionToggleButton'; import AlbumCard from '@/features/album/components/AlbumCard'; -import GenreFilterBar from '@/components/GenreFilterBar'; +import GenreFilterBar from '@/ui/GenreFilterBar'; import { useTranslation } from 'react-i18next'; import { useLocation, useNavigate } from 'react-router-dom'; import { useAuthStore } from '@/store/authStore'; diff --git a/src/features/album/pages/RandomAlbums.tsx b/src/features/album/pages/RandomAlbums.tsx index d10967ce..1ff007c2 100644 --- a/src/features/album/pages/RandomAlbums.tsx +++ b/src/features/album/pages/RandomAlbums.tsx @@ -9,7 +9,7 @@ import React, { useEffect, useLayoutEffect, useState, useCallback, useRef } from import { RefreshCw, Download, HardDriveDownload } from 'lucide-react'; import SelectionToggleButton from '@/ui/SelectionToggleButton'; import AlbumCard from '@/features/album/components/AlbumCard'; -import GenreFilterBar from '@/components/GenreFilterBar'; +import GenreFilterBar from '@/ui/GenreFilterBar'; import { useTranslation } from 'react-i18next'; import { useLocation, useNavigate } from 'react-router-dom'; import { useAuthStore } from '@/store/authStore'; diff --git a/src/features/favorites/components/FavoritesSongsSectionHeader.tsx b/src/features/favorites/components/FavoritesSongsSectionHeader.tsx index df9179f8..9b0a1a05 100644 --- a/src/features/favorites/components/FavoritesSongsSectionHeader.tsx +++ b/src/features/favorites/components/FavoritesSongsSectionHeader.tsx @@ -6,7 +6,7 @@ import { usePlayerStore } from '@/features/playback/store/playerStore'; import { useSelectionStore } from '@/store/selectionStore'; import { songToTrack } from '@/lib/media/songToTrack'; import { AddToPlaylistSubmenu } from '@/components/ContextMenu'; -import GenreFilterBar from '@/components/GenreFilterBar'; +import GenreFilterBar from '@/ui/GenreFilterBar'; interface Props { visibleSongs: SubsonicSong[]; diff --git a/src/components/SongCard.test.tsx b/src/features/home/components/SongCard.test.tsx similarity index 89% rename from src/components/SongCard.test.tsx rename to src/features/home/components/SongCard.test.tsx index 9178ef9c..3091ea5f 100644 --- a/src/components/SongCard.test.tsx +++ b/src/features/home/components/SongCard.test.tsx @@ -1,8 +1,8 @@ import { describe, expect, it, vi } from 'vitest'; import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { renderWithProviders } from '../test/helpers/renderWithProviders'; -import SongCard from './SongCard'; +import { renderWithProviders } from '@/test/helpers/renderWithProviders'; +import SongCard from '@/features/home/components/SongCard'; import type { SubsonicSong } from '@/lib/api/subsonicTypes'; const navigateToArtist = vi.fn(); @@ -14,7 +14,7 @@ vi.mock('@/features/artist/hooks/useNavigateToArtist', () => ({ useNavigateToArtist: () => navigateToArtist, })); -vi.mock('../cover/useLibraryCoverRef', () => ({ +vi.mock('@/cover/useLibraryCoverRef', () => ({ useTrackCoverRef: () => undefined, })); diff --git a/src/components/SongCard.tsx b/src/features/home/components/SongCard.tsx similarity index 96% rename from src/components/SongCard.tsx rename to src/features/home/components/SongCard.tsx index 415482cb..607a936c 100644 --- a/src/components/SongCard.tsx +++ b/src/features/home/components/SongCard.tsx @@ -4,10 +4,10 @@ import React, { memo, useMemo } from 'react'; import { Play, ListPlus, Star, Disc3 } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { usePlayerStore } from '@/features/playback/store/playerStore'; -import { CoverArtImage } from '../cover/CoverArtImage'; -import { useCoverArt } from '../cover/useCoverArt'; -import { useTrackCoverRef } from '../cover/useLibraryCoverRef'; -import { COVER_DENSE_RAIL_CELL_CSS_PX } from '../cover/layoutSizes'; +import { CoverArtImage } from '@/cover/CoverArtImage'; +import { useCoverArt } from '@/cover/useCoverArt'; +import { useTrackCoverRef } from '@/cover/useLibraryCoverRef'; +import { COVER_DENSE_RAIL_CELL_CSS_PX } from '@/cover/layoutSizes'; import { enqueueAndPlay } from '@/features/playback/utils/playback/playSong'; import { useDragDrop } from '@/lib/dnd/DragDropContext'; import { useOrbitSongRowBehavior } from '@/features/orbit'; diff --git a/src/features/home/components/SongRail.tsx b/src/features/home/components/SongRail.tsx index ec7904d8..fc035bb5 100644 --- a/src/features/home/components/SongRail.tsx +++ b/src/features/home/components/SongRail.tsx @@ -1,7 +1,7 @@ import type { SubsonicSong } from '@/lib/api/subsonicTypes'; import React, { useRef, useState, useEffect, useMemo } from 'react'; import { ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react'; -import SongCard from '@/components/SongCard'; +import SongCard from '@/features/home/components/SongCard'; import { usePerfProbeFlags } from '@/utils/perf/perfFlags'; import { dedupeById } from '@/lib/util/dedupeById';