mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
feat(library): "favorites only" filter on Albums, Artists, AdvancedSearch (#466)
* feat(ui): StarFilterButton component + common i18n keys Reusable toggle button for "favorites only" filtering. Three size variants for different toolbar contexts: - default: icon + label (Albums-style) - compact: icon-only with 0.5rem padding (Artists view-mode buttons) - small: icon + label at 12px / 4×14 padding (AdvancedSearch tabs) Adds common.favorites + favoritesTooltipOff/On in all 8 locales. * feat(library): "favorites only" filter on Albums, Artists, AdvancedSearch Client-side filter using the existing useMemo pipelines on each page. Reads starred state from item.starred + playerStore.starredOverrides (O(1) Map lookup, picks up live star toggles without refetch). - Albums: toolbar button (default size) next to compilation filter. - Artists: toolbar button (compact / icon-only) before the Images toggle. - AdvancedSearch: toolbar button (small) next to the result-type tabs; filters all three result categories (artists / albums / songs) and updates the count badges accordingly. Filter state is ephemeral per-page (not persisted) so users don't get surprised by hidden items after a restart. Zero extra server calls. * docs(contributors): credit + changelog entry for #466
This commit is contained in:
committed by
GitHub
parent
0fab2849e5
commit
d33abf565c
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { SlidersVertical } from 'lucide-react';
|
||||
import {
|
||||
@@ -10,7 +10,9 @@ import AlbumRow from '../components/AlbumRow';
|
||||
import ArtistRow from '../components/ArtistRow';
|
||||
import SongRow, { SongListHeader } from '../components/SongRow';
|
||||
import CustomSelect from '../components/CustomSelect';
|
||||
import StarFilterButton from '../components/StarFilterButton';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
|
||||
type ResultType = 'all' | 'artists' | 'albums' | 'songs';
|
||||
|
||||
@@ -37,10 +39,23 @@ export default function AdvancedSearch() {
|
||||
const [yearFrom, setYearFrom] = useState('');
|
||||
const [yearTo, setYearTo] = useState('');
|
||||
const [resultType, setResultType] = useState<ResultType>('all');
|
||||
const [starredOnly, setStarredOnly] = useState(false);
|
||||
const [genres, setGenres] = useState<SubsonicGenre[]>([]);
|
||||
const [results, setResults] = useState<Results | null>(null);
|
||||
const total = results
|
||||
? results.artists.length + results.albums.length + results.songs.length
|
||||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||
const filteredResults = useMemo<Results | null>(() => {
|
||||
if (!results) return null;
|
||||
if (!starredOnly) return results;
|
||||
const isFav = (id: string, base: boolean | string | undefined) =>
|
||||
id in starredOverrides ? !!starredOverrides[id] : !!base;
|
||||
return {
|
||||
artists: results.artists.filter(a => isFav(a.id, a.starred)),
|
||||
albums: results.albums.filter(a => isFav(a.id, a.starred)),
|
||||
songs: results.songs.filter(s => isFav(s.id, s.starred)),
|
||||
};
|
||||
}, [results, starredOnly, starredOverrides]);
|
||||
const total = filteredResults
|
||||
? filteredResults.artists.length + filteredResults.albums.length + filteredResults.songs.length
|
||||
: 0;
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
@@ -260,7 +275,7 @@ export default function AdvancedSearch() {
|
||||
|
||||
{/* Row 3: Result type + Search button */}
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', gap: '0.3rem', flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', gap: '0.3rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{typeOptions.map(opt => (
|
||||
<button
|
||||
key={opt.id}
|
||||
@@ -272,6 +287,7 @@ export default function AdvancedSearch() {
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
<StarFilterButton size="small" active={starredOnly} onChange={setStarredOnly} />
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
@@ -303,21 +319,21 @@ export default function AdvancedSearch() {
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
|
||||
|
||||
{results && results.artists.length > 0 && (
|
||||
{filteredResults && filteredResults.artists.length > 0 && (
|
||||
<ArtistRow
|
||||
title={`${t('search.artists')} (${results.artists.length})`}
|
||||
artists={results.artists}
|
||||
title={`${t('search.artists')} (${filteredResults.artists.length})`}
|
||||
artists={filteredResults.artists}
|
||||
/>
|
||||
)}
|
||||
|
||||
{results && results.albums.length > 0 && (
|
||||
{filteredResults && filteredResults.albums.length > 0 && (
|
||||
<AlbumRow
|
||||
title={`${t('search.albums')} (${results.albums.length})`}
|
||||
albums={results.albums}
|
||||
title={`${t('search.albums')} (${filteredResults.albums.length})`}
|
||||
albums={filteredResults.albums}
|
||||
/>
|
||||
)}
|
||||
|
||||
{results && results.songs.length > 0 && (
|
||||
{filteredResults && filteredResults.songs.length > 0 && (
|
||||
<section>
|
||||
<h2 className="section-title" style={{ marginBottom: '0.75rem' }}>
|
||||
{t('search.songs')}
|
||||
@@ -328,7 +344,7 @@ export default function AdvancedSearch() {
|
||||
)}
|
||||
</h2>
|
||||
<SongListHeader />
|
||||
{results.songs.map(song => (
|
||||
{filteredResults.songs.map(song => (
|
||||
<SongRow key={song.id} song={song} />
|
||||
))}
|
||||
{songsHasMore && (
|
||||
|
||||
+13
-4
@@ -2,6 +2,7 @@ import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react'
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import GenreFilterBar from '../components/GenreFilterBar';
|
||||
import YearFilterButton from '../components/YearFilterButton';
|
||||
import StarFilterButton from '../components/StarFilterButton';
|
||||
import SortDropdown from '../components/SortDropdown';
|
||||
import { getAlbumList, getAlbumsByGenre, getAlbum, SubsonicAlbum, buildDownloadUrl } from '../api/subsonic';
|
||||
import { songToTrack } from '../store/playerStore';
|
||||
@@ -50,6 +51,7 @@ export default function Albums() {
|
||||
const [yearFrom, setYearFrom] = useState('');
|
||||
const [yearTo, setYearTo] = useState('');
|
||||
const [compFilter, setCompFilter] = useState<CompFilter>('all');
|
||||
const [starredOnly, setStarredOnly] = useState(false);
|
||||
const observerTarget = useRef<HTMLDivElement>(null);
|
||||
|
||||
// ── Multi-selection ──────────────────────────────────────────────────────
|
||||
@@ -74,11 +76,16 @@ export default function Albums() {
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||
const visibleAlbums = useMemo(() => {
|
||||
if (compFilter === 'all') return albums;
|
||||
if (compFilter === 'only') return albums.filter(a => a.isCompilation);
|
||||
return albums.filter(a => !a.isCompilation);
|
||||
}, [albums, compFilter]);
|
||||
let out = albums;
|
||||
if (compFilter === 'only') out = out.filter(a => a.isCompilation);
|
||||
else if (compFilter === 'hide') out = out.filter(a => !a.isCompilation);
|
||||
if (starredOnly) {
|
||||
out = out.filter(a => a.id in starredOverrides ? starredOverrides[a.id] : !!a.starred);
|
||||
}
|
||||
return out;
|
||||
}, [albums, compFilter, starredOnly, starredOverrides]);
|
||||
|
||||
const selectedAlbums = visibleAlbums.filter(a => selectedIds.has(a.id));
|
||||
const openContextMenu = usePlayerStore(state => state.openContextMenu);
|
||||
@@ -261,6 +268,8 @@ export default function Albums() {
|
||||
|
||||
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
|
||||
|
||||
<StarFilterButton active={starredOnly} onChange={setStarredOnly} />
|
||||
|
||||
<button
|
||||
className={`btn btn-surface${compFilter !== 'all' ? ' btn-sort-active' : ''}`}
|
||||
onClick={cycleCompFilter}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react'
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getArtists, SubsonicArtist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import { LayoutGrid, List, Images, CheckSquare2, ListMusic, Check } from 'lucide-react';
|
||||
import StarFilterButton from '../components/StarFilterButton';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
@@ -79,6 +80,7 @@ export default function Artists() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [letterFilter, setLetterFilter] = useState(ALL_SENTINEL);
|
||||
const [starredOnly, setStarredOnly] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
|
||||
const showArtistImages = useAuthStore(s => s.showArtistImages);
|
||||
@@ -129,8 +131,9 @@ export default function Artists() {
|
||||
// Reset infinite scroll when filters or image setting change
|
||||
useEffect(() => {
|
||||
setVisibleCount(PAGE_SIZE);
|
||||
}, [filter, letterFilter, viewMode, PAGE_SIZE]);
|
||||
}, [filter, letterFilter, starredOnly, viewMode, PAGE_SIZE]);
|
||||
|
||||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||
// Filter pipeline — memoised so unrelated state changes (selection mode,
|
||||
// viewMode, etc.) don't re-iterate the full artists array. With 5000+
|
||||
// artists each re-render walked the list twice without this.
|
||||
@@ -148,8 +151,11 @@ export default function Artists() {
|
||||
const needle = filter.toLowerCase();
|
||||
out = out.filter(a => a.name.toLowerCase().includes(needle));
|
||||
}
|
||||
if (starredOnly) {
|
||||
out = out.filter(a => a.id in starredOverrides ? starredOverrides[a.id] : !!a.starred);
|
||||
}
|
||||
return out;
|
||||
}, [artists, letterFilter, filter]);
|
||||
}, [artists, letterFilter, filter, starredOnly, starredOverrides]);
|
||||
|
||||
const visible = useMemo(() => filtered.slice(0, visibleCount), [filtered, visibleCount]);
|
||||
const hasMore = visibleCount < filtered.length;
|
||||
@@ -200,6 +206,7 @@ export default function Artists() {
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
{!(selectionMode && selectedIds.size > 0) && (<>
|
||||
<StarFilterButton size="compact" active={starredOnly} onChange={setStarredOnly} />
|
||||
<button
|
||||
className={`btn btn-surface`}
|
||||
onClick={() => setShowArtistImages(!showArtistImages)}
|
||||
|
||||
@@ -361,6 +361,7 @@ const CONTRIBUTORS = [
|
||||
'Tracks: Highly Rated rail and per-card star display, with cache layer for ndListSongs (PR #443)',
|
||||
'Random Mix: playlist-size picker (50/75/100/125/150) and filter-panel layout cleanup (PR #445)',
|
||||
'Queue: optional "Preserve Play Next order" toggle — multiple Play Next inserts queue up behind each other instead of latest-on-top (PR #464)',
|
||||
'Library: "favorites only" filter on Albums, Artists and Advanced Search — toolbar toggle reading star overrides live (PR #466)',
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
Reference in New Issue
Block a user