diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f2bb959..65b40b9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -133,6 +133,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Right-clicking a playlist card now offers **Play next** and **Add to queue** alongside **Play now**, matching the album card. All three honour offline mode and the active multi-library filter. +### Playlists browse — scoped header search + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#1308](https://github.com/Psychotoxical/psysonic/pull/1308)** + +* The header search field on the Playlists page now filters the list by playlist name (same scoped badge pattern as Artists / Albums), including in folder view. + ## Changed diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 6ddc8ee8..2aff7c1a 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -207,6 +207,7 @@ const CONTRIBUTOR_ENTRIES = [ 'Persistent shuffle mode — queue-reordering shuffle with restore, survives restart, in sync with other devices and Orbit (request: mikmik on Psysonic Discord, PR #1288)', 'Playlist and radio custom covers — preserve Navidrome pl-/ra-* getCoverArt ids (fixes blank uploaded covers; PR #1295)', 'Playlist cards — Play next and Add to queue from the right-click menu, matching album cards (PR #1307)', + 'Playlists browse — scoped header search by playlist name (PR #1308)', ], }, { diff --git a/src/features/playlist/components/PlaylistsFolderView.tsx b/src/features/playlist/components/PlaylistsFolderView.tsx index 4180c224..a96f5a59 100644 --- a/src/features/playlist/components/PlaylistsFolderView.tsx +++ b/src/features/playlist/components/PlaylistsFolderView.tsx @@ -10,6 +10,8 @@ interface Props { playlists: SubsonicPlaylist[]; renderCard: (pl: SubsonicPlaylist) => React.ReactNode; disableVirtualization: boolean; + /** When true, omit folder sections that currently have no matching playlists. */ + hideEmptyFolders?: boolean; } /** @@ -18,17 +20,26 @@ interface Props { * virtualisation match the flat grid; only the grouping differs. Rendered only * when at least one folder exists (the page falls back to the plain grid). */ -export default function PlaylistsFolderView({ serverId, playlists, renderCard, disableVirtualization }: Props) { +export default function PlaylistsFolderView({ + serverId, + playlists, + renderCard, + disableVirtualization, + hideEmptyFolders = false, +}: Props) { const bucket = usePlaylistFolderStore(s => s.byServer[serverId]) ?? EMPTY_SERVER_FOLDERS; const { isDragging } = useDragDrop(); const grouped = groupPlaylistsByFolder(playlists, bucket.folders, bucket.assignments); // Keep the ungrouped section as a drop target during a drag even when empty, // so a playlist filed into a folder can always be dragged back out to root. const showUngrouped = grouped.ungrouped.length > 0 || isDragging; + const folderSections = hideEmptyFolders && !isDragging + ? grouped.folders.filter(({ playlists: items }) => items.length > 0) + : grouped.folders; return (
- {grouped.folders.map(({ folder, playlists: items }) => ( + {folderSections.map(({ folder, playlists: items }) => ( s.touchPlaylist); const removeId = usePlaylistStore((s) => s.removeId); const playlists = usePlaylistStore((s) => s.playlists); + const playlistsSearchQuery = useScopedBrowseSearchQuery('playlists'); + const visiblePlaylists = useMemo( + () => filterPlaylistsByNameQuery(playlists, playlistsSearchQuery), + [playlists, playlistsSearchQuery], + ); + const textSearchActive = playlistsSearchQuery.trim().length > 0; const fetchPlaylists = usePlaylistStore((s) => s.fetchPlaylists); const activeUsername = useAuthStore(s => s.getActiveServer()?.username ?? ''); const activeServerId = useAuthStore(s => s.activeServerId); @@ -71,12 +79,37 @@ export default function Playlists() { // ── Multi-selection ────────────────────────────────────────────────────── const [selectionMode, setSelectionMode] = useState(false); - const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(playlists); + const { + selectedIds, + setSelectedIds, + toggleSelect, + clearSelection: resetSelection, + } = useRangeSelection(visiblePlaylists); const isNavidromeServer = Boolean( activeServerId && (subsonicIdentityByServer[activeServerId]?.type ?? '').toLowerCase() === 'navidrome', ); + // Intersect with the visible list so header/bulk actions never count hidden ids + // (even for the render before the prune effect below runs). + const visibleSelectedIds = useMemo(() => { + if (selectedIds.size === 0) return selectedIds; + const visibleIds = new Set(visiblePlaylists.map(p => p.id)); + let changed = false; + const next = new Set(); + for (const id of selectedIds) { + if (visibleIds.has(id)) next.add(id); + else changed = true; + } + return changed ? next : selectedIds; + }, [selectedIds, visiblePlaylists]); + + // Drop ids that the scoped search hid so range-select state stays coherent. + useEffect(() => { + if (visibleSelectedIds === selectedIds) return; + setSelectedIds(visibleSelectedIds); + }, [visibleSelectedIds, selectedIds, setSelectedIds]); + const toggleSelectionMode = () => { setSelectionMode(v => !v); resetSelection(); @@ -87,7 +120,7 @@ export default function Playlists() { resetSelection(); }; - const selectedPlaylists = playlists.filter(p => selectedIds.has(p.id)); + const selectedPlaylists = visiblePlaylists.filter(p => visibleSelectedIds.has(p.id)); const isPlaylistDeletable = useCallback((pl: SubsonicPlaylist) => { if (!pl.owner) return true; if (!activeUsername) return false; @@ -156,7 +189,7 @@ export default function Playlists() { }); const handleDeleteSelected = () => runPlaylistDeleteSelected({ - selectedPlaylists, selectedIds, isPlaylistDeletable, removeId, clearSelection, t, + selectedPlaylists, isPlaylistDeletable, removeId, clearSelection, t, }); const renderCard = (pl: SubsonicPlaylist) => ( @@ -164,7 +197,7 @@ export default function Playlists() { pl={pl} selectionMode={selectionMode} draggable={showFolderView} - selectedIds={selectedIds} + selectedIds={visibleSelectedIds} selectedPlaylists={selectedPlaylists} toggleSelect={toggleSelect} isPlaylistDeletable={isPlaylistDeletable} @@ -237,7 +270,7 @@ export default function Playlists() { {t('playlists.empty')}
+ ) : visiblePlaylists.length === 0 && textSearchActive ? ( +
{t('playlists.noMatchingSearch')}
) : ( <> {showFolderView && ( @@ -284,17 +319,18 @@ export default function Playlists() { {showFolderView && activeServerId ? ( ) : ( pl.id} rowVariant="playlist" disableVirtualization={perfFlags.disableMainstageVirtualLists} - layoutSignal={playlists.length} + layoutSignal={visiblePlaylists.length} renderItem={renderCard} /> )} diff --git a/src/features/playlist/utils/playlistsBrowseSearch.test.ts b/src/features/playlist/utils/playlistsBrowseSearch.test.ts new file mode 100644 index 00000000..9761aafb --- /dev/null +++ b/src/features/playlist/utils/playlistsBrowseSearch.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import type { SubsonicPlaylist } from '@/lib/api/subsonicTypes'; +import { + filterPlaylistsByNameQuery, + isPlaylistsBrowsePath, +} from './playlistsBrowseSearch'; + +function pl(id: string, name: string): SubsonicPlaylist { + return { id, name, songCount: 0, duration: 0, created: '', changed: '' }; +} + +describe('isPlaylistsBrowsePath', () => { + it('matches only the playlists list route', () => { + expect(isPlaylistsBrowsePath('/playlists')).toBe(true); + expect(isPlaylistsBrowsePath('/playlists/abc')).toBe(false); + expect(isPlaylistsBrowsePath('/artists')).toBe(false); + }); +}); + +describe('filterPlaylistsByNameQuery', () => { + const list = [pl('1', 'Road Trip'), pl('2', 'Focus Mix'), pl('3', 'road house')]; + + it('returns all playlists when query is empty', () => { + expect(filterPlaylistsByNameQuery(list, ' ')).toEqual(list); + }); + + it('filters by case-insensitive name substring', () => { + expect(filterPlaylistsByNameQuery(list, 'road').map(p => p.id)).toEqual(['1', '3']); + expect(filterPlaylistsByNameQuery(list, 'FOCUS').map(p => p.id)).toEqual(['2']); + }); +}); diff --git a/src/features/playlist/utils/playlistsBrowseSearch.ts b/src/features/playlist/utils/playlistsBrowseSearch.ts new file mode 100644 index 00000000..4d9180ad --- /dev/null +++ b/src/features/playlist/utils/playlistsBrowseSearch.ts @@ -0,0 +1,16 @@ +import type { SubsonicPlaylist } from '@/lib/api/subsonicTypes'; + +/** True when pathname is the Playlists browse route (`/playlists`). */ +export function isPlaylistsBrowsePath(pathname: string): boolean { + return pathname === '/playlists'; +} + +/** Scoped Playlists text search — playlist name only. */ +export function filterPlaylistsByNameQuery( + playlists: SubsonicPlaylist[], + query: string, +): SubsonicPlaylist[] { + const needle = query.trim().toLowerCase(); + if (!needle) return playlists; + return playlists.filter(p => (p.name ?? '').toLowerCase().includes(needle)); +} diff --git a/src/features/playlist/utils/runPlaylistsActions.ts b/src/features/playlist/utils/runPlaylistsActions.ts index 0a078c0a..0e9965d5 100644 --- a/src/features/playlist/utils/runPlaylistsActions.ts +++ b/src/features/playlist/utils/runPlaylistsActions.ts @@ -42,7 +42,6 @@ export async function runPlaylistDelete(deps: RunPlaylistDeleteDeps): Promise; isPlaylistDeletable: (pl: SubsonicPlaylist) => boolean; removeId: (id: string) => void; clearSelection: () => void; @@ -50,26 +49,26 @@ export interface RunPlaylistDeleteSelectedDeps { } export async function runPlaylistDeleteSelected(deps: RunPlaylistDeleteSelectedDeps): Promise { - const { selectedPlaylists, selectedIds, isPlaylistDeletable, removeId, clearSelection, t } = deps; + const { selectedPlaylists, isPlaylistDeletable, removeId, clearSelection, t } = deps; const deletable = selectedPlaylists.filter(isPlaylistDeletable); if (deletable.length === 0) return; - let deleted = 0; + const removedIds = new Set(); for (const pl of deletable) { try { await deletePlaylist(pl.id); removeId(pl.id); - deleted++; + removedIds.add(pl.id); } catch { showToast(t('playlists.deleteFailed', { name: pl.name }), 3000, 'error'); } } - usePlaylistStore.setState((s) => ({ - playlists: s.playlists.filter((p) => !(selectedIds.has(p.id) && isPlaylistDeletable(p))), - })); - clearSelection(); - if (deleted > 0) { - showToast(t('playlists.deleteSuccess', { count: deleted }), 3000, 'info'); + if (removedIds.size > 0) { + usePlaylistStore.setState((s) => ({ + playlists: s.playlists.filter((p) => !removedIds.has(p.id)), + })); + showToast(t('playlists.deleteSuccess', { count: removedIds.size }), 3000, 'info'); } + clearSelection(); } export interface RunPlaylistMergeSelectedDeps { diff --git a/src/features/search/components/liveSearchScope.ts b/src/features/search/components/liveSearchScope.ts index 63841eb0..dd854dcb 100644 --- a/src/features/search/components/liveSearchScope.ts +++ b/src/features/search/components/liveSearchScope.ts @@ -5,6 +5,7 @@ import { isAlbumsBrowsePath, isNewReleasesBrowsePath } from '@/features/album'; import { isTracksBrowsePath } from '@/store/advancedSearchSessionStore'; import { isArtistsBrowsePath } from '@/features/artist'; import { isComposersBrowsePath } from '@/features/composers'; +import { isPlaylistsBrowsePath } from '@/features/playlist'; export const SCOPE_NAV_ITEM: Record = { artists: 'artists', @@ -12,6 +13,7 @@ export const SCOPE_NAV_ITEM: Record newReleases: 'newReleases', tracks: 'tracks', composers: 'composers', + playlists: 'playlists', }; /** Scope to restore when on a browse route but the badge was cleared (global search mode). */ @@ -25,6 +27,7 @@ export function resolveLiveSearchScopeGhost( if (isNewReleasesBrowsePath(pathname)) return 'newReleases'; if (isTracksBrowsePath(pathname)) return 'tracks'; if (isComposersBrowsePath(pathname)) return 'composers'; + if (isPlaylistsBrowsePath(pathname)) return 'playlists'; return null; } @@ -40,6 +43,8 @@ export function liveSearchScopePlaceholderKey(scope: LiveSearchScope | null): st return 'search.scopeTracksPlaceholder'; case 'composers': return 'search.scopeComposersPlaceholder'; + case 'playlists': + return 'search.scopePlaylistsPlaceholder'; default: return 'search.placeholder'; } @@ -62,6 +67,8 @@ export function liveSearchScopeBadgeTooltipKey(scope: LiveSearchScope): string { return 'search.scopeTracksBadgeTooltip'; case 'composers': return 'search.scopeComposersBadgeTooltip'; + case 'playlists': + return 'search.scopePlaylistsBadgeTooltip'; default: return 'search.scopeArtistsBadgeTooltip'; } @@ -79,6 +86,8 @@ export function liveSearchScopeGhostTooltipKey(scope: LiveSearchScope): string { return 'search.scopeTracksGhostTooltip'; case 'composers': return 'search.scopeComposersGhostTooltip'; + case 'playlists': + return 'search.scopePlaylistsGhostTooltip'; default: return 'search.scopeArtistsGhostTooltip'; } diff --git a/src/features/search/components/liveSearchScopeUi.test.ts b/src/features/search/components/liveSearchScopeUi.test.ts index 33e6297b..867b767d 100644 --- a/src/features/search/components/liveSearchScopeUi.test.ts +++ b/src/features/search/components/liveSearchScopeUi.test.ts @@ -41,6 +41,9 @@ describe('resolveLiveSearchScopeGhost', () => { expect(resolveLiveSearchScopeGhost('/tracks', 'tracks')).toBeNull(); expect(resolveLiveSearchScopeGhost('/composers', null)).toBe('composers'); expect(resolveLiveSearchScopeGhost('/composers', 'composers')).toBeNull(); + expect(resolveLiveSearchScopeGhost('/playlists', null)).toBe('playlists'); + expect(resolveLiveSearchScopeGhost('/playlists', 'playlists')).toBeNull(); + expect(resolveLiveSearchScopeGhost('/playlists/abc', null)).toBeNull(); }); }); @@ -128,6 +131,7 @@ describe('liveSearchScopePlaceholderKey', () => { expect(liveSearchScopePlaceholderKey('newReleases')).toBe('search.scopeNewReleasesPlaceholder'); expect(liveSearchScopePlaceholderKey('tracks')).toBe('search.scopeTracksPlaceholder'); expect(liveSearchScopePlaceholderKey('composers')).toBe('search.scopeComposersPlaceholder'); + expect(liveSearchScopePlaceholderKey('playlists')).toBe('search.scopePlaylistsPlaceholder'); expect(liveSearchScopePlaceholderKey(null)).toBe('search.placeholder'); }); }); diff --git a/src/features/search/hooks/useLiveSearchRouteScope.test.ts b/src/features/search/hooks/useLiveSearchRouteScope.test.ts index 8f5ca195..ebc25081 100644 --- a/src/features/search/hooks/useLiveSearchRouteScope.test.ts +++ b/src/features/search/hooks/useLiveSearchRouteScope.test.ts @@ -16,6 +16,9 @@ describe('syncLiveSearchRouteScope', () => { syncLiveSearchRouteScope('/composers'); expect(useLiveSearchScopeStore.getState().scope).toBe('composers'); + + syncLiveSearchRouteScope('/playlists'); + expect(useLiveSearchScopeStore.getState().scope).toBe('playlists'); }); it('clears scope and query when leaving browse routes', () => { @@ -27,6 +30,15 @@ describe('syncLiveSearchRouteScope', () => { expect(useLiveSearchScopeStore.getState().query).toBe(''); }); + it('clears scope when entering playlist detail', () => { + useLiveSearchScopeStore.setState({ query: 'road', scope: 'playlists' }); + + syncLiveSearchRouteScope('/playlists/abc123'); + + expect(useLiveSearchScopeStore.getState().scope).toBeNull(); + expect(useLiveSearchScopeStore.getState().query).toBe(''); + }); + it('clears query when leaving browse with scope already cleared (ghost mode)', () => { useLiveSearchScopeStore.setState({ query: 'beatles', scope: null }); diff --git a/src/features/search/hooks/useLiveSearchRouteScope.ts b/src/features/search/hooks/useLiveSearchRouteScope.ts index 69f34645..bfc23116 100644 --- a/src/features/search/hooks/useLiveSearchRouteScope.ts +++ b/src/features/search/hooks/useLiveSearchRouteScope.ts @@ -4,6 +4,7 @@ import { isAlbumsBrowsePath, isNewReleasesBrowsePath } from '@/features/album'; import { isArtistsBrowsePath } from '@/features/artist'; import { isTracksBrowsePath } from '@/store/advancedSearchSessionStore'; import { isComposersBrowsePath } from '@/features/composers'; +import { isPlaylistsBrowsePath } from '@/features/playlist'; import { useLiveSearchScopeStore } from '@/store/liveSearchScopeStore'; /** Keep scope badge in sync with browse routes; clear field text when leaving browse. */ @@ -20,6 +21,8 @@ export function syncLiveSearchRouteScope(pathname: string): void { store.setScope('tracks'); } else if (isComposersBrowsePath(pathname)) { store.setScope('composers'); + } else if (isPlaylistsBrowsePath(pathname)) { + store.setScope('playlists'); } else { if (store.scope != null) store.clearScope(); if (store.query !== '') store.setQuery(''); diff --git a/src/locales/bg/playlists.ts b/src/locales/bg/playlists.ts index 06e2c284..72f04c44 100644 --- a/src/locales/bg/playlists.ts +++ b/src/locales/bg/playlists.ts @@ -9,6 +9,7 @@ export const playlists = { create: 'Създай', cancel: 'Отказ', empty: 'Все още няма плейлисти.', + noMatchingSearch: 'Няма плейлисти, които съответстват на търсенето.', emptyPlaylist: 'Този плейлист е празен.', addFirstSong: 'Добавете първата си песен', notFound: 'Плейлистът не е намерен.', diff --git a/src/locales/bg/search.ts b/src/locales/bg/search.ts index f04c3ca0..b5e6e3b7 100644 --- a/src/locales/bg/search.ts +++ b/src/locales/bg/search.ts @@ -60,6 +60,9 @@ export const search = { scopeComposersPlaceholder: 'Търси композитор…', scopeComposersBadgeTooltip: 'Кликнете, за да премахнете', scopeComposersGhostTooltip: 'Кликнете, за да търсите само на тази страница', + scopePlaylistsPlaceholder: 'Търси плейлист…', + scopePlaylistsBadgeTooltip: 'Кликнете, за да премахнете', + scopePlaylistsGhostTooltip: 'Кликнете, за да търсите само на тази страница', genres: 'Жанрове', shareLink: 'Връзка за споделяне', shareTrackTitle: 'Споделена песен', diff --git a/src/locales/de/playlists.ts b/src/locales/de/playlists.ts index 98b50b99..ef5736df 100644 --- a/src/locales/de/playlists.ts +++ b/src/locales/de/playlists.ts @@ -9,6 +9,7 @@ export const playlists = { create: 'Erstellen', cancel: 'Abbrechen', empty: 'Noch keine Playlists.', + noMatchingSearch: 'Keine Playlists entsprechen deiner Suche.', emptyPlaylist: 'Diese Playlist ist leer.', addFirstSong: 'Ersten Song hinzufügen', notFound: 'Playlist nicht gefunden.', diff --git a/src/locales/de/search.ts b/src/locales/de/search.ts index 95a76b2d..764d69cf 100644 --- a/src/locales/de/search.ts +++ b/src/locales/de/search.ts @@ -60,6 +60,9 @@ export const search = { scopeComposersPlaceholder: 'Komponist suchen…', scopeComposersBadgeTooltip: 'Klicken — entfernen', scopeComposersGhostTooltip: 'Klicken — nur auf dieser Seite suchen', + scopePlaylistsPlaceholder: 'Playlist suchen…', + scopePlaylistsBadgeTooltip: 'Klicken — entfernen', + scopePlaylistsGhostTooltip: 'Klicken — nur auf dieser Seite suchen', genres: 'Genres', shareLink: 'Share link', shareTrackTitle: 'Shared track', diff --git a/src/locales/en/playlists.ts b/src/locales/en/playlists.ts index 81712ed5..c9e8f74c 100644 --- a/src/locales/en/playlists.ts +++ b/src/locales/en/playlists.ts @@ -9,6 +9,7 @@ export const playlists = { create: 'Create', cancel: 'Cancel', empty: 'No playlists yet.', + noMatchingSearch: 'No playlists match your search.', emptyPlaylist: 'This playlist is empty.', addFirstSong: 'Add your first song', notFound: 'Playlist not found.', diff --git a/src/locales/en/search.ts b/src/locales/en/search.ts index cbd0fa5d..bf4200fa 100644 --- a/src/locales/en/search.ts +++ b/src/locales/en/search.ts @@ -60,6 +60,9 @@ export const search = { scopeComposersPlaceholder: 'Search for composer…', scopeComposersBadgeTooltip: 'Click to remove', scopeComposersGhostTooltip: 'Click to search on this page only', + scopePlaylistsPlaceholder: 'Search for playlist…', + scopePlaylistsBadgeTooltip: 'Click to remove', + scopePlaylistsGhostTooltip: 'Click to search on this page only', genres: 'Genres', shareLink: 'Share link', shareTrackTitle: 'Shared track', diff --git a/src/locales/es/playlists.ts b/src/locales/es/playlists.ts index f70d0a59..c016144f 100644 --- a/src/locales/es/playlists.ts +++ b/src/locales/es/playlists.ts @@ -9,6 +9,7 @@ export const playlists = { create: 'Crear', cancel: 'Cancelar', empty: 'Aún no hay listas.', + noMatchingSearch: 'Ninguna playlist coincide con tu búsqueda.', emptyPlaylist: 'Esta lista está vacía.', addFirstSong: 'Agrega tu primera canción', notFound: 'Lista no encontrada.', diff --git a/src/locales/es/search.ts b/src/locales/es/search.ts index 37bb355b..279feb45 100644 --- a/src/locales/es/search.ts +++ b/src/locales/es/search.ts @@ -60,6 +60,9 @@ export const search = { scopeComposersPlaceholder: 'Buscar compositor…', scopeComposersBadgeTooltip: 'Clic — quitar', scopeComposersGhostTooltip: 'Clic — buscar solo en esta página', + scopePlaylistsPlaceholder: 'Buscar playlist…', + scopePlaylistsBadgeTooltip: 'Clic — quitar', + scopePlaylistsGhostTooltip: 'Clic — buscar solo en esta página', genres: 'Géneros', shareLink: 'Share link', shareTrackTitle: 'Shared track', diff --git a/src/locales/fr/playlists.ts b/src/locales/fr/playlists.ts index b23288e6..4e92f82a 100644 --- a/src/locales/fr/playlists.ts +++ b/src/locales/fr/playlists.ts @@ -9,6 +9,7 @@ export const playlists = { create: 'Créer', cancel: 'Annuler', empty: 'Aucune playlist pour l\'instant.', + noMatchingSearch: 'Aucune playlist ne correspond à votre recherche.', emptyPlaylist: 'Cette playlist est vide.', addFirstSong: 'Ajouter votre premier titre', notFound: 'Playlist introuvable.', diff --git a/src/locales/fr/search.ts b/src/locales/fr/search.ts index ac1a506f..705b7357 100644 --- a/src/locales/fr/search.ts +++ b/src/locales/fr/search.ts @@ -60,6 +60,9 @@ export const search = { scopeComposersPlaceholder: 'Rechercher un compositeur…', scopeComposersBadgeTooltip: 'Clic — retirer', scopeComposersGhostTooltip: 'Clic — rechercher sur cette page seulement', + scopePlaylistsPlaceholder: 'Rechercher une playlist…', + scopePlaylistsBadgeTooltip: 'Clic — retirer', + scopePlaylistsGhostTooltip: 'Clic — rechercher sur cette page seulement', genres: 'Genres', shareLink: 'Share link', shareTrackTitle: 'Shared track', diff --git a/src/locales/hu/playlists.ts b/src/locales/hu/playlists.ts index 03c8423b..b5c929d3 100644 --- a/src/locales/hu/playlists.ts +++ b/src/locales/hu/playlists.ts @@ -9,6 +9,7 @@ export const playlists = { create: 'Létrehozás', cancel: 'Mégse', empty: 'Még nincsenek lejátszási listák.', + noMatchingSearch: 'Nincs a keresésnek megfelelő lejátszási lista.', emptyPlaylist: 'Ez a lejátszási lista üres.', addFirstSong: 'Add hozzá az első dalt', notFound: 'A lejátszási lista nem található.', diff --git a/src/locales/hu/search.ts b/src/locales/hu/search.ts index b3f68af4..6252cd81 100644 --- a/src/locales/hu/search.ts +++ b/src/locales/hu/search.ts @@ -60,6 +60,9 @@ export const search = { scopeComposersPlaceholder: 'Zeneszerző keresése…', scopeComposersBadgeTooltip: 'Kattints az eltávolításhoz', scopeComposersGhostTooltip: 'Kattints, hogy csak ezen az oldalon keress', + scopePlaylistsPlaceholder: 'Lejátszási lista keresése…', + scopePlaylistsBadgeTooltip: 'Kattints az eltávolításhoz', + scopePlaylistsGhostTooltip: 'Kattints, hogy csak ezen az oldalon keress', genres: 'Műfajok', shareLink: 'Megosztási link', shareTrackTitle: 'Megosztott szám', diff --git a/src/locales/it/playlists.ts b/src/locales/it/playlists.ts index 818653ab..34a936c9 100644 --- a/src/locales/it/playlists.ts +++ b/src/locales/it/playlists.ts @@ -9,6 +9,7 @@ export const playlists = { create: 'Crea', cancel: 'Annulla', empty: 'Ancora nessuna playlist.', + noMatchingSearch: 'Nessuna playlist corrisponde alla ricerca.', emptyPlaylist: 'Questa playlist è vuota.', addFirstSong: 'Aggiungi il tuo primo brano', notFound: 'Playlist non trovata.', diff --git a/src/locales/it/search.ts b/src/locales/it/search.ts index eb96e290..725764bd 100644 --- a/src/locales/it/search.ts +++ b/src/locales/it/search.ts @@ -60,6 +60,9 @@ export const search = { scopeComposersPlaceholder: 'Cerca un compositore…', scopeComposersBadgeTooltip: 'Clicca per rimuovere', scopeComposersGhostTooltip: 'Clicca per cercare solo in questa pagina', + scopePlaylistsPlaceholder: 'Cerca una playlist…', + scopePlaylistsBadgeTooltip: 'Clicca per rimuovere', + scopePlaylistsGhostTooltip: 'Clicca per cercare solo in questa pagina', genres: 'Generi', shareLink: 'Condividi link', shareTrackTitle: 'Brano condiviso', diff --git a/src/locales/ja/playlists.ts b/src/locales/ja/playlists.ts index e002ca90..47096cb1 100644 --- a/src/locales/ja/playlists.ts +++ b/src/locales/ja/playlists.ts @@ -9,6 +9,7 @@ export const playlists = { create: '作成', cancel: 'キャンセル', empty: 'プレイリストはまだありません。', + noMatchingSearch: '検索に一致するプレイリストはありません。', emptyPlaylist: 'このプレイリストは空です。', addFirstSong: '最初の曲を追加', notFound: 'プレイリストが見つかりません。', diff --git a/src/locales/ja/search.ts b/src/locales/ja/search.ts index 34f4854e..97935c46 100644 --- a/src/locales/ja/search.ts +++ b/src/locales/ja/search.ts @@ -60,6 +60,9 @@ export const search = { scopeComposersPlaceholder: '作曲者を検索…', scopeComposersBadgeTooltip: 'クリックして削除', scopeComposersGhostTooltip: 'クリックしてこのページだけで検索', + scopePlaylistsPlaceholder: 'プレイリストを検索…', + scopePlaylistsBadgeTooltip: 'クリックして削除', + scopePlaylistsGhostTooltip: 'クリックしてこのページだけで検索', genres: 'ジャンル', shareLink: '共有リンク', shareTrackTitle: '共有トラック', diff --git a/src/locales/nb/playlists.ts b/src/locales/nb/playlists.ts index fd911909..f251cc48 100644 --- a/src/locales/nb/playlists.ts +++ b/src/locales/nb/playlists.ts @@ -9,6 +9,7 @@ export const playlists = { create: 'Opprett', cancel: 'Avbryt', empty: 'Ingen spillelister ennå.', + noMatchingSearch: 'Ingen spillelister matcher søket.', emptyPlaylist: 'Denne spillelisten er tom.', addFirstSong: 'Legg til din første sang', notFound: 'Spillelisten ble ikke funnet.', diff --git a/src/locales/nb/search.ts b/src/locales/nb/search.ts index 88cea335..8c177c11 100644 --- a/src/locales/nb/search.ts +++ b/src/locales/nb/search.ts @@ -60,6 +60,9 @@ export const search = { scopeComposersPlaceholder: 'Søk komponist…', scopeComposersBadgeTooltip: 'Klikk — fjern', scopeComposersGhostTooltip: 'Klikk — søk bare på denne siden', + scopePlaylistsPlaceholder: 'Søk etter spilleliste…', + scopePlaylistsBadgeTooltip: 'Klikk — fjern', + scopePlaylistsGhostTooltip: 'Klikk — søk bare på denne siden', genres: 'Sjangre', shareLink: 'Share link', shareTrackTitle: 'Shared track', diff --git a/src/locales/nl/playlists.ts b/src/locales/nl/playlists.ts index 7ac9b5cb..b15d3e30 100644 --- a/src/locales/nl/playlists.ts +++ b/src/locales/nl/playlists.ts @@ -9,6 +9,7 @@ export const playlists = { create: 'Aanmaken', cancel: 'Annuleren', empty: 'Nog geen playlists.', + noMatchingSearch: 'Geen playlists komen overeen met je zoekopdracht.', emptyPlaylist: 'Deze playlist is leeg.', addFirstSong: 'Voeg je eerste nummer toe', notFound: 'Playlist niet gevonden.', diff --git a/src/locales/nl/search.ts b/src/locales/nl/search.ts index fb396c88..e8145d15 100644 --- a/src/locales/nl/search.ts +++ b/src/locales/nl/search.ts @@ -60,6 +60,9 @@ export const search = { scopeComposersPlaceholder: 'Componist zoeken…', scopeComposersBadgeTooltip: 'Klik — verwijderen', scopeComposersGhostTooltip: 'Klik — alleen op deze pagina zoeken', + scopePlaylistsPlaceholder: 'Zoek een playlist…', + scopePlaylistsBadgeTooltip: 'Klik — verwijderen', + scopePlaylistsGhostTooltip: 'Klik — alleen op deze pagina zoeken', genres: 'Genres', shareLink: 'Share link', shareTrackTitle: 'Shared track', diff --git a/src/locales/pl/playlists.ts b/src/locales/pl/playlists.ts index e598006e..4b5f5f7a 100644 --- a/src/locales/pl/playlists.ts +++ b/src/locales/pl/playlists.ts @@ -6,6 +6,7 @@ export const playlists = { create: 'Stwórz', cancel: 'Anuluj', empty: 'Nie masz jeszcze żadnych playlist.', + noMatchingSearch: 'Żadna playlista nie pasuje do wyszukiwania.', emptyPlaylist: 'Ta playlista jest pusta.', addFirstSong: 'Dodaj pierwszy utwór', notFound: 'Playlista nie znaleziona.', diff --git a/src/locales/pl/search.ts b/src/locales/pl/search.ts index 10adc9b6..9b5d7483 100644 --- a/src/locales/pl/search.ts +++ b/src/locales/pl/search.ts @@ -60,6 +60,9 @@ export const search = { scopeComposersPlaceholder: 'Szukaj kompozytora…', scopeComposersBadgeTooltip: 'Kliknij, aby usunąć', scopeComposersGhostTooltip: 'Kliknij, aby wyszukać tylko na tej stronie', + scopePlaylistsPlaceholder: 'Szukaj playlisty…', + scopePlaylistsBadgeTooltip: 'Kliknij, aby usunąć', + scopePlaylistsGhostTooltip: 'Kliknij, aby wyszukać tylko na tej stronie', genres: 'Gatunki', shareLink: 'Udostępnij link', shareTrackTitle: 'Udostępniony utwór', diff --git a/src/locales/ro/playlists.ts b/src/locales/ro/playlists.ts index ca5084e6..88e3b220 100644 --- a/src/locales/ro/playlists.ts +++ b/src/locales/ro/playlists.ts @@ -9,6 +9,7 @@ export const playlists = { create: 'Crează', cancel: 'Anulează', empty: 'Niciun playlist deocamdată.', + noMatchingSearch: 'Niciun playlist nu se potrivește căutării.', emptyPlaylist: 'Acest playlist este gol.', addFirstSong: 'Adaugă prima ta piesă', notFound: 'Playlistul nu a fost găsit.', diff --git a/src/locales/ro/search.ts b/src/locales/ro/search.ts index dcbb8a46..0724e901 100644 --- a/src/locales/ro/search.ts +++ b/src/locales/ro/search.ts @@ -60,6 +60,9 @@ export const search = { scopeComposersPlaceholder: 'Caută compozitor…', scopeComposersBadgeTooltip: 'Clic — elimină', scopeComposersGhostTooltip: 'Clic — caută doar pe această pagină', + scopePlaylistsPlaceholder: 'Caută playlist…', + scopePlaylistsBadgeTooltip: 'Clic — elimină', + scopePlaylistsGhostTooltip: 'Clic — caută doar pe această pagină', genres: 'Genuri', shareLink: 'Share link', shareTrackTitle: 'Shared track', diff --git a/src/locales/ru/playlists.ts b/src/locales/ru/playlists.ts index 19629a70..40d1a0c5 100644 --- a/src/locales/ru/playlists.ts +++ b/src/locales/ru/playlists.ts @@ -9,6 +9,7 @@ export const playlists = { create: 'Создать', cancel: 'Отмена', empty: 'Плейлистов пока нет.', + noMatchingSearch: 'Нет плейлистов, подходящих под поиск.', emptyPlaylist: 'Плейлист пуст.', addFirstSong: 'Добавьте первый трек', notFound: 'Плейлист не найден.', diff --git a/src/locales/ru/search.ts b/src/locales/ru/search.ts index 0716c976..cf22b98e 100644 --- a/src/locales/ru/search.ts +++ b/src/locales/ru/search.ts @@ -60,6 +60,9 @@ export const search = { scopeComposersPlaceholder: 'Поиск композитора…', scopeComposersBadgeTooltip: 'Щелчок — удалить', scopeComposersGhostTooltip: 'Щелчок — искать только на этой странице', + scopePlaylistsPlaceholder: 'Поиск плейлиста…', + scopePlaylistsBadgeTooltip: 'Щелчок — удалить', + scopePlaylistsGhostTooltip: 'Щелчок — искать только на этой странице', genres: 'Жанры', shareLink: 'Ссылка для обмена', shareTrackTitle: 'Общий трек', diff --git a/src/locales/zh/playlists.ts b/src/locales/zh/playlists.ts index 2814ea37..9fd3400b 100644 --- a/src/locales/zh/playlists.ts +++ b/src/locales/zh/playlists.ts @@ -9,6 +9,7 @@ export const playlists = { create: '创建', cancel: '取消', empty: '暂无播放列表。', + noMatchingSearch: '没有匹配搜索的播放列表。', emptyPlaylist: '此播放列表为空。', addFirstSong: '添加第一首歌曲', notFound: '未找到播放列表。', diff --git a/src/locales/zh/search.ts b/src/locales/zh/search.ts index 3598189d..db06f590 100644 --- a/src/locales/zh/search.ts +++ b/src/locales/zh/search.ts @@ -60,6 +60,9 @@ export const search = { scopeComposersPlaceholder: '搜索作曲家…', scopeComposersBadgeTooltip: '单击 — 移除', scopeComposersGhostTooltip: '单击 — 仅在此页面搜索', + scopePlaylistsPlaceholder: '搜索播放列表…', + scopePlaylistsBadgeTooltip: '单击 — 移除', + scopePlaylistsGhostTooltip: '单击 — 仅在此页面搜索', genres: '流派', shareLink: 'Share link', shareTrackTitle: 'Shared track', diff --git a/src/store/liveSearchScopeStore.test.ts b/src/store/liveSearchScopeStore.test.ts index 7117038e..be369230 100644 --- a/src/store/liveSearchScopeStore.test.ts +++ b/src/store/liveSearchScopeStore.test.ts @@ -24,6 +24,9 @@ describe('liveSearchScopeStore', () => { useLiveSearchScopeStore.setState({ query: 'bach', scope: 'composers' }); expect(scopedBrowseSearchQuery('bach', 'composers', 'composers')).toBe('bach'); expect(scopedBrowseSearchQuery('bach', 'artists', 'composers')).toBe(''); + useLiveSearchScopeStore.setState({ query: 'road', scope: 'playlists' }); + expect(scopedBrowseSearchQuery('road', 'playlists', 'playlists')).toBe('road'); + expect(scopedBrowseSearchQuery('road', 'albums', 'playlists')).toBe(''); }); it('undoes query and scope badge changes', () => { diff --git a/src/store/liveSearchScopeStore.ts b/src/store/liveSearchScopeStore.ts index 6d2f84af..b2a20219 100644 --- a/src/store/liveSearchScopeStore.ts +++ b/src/store/liveSearchScopeStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; /** Page-scoped live search mode — badge in the header search field. */ -export type LiveSearchScope = 'artists' | 'albums' | 'newReleases' | 'tracks' | 'composers'; +export type LiveSearchScope = 'artists' | 'albums' | 'newReleases' | 'tracks' | 'composers' | 'playlists'; export type LiveSearchSnapshot = { query: string;