mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
feat: library browse navigation — restore filters, scroll, and search on back (#936)
* feat(albums): restore scroll position when returning from album detail Save in-page scroll and grid depth when opening an album from All Albums, then on browser back restore filters, preload enough rows, and apply scroll before revealing the grid to avoid a visible jump from the top. * feat(albums): smart back navigation and restore browse session on return Remember the originating route when opening album detail, restore All Albums filters/scroll on back (including explicit returnTo navigation), hide the grid until scroll is applied, and fix filters being cleared after albumBrowseRestore state is stripped from the location. * feat(search): restore Advanced Search session when returning from album Stash filters and results when leaving /search/advanced for album detail, then restore them on back navigation (POP or returnTo with advancedSearchRestore). * feat(search): restore Advanced Search album row scroll on return from album Save horizontal scrollLeft when opening an album from Advanced Search and reapply it via AlbumRow on return; keep main viewport at top. Add snapshot helpers and session stash fields; extend AlbumRow with restoreScrollLeft. * feat(search): restore Advanced Search session scroll and artist return path Save filters, main scroll, and album-row scroll when leaving to album or artist; restore without flash via hidden-until-ready. Add useNavigateToArtist, restoreMainViewportScroll helper, and AppShell scroll reset only on pathname change. * feat(search): speed up Advanced Search back restore and year-only queries Reveal the page right after sync scroll instead of blocking on full viewport and album-row restore. Retry local index without the ready gate during sync; use open-ended byYear params on network fallback, matching All Albums browse. * feat(search): restore Advanced Search artist row scroll on back Save leave snapshot when opening artist from ArtistCardLocal, persist artistRowScrollLeft in session stash, and keep row restore targets in refs so horizontal scroll survives finishLeaveRestoreUi like vertical scrollTop. * feat(nav): route mouse back on album/artist detail like UI back Trap history popstate when returnTo is set and call navigateAlbumDetailBack so browser/mouse back restores browse/search session the same way as the header button. * feat(artists): restore browse filters and scroll on back from artist detail Persist Artists page filters, view settings, and vertical scroll when opening an artist and returning via UI or mouse back, matching All Albums behavior. * feat(search): unify quick and advanced search; fix LiveSearch dismiss on Enter Serve /search and /search/advanced from one page with shared session restore and scroll snapshot. Reset live search overlay state when navigating to full search so the dropdown does not linger or reopen. * feat(tracks): unify with search session and restore scroll on back Route /tracks through AdvancedSearch with shared leave snapshot, song browse stash, and main-viewport scroll restore when returning from album or artist detail. Wait for hero/rails layout before applying scroll. * refactor(search): rename AdvancedSearch page to SearchBrowsePage The shared route shell serves /search, /search/advanced, and /tracks; rename the page component and refresh stale file references in comments. * feat(albums): restore New Releases and Random Albums on back from detail Unify album grid leave-restore with surface-scoped session stash, live scroll snapshot sync, and in-page scroll for Random Albums. Keep the same random batch when returning from album detail; Refresh fetches anew and scrolls up. * docs: add CHANGELOG and credits for PR #936
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../api/subsonicTypes';
|
||||
import { create } from 'zustand';
|
||||
import type { AdvancedSearchLeaveSnapshot } from '../utils/navigation/advancedSearchScrollSnapshot';
|
||||
|
||||
export type AdvancedSearchResultType = 'all' | 'artists' | 'albums' | 'songs';
|
||||
|
||||
export type AdvancedSearchFormStash = {
|
||||
query: string;
|
||||
genre: string;
|
||||
yearFrom: string;
|
||||
yearTo: string;
|
||||
bpmFrom: string;
|
||||
bpmTo: string;
|
||||
moodGroup: string;
|
||||
losslessOnly: boolean;
|
||||
resultType: AdvancedSearchResultType;
|
||||
starredOnly: boolean;
|
||||
};
|
||||
|
||||
export type AdvancedSearchQueryStash = Omit<AdvancedSearchFormStash, 'starredOnly'>;
|
||||
|
||||
export type AdvancedSearchResultsStash = {
|
||||
artists: SubsonicArtist[];
|
||||
albums: SubsonicAlbum[];
|
||||
songs: SubsonicSong[];
|
||||
};
|
||||
|
||||
/** Session snapshot when leaving Search → album/artist detail. */
|
||||
export type AdvancedSearchSessionStash = AdvancedSearchFormStash & {
|
||||
results: AdvancedSearchResultsStash | null;
|
||||
hasSearched: boolean;
|
||||
activeSearch: AdvancedSearchQueryStash | null;
|
||||
localMode: boolean;
|
||||
songsServerOffset: number;
|
||||
songsHasMore: boolean;
|
||||
genreNote: boolean;
|
||||
/** `/search?q=` quick results (no advanced filter panel). */
|
||||
basicSearchMode: boolean;
|
||||
/** `/tracks` hub — browse-all list with toolbar filter. */
|
||||
tracksBrowseMode: boolean;
|
||||
tracksBrowseUnsupported?: boolean;
|
||||
scrollTop?: number;
|
||||
albumRowScrollLeft?: number;
|
||||
artistRowScrollLeft?: number;
|
||||
};
|
||||
|
||||
interface AdvancedSearchSessionStore {
|
||||
returnStash: AdvancedSearchSessionStash | null;
|
||||
leaveScrollSnapshot: AdvancedSearchLeaveSnapshot | null;
|
||||
stashReturnSession: (stash: AdvancedSearchSessionStash) => void;
|
||||
peekReturnStash: () => AdvancedSearchSessionStash | null;
|
||||
clearReturnStash: () => void;
|
||||
setLeaveScrollSnapshot: (snapshot: AdvancedSearchLeaveSnapshot) => void;
|
||||
peekLeaveScrollSnapshot: () => AdvancedSearchLeaveSnapshot | null;
|
||||
clearLeaveScrollSnapshot: () => void;
|
||||
}
|
||||
|
||||
export const useAdvancedSearchSessionStore = create<AdvancedSearchSessionStore>((set, get) => ({
|
||||
returnStash: null,
|
||||
leaveScrollSnapshot: null,
|
||||
|
||||
stashReturnSession: (stash) => {
|
||||
set({
|
||||
returnStash: {
|
||||
...stash,
|
||||
results: stash.results
|
||||
? {
|
||||
artists: [...stash.results.artists],
|
||||
albums: [...stash.results.albums],
|
||||
songs: [...stash.results.songs],
|
||||
}
|
||||
: null,
|
||||
activeSearch: stash.activeSearch ? { ...stash.activeSearch } : null,
|
||||
...(typeof stash.scrollTop === 'number' ? { scrollTop: stash.scrollTop } : {}),
|
||||
...(typeof stash.albumRowScrollLeft === 'number'
|
||||
? { albumRowScrollLeft: stash.albumRowScrollLeft }
|
||||
: {}),
|
||||
...(typeof stash.artistRowScrollLeft === 'number'
|
||||
? { artistRowScrollLeft: stash.artistRowScrollLeft }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
clearReturnStash: () => set({ returnStash: null }),
|
||||
|
||||
setLeaveScrollSnapshot: (snapshot) => set({ leaveScrollSnapshot: { ...snapshot } }),
|
||||
|
||||
clearLeaveScrollSnapshot: () => set({ leaveScrollSnapshot: null }),
|
||||
|
||||
peekLeaveScrollSnapshot: () => {
|
||||
const snapshot = get().leaveScrollSnapshot;
|
||||
return snapshot ? { ...snapshot } : null;
|
||||
},
|
||||
|
||||
peekReturnStash: () => {
|
||||
const stash = get().returnStash;
|
||||
if (!stash) return null;
|
||||
return {
|
||||
...stash,
|
||||
results: stash.results
|
||||
? {
|
||||
artists: [...stash.results.artists],
|
||||
albums: [...stash.results.albums],
|
||||
songs: [...stash.results.songs],
|
||||
}
|
||||
: null,
|
||||
activeSearch: stash.activeSearch ? { ...stash.activeSearch } : null,
|
||||
...(typeof stash.scrollTop === 'number' ? { scrollTop: stash.scrollTop } : {}),
|
||||
...(typeof stash.albumRowScrollLeft === 'number'
|
||||
? { albumRowScrollLeft: stash.albumRowScrollLeft }
|
||||
: {}),
|
||||
...(typeof stash.artistRowScrollLeft === 'number'
|
||||
? { artistRowScrollLeft: stash.artistRowScrollLeft }
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
/** True when pathname is the unified search page (`/search`, `/search/advanced`, or `/tracks`). */
|
||||
export function isAdvancedSearchPath(pathname: string): boolean {
|
||||
return pathname === '/search' || pathname === '/search/advanced' || pathname === '/tracks';
|
||||
}
|
||||
|
||||
/** True when pathname is the Tracks hub (`/tracks`). */
|
||||
export function isTracksBrowsePath(pathname: string): boolean {
|
||||
return pathname === '/tracks';
|
||||
}
|
||||
|
||||
/** True when the advanced filter panel should be shown. */
|
||||
export function isAdvancedSearchPanelPath(pathname: string): boolean {
|
||||
return pathname === '/search/advanced';
|
||||
}
|
||||
@@ -1,15 +1,18 @@
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import {
|
||||
DEFAULT_ALBUM_BROWSE_RETURN_FILTERS,
|
||||
DEFAULT_ALBUM_BROWSE_SORT,
|
||||
albumBrowseSortForServer,
|
||||
albumBrowseSurfaceForPath,
|
||||
isAlbumDetailPath,
|
||||
peekAlbumBrowseScrollRestore,
|
||||
useAlbumBrowseSessionStore,
|
||||
} from './albumBrowseSessionStore';
|
||||
|
||||
describe('albumBrowseSessionStore', () => {
|
||||
beforeEach(() => {
|
||||
useAlbumBrowseSessionStore.setState({ sortByServer: {}, returnStashByServer: {} });
|
||||
useAlbumBrowseSessionStore.setState({ sortByServer: {}, returnStashByKey: {} });
|
||||
});
|
||||
|
||||
it('keeps sort per server for the session', () => {
|
||||
@@ -22,35 +25,72 @@ describe('albumBrowseSessionStore', () => {
|
||||
expect(albumBrowseSortForServer(sortByServer, 'srv-b')).toBe('alphabeticalByName');
|
||||
});
|
||||
|
||||
it('stashes and peeks return filters', () => {
|
||||
it('stashes and peeks return filters with scroll snapshot per surface', () => {
|
||||
const { stashReturnFilters, peekReturnStash } = useAlbumBrowseSessionStore.getState();
|
||||
stashReturnFilters('srv-a', {
|
||||
stashReturnFilters('srv-a', 'albums', {
|
||||
...DEFAULT_ALBUM_BROWSE_RETURN_FILTERS,
|
||||
selectedGenres: ['Rock'],
|
||||
yearFrom: '1990',
|
||||
yearTo: '2000',
|
||||
starredOnly: true,
|
||||
scrollTop: 840,
|
||||
displayCount: 120,
|
||||
});
|
||||
|
||||
expect(peekReturnStash('srv-a')).toEqual({
|
||||
expect(peekReturnStash('srv-a', 'albums')).toEqual({
|
||||
selectedGenres: ['Rock'],
|
||||
yearFrom: '1990',
|
||||
yearTo: '2000',
|
||||
compFilter: 'all',
|
||||
starredOnly: true,
|
||||
losslessOnly: false,
|
||||
scrollTop: 840,
|
||||
displayCount: 120,
|
||||
});
|
||||
expect(peekReturnStash('srv-a')).not.toBeNull();
|
||||
expect(peekReturnStash('srv-a', 'new-releases')).toBeNull();
|
||||
});
|
||||
|
||||
it('clears return stash', () => {
|
||||
it('peeks scroll restore target for a surface', () => {
|
||||
const { stashReturnFilters } = useAlbumBrowseSessionStore.getState();
|
||||
stashReturnFilters('srv-a', 'new-releases', {
|
||||
...DEFAULT_ALBUM_BROWSE_RETURN_FILTERS,
|
||||
scrollTop: 420,
|
||||
displayCount: 180,
|
||||
});
|
||||
expect(peekAlbumBrowseScrollRestore('srv-a', 'new-releases')).toEqual({
|
||||
scrollTop: 420,
|
||||
displayCount: 180,
|
||||
});
|
||||
expect(peekAlbumBrowseScrollRestore('srv-b', 'new-releases')).toBeNull();
|
||||
});
|
||||
|
||||
it('stashes cached album rows for grid surfaces', () => {
|
||||
const albums = [{ id: 'a1', name: 'A', artist: 'X', artistId: 'x' }] as SubsonicAlbum[];
|
||||
const { stashReturnFilters, peekReturnStash } = useAlbumBrowseSessionStore.getState();
|
||||
stashReturnFilters('srv-a', 'random-albums', {
|
||||
...DEFAULT_ALBUM_BROWSE_RETURN_FILTERS,
|
||||
selectedGenres: ['Jazz'],
|
||||
albums,
|
||||
hasMore: false,
|
||||
scrollTop: 100,
|
||||
displayCount: 1,
|
||||
});
|
||||
expect(peekReturnStash('srv-a', 'random-albums')?.albums).toEqual(albums);
|
||||
});
|
||||
|
||||
it('clears return stash for a surface only', () => {
|
||||
const { stashReturnFilters, clearReturnStash, peekReturnStash } = useAlbumBrowseSessionStore.getState();
|
||||
stashReturnFilters('srv-a', {
|
||||
stashReturnFilters('srv-a', 'albums', {
|
||||
...DEFAULT_ALBUM_BROWSE_RETURN_FILTERS,
|
||||
selectedGenres: ['Jazz'],
|
||||
});
|
||||
clearReturnStash('srv-a');
|
||||
expect(peekReturnStash('srv-a')).toBeNull();
|
||||
stashReturnFilters('srv-a', 'new-releases', {
|
||||
...DEFAULT_ALBUM_BROWSE_RETURN_FILTERS,
|
||||
selectedGenres: ['Rock'],
|
||||
});
|
||||
clearReturnStash('srv-a', 'albums');
|
||||
expect(peekReturnStash('srv-a', 'albums')).toBeNull();
|
||||
expect(peekReturnStash('srv-a', 'new-releases')?.selectedGenres).toEqual(['Rock']);
|
||||
});
|
||||
|
||||
it('defaults sort when server has no entry', () => {
|
||||
@@ -68,3 +108,12 @@ describe('isAlbumDetailPath', () => {
|
||||
expect(isAlbumDetailPath('/album/abc/tracks')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('albumBrowseSurfaceForPath', () => {
|
||||
it('maps album grid browse routes', () => {
|
||||
expect(albumBrowseSurfaceForPath('/albums')).toBe('albums');
|
||||
expect(albumBrowseSurfaceForPath('/new-releases')).toBe('new-releases');
|
||||
expect(albumBrowseSurfaceForPath('/random/albums')).toBe('random-albums');
|
||||
expect(albumBrowseSurfaceForPath('/artists')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { create } from 'zustand';
|
||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import type { AlbumBrowseSort } from '../utils/library/browseTextSearch';
|
||||
|
||||
export const DEFAULT_ALBUM_BROWSE_SORT: AlbumBrowseSort = 'alphabeticalByName';
|
||||
|
||||
export type AlbumBrowseCompFilter = 'all' | 'only' | 'hide';
|
||||
|
||||
/** Filters restored only when returning to Albums via browser/app back from album detail. */
|
||||
/** Album grid browse surfaces that share leave-restore session behavior. */
|
||||
export type AlbumBrowseSurface = 'albums' | 'new-releases' | 'random-albums';
|
||||
|
||||
/** Browse state restored when returning via browser/app back from album detail. */
|
||||
export interface AlbumBrowseReturnFilters {
|
||||
selectedGenres: string[];
|
||||
yearFrom: string;
|
||||
@@ -13,6 +17,13 @@ export interface AlbumBrowseReturnFilters {
|
||||
compFilter: AlbumBrowseCompFilter;
|
||||
starredOnly: boolean;
|
||||
losslessOnly: boolean;
|
||||
/** In-page grid scroll position when leaving the browse surface. */
|
||||
scrollTop?: number;
|
||||
/** Row count at leave time — preload at least this many rows before scroll. */
|
||||
displayCount?: number;
|
||||
/** Cached grid rows (New Releases / Random Albums). */
|
||||
albums?: SubsonicAlbum[];
|
||||
hasMore?: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_ALBUM_BROWSE_RETURN_FILTERS: AlbumBrowseReturnFilters = {
|
||||
@@ -31,12 +42,20 @@ interface ServerAlbumBrowseSession {
|
||||
interface AlbumBrowseSessionStore {
|
||||
/** Session-lifetime sort per server (sidebar ↔ album detail). */
|
||||
sortByServer: Record<string, AlbumBrowseSort>;
|
||||
/** Stashed when leaving Albums → album detail; consumed on POP back. */
|
||||
returnStashByServer: Record<string, AlbumBrowseReturnFilters>;
|
||||
/** Stashed when leaving a browse surface → album detail; consumed after scroll restore. */
|
||||
returnStashByKey: Record<string, AlbumBrowseReturnFilters>;
|
||||
setSort: (serverId: string, sort: AlbumBrowseSort) => void;
|
||||
stashReturnFilters: (serverId: string, filters: AlbumBrowseReturnFilters) => void;
|
||||
clearReturnStash: (serverId: string) => void;
|
||||
peekReturnStash: (serverId: string) => AlbumBrowseReturnFilters | null;
|
||||
stashReturnFilters: (
|
||||
serverId: string,
|
||||
surface: AlbumBrowseSurface,
|
||||
filters: AlbumBrowseReturnFilters,
|
||||
) => void;
|
||||
clearReturnStash: (serverId: string, surface: AlbumBrowseSurface) => void;
|
||||
peekReturnStash: (serverId: string, surface: AlbumBrowseSurface) => AlbumBrowseReturnFilters | null;
|
||||
}
|
||||
|
||||
function returnStashKey(serverId: string, surface: AlbumBrowseSurface): string {
|
||||
return `${serverId}:${surface}`;
|
||||
}
|
||||
|
||||
function sortEntryFor(
|
||||
@@ -46,9 +65,24 @@ function sortEntryFor(
|
||||
return sortByServer[serverId] ?? DEFAULT_ALBUM_BROWSE_SORT;
|
||||
}
|
||||
|
||||
function cloneReturnFilters(filters: AlbumBrowseReturnFilters): AlbumBrowseReturnFilters {
|
||||
return {
|
||||
selectedGenres: [...filters.selectedGenres],
|
||||
yearFrom: filters.yearFrom,
|
||||
yearTo: filters.yearTo,
|
||||
compFilter: filters.compFilter,
|
||||
starredOnly: filters.starredOnly,
|
||||
losslessOnly: filters.losslessOnly,
|
||||
...(typeof filters.scrollTop === 'number' ? { scrollTop: filters.scrollTop } : {}),
|
||||
...(typeof filters.displayCount === 'number' ? { displayCount: filters.displayCount } : {}),
|
||||
...(filters.albums ? { albums: [...filters.albums] } : {}),
|
||||
...(typeof filters.hasMore === 'boolean' ? { hasMore: filters.hasMore } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export const useAlbumBrowseSessionStore = create<AlbumBrowseSessionStore>((set, get) => ({
|
||||
sortByServer: {},
|
||||
returnStashByServer: {},
|
||||
returnStashByKey: {},
|
||||
|
||||
setSort: (serverId, sort) => {
|
||||
if (!serverId) return;
|
||||
@@ -57,45 +91,47 @@ export const useAlbumBrowseSessionStore = create<AlbumBrowseSessionStore>((set,
|
||||
}));
|
||||
},
|
||||
|
||||
stashReturnFilters: (serverId, filters) => {
|
||||
stashReturnFilters: (serverId, surface, filters) => {
|
||||
if (!serverId) return;
|
||||
const key = returnStashKey(serverId, surface);
|
||||
set((s) => ({
|
||||
returnStashByServer: {
|
||||
...s.returnStashByServer,
|
||||
[serverId]: {
|
||||
selectedGenres: [...filters.selectedGenres],
|
||||
yearFrom: filters.yearFrom,
|
||||
yearTo: filters.yearTo,
|
||||
compFilter: filters.compFilter,
|
||||
starredOnly: filters.starredOnly,
|
||||
losslessOnly: filters.losslessOnly,
|
||||
},
|
||||
returnStashByKey: {
|
||||
...s.returnStashByKey,
|
||||
[key]: cloneReturnFilters(filters),
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
clearReturnStash: (serverId) => {
|
||||
clearReturnStash: (serverId, surface) => {
|
||||
if (!serverId) return;
|
||||
const next = { ...get().returnStashByServer };
|
||||
delete next[serverId];
|
||||
set({ returnStashByServer: next });
|
||||
const key = returnStashKey(serverId, surface);
|
||||
const next = { ...get().returnStashByKey };
|
||||
delete next[key];
|
||||
set({ returnStashByKey: next });
|
||||
},
|
||||
|
||||
peekReturnStash: (serverId) => {
|
||||
peekReturnStash: (serverId, surface) => {
|
||||
if (!serverId) return null;
|
||||
const stash = get().returnStashByServer[serverId];
|
||||
const stash = get().returnStashByKey[returnStashKey(serverId, surface)];
|
||||
if (!stash) return null;
|
||||
return {
|
||||
selectedGenres: [...stash.selectedGenres],
|
||||
yearFrom: stash.yearFrom,
|
||||
yearTo: stash.yearTo,
|
||||
compFilter: stash.compFilter,
|
||||
starredOnly: stash.starredOnly,
|
||||
losslessOnly: stash.losslessOnly,
|
||||
};
|
||||
return cloneReturnFilters(stash);
|
||||
},
|
||||
}));
|
||||
|
||||
/** Scroll-restore target saved when leaving a browse surface for album detail. */
|
||||
export function peekAlbumBrowseScrollRestore(
|
||||
serverId: string,
|
||||
surface: AlbumBrowseSurface,
|
||||
): { scrollTop: number; displayCount: number } | null {
|
||||
const stash = useAlbumBrowseSessionStore.getState().peekReturnStash(serverId, surface);
|
||||
if (!stash) return null;
|
||||
if (typeof stash.scrollTop !== 'number' || typeof stash.displayCount !== 'number') return null;
|
||||
return {
|
||||
scrollTop: Math.max(0, stash.scrollTop),
|
||||
displayCount: Math.max(0, stash.displayCount),
|
||||
};
|
||||
}
|
||||
|
||||
export function albumBrowseSortForServer(
|
||||
sortByServer: Record<string, AlbumBrowseSort>,
|
||||
serverId: string,
|
||||
@@ -104,7 +140,25 @@ export function albumBrowseSortForServer(
|
||||
return sortEntryFor(sortByServer, serverId);
|
||||
}
|
||||
|
||||
/** Map pathname to album grid browse surface, if any. */
|
||||
export function albumBrowseSurfaceForPath(pathname: string): AlbumBrowseSurface | null {
|
||||
const path = pathname.split('?')[0]?.replace(/\/$/, '') || pathname;
|
||||
if (path === '/albums') return 'albums';
|
||||
if (path === '/new-releases') return 'new-releases';
|
||||
if (path === '/random/albums') return 'random-albums';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** True when pathname is a single album detail route (`/album/:id`). */
|
||||
export function isAlbumDetailPath(pathname: string): boolean {
|
||||
return /^\/album\/[^/]+\/?$/.test(pathname);
|
||||
}
|
||||
|
||||
/** True when pathname is a single artist detail route (`/artist/:id`). */
|
||||
export function isArtistDetailPath(pathname: string): boolean {
|
||||
return /^\/artist\/[^/]+\/?$/.test(pathname);
|
||||
}
|
||||
|
||||
export function isAdvancedSearchLeaveTargetPath(pathname: string): boolean {
|
||||
return isAlbumDetailPath(pathname) || isArtistDetailPath(pathname);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
import {
|
||||
DEFAULT_ARTIST_BROWSE_RETURN_STATE,
|
||||
peekArtistBrowseScrollRestore,
|
||||
useArtistBrowseSessionStore,
|
||||
} from './artistBrowseSessionStore';
|
||||
|
||||
describe('artistBrowseSessionStore', () => {
|
||||
beforeEach(() => {
|
||||
useArtistBrowseSessionStore.setState({ returnStashByServer: {} });
|
||||
});
|
||||
|
||||
it('stashes and peeks return state per server', () => {
|
||||
const { stashReturnState, peekReturnStash } = useArtistBrowseSessionStore.getState();
|
||||
stashReturnState('s1', {
|
||||
...DEFAULT_ARTIST_BROWSE_RETURN_STATE,
|
||||
filter: 'mozart',
|
||||
letterFilter: 'M',
|
||||
viewMode: 'list',
|
||||
scrollTop: 240,
|
||||
visibleCount: 120,
|
||||
});
|
||||
expect(peekReturnStash('s1')).toEqual({
|
||||
filter: 'mozart',
|
||||
letterFilter: 'M',
|
||||
starredOnly: false,
|
||||
viewMode: 'list',
|
||||
showArtistImages: true,
|
||||
scrollTop: 240,
|
||||
visibleCount: 120,
|
||||
});
|
||||
});
|
||||
|
||||
it('clears return stash for a server', () => {
|
||||
const { stashReturnState, clearReturnStash, peekReturnStash } = useArtistBrowseSessionStore.getState();
|
||||
stashReturnState('s1', DEFAULT_ARTIST_BROWSE_RETURN_STATE);
|
||||
clearReturnStash('s1');
|
||||
expect(peekReturnStash('s1')).toBeNull();
|
||||
});
|
||||
|
||||
it('exposes scroll restore target when scroll fields are present', () => {
|
||||
useArtistBrowseSessionStore.getState().stashReturnState('s1', {
|
||||
...DEFAULT_ARTIST_BROWSE_RETURN_STATE,
|
||||
scrollTop: 512,
|
||||
visibleCount: 80,
|
||||
});
|
||||
expect(peekArtistBrowseScrollRestore('s1')).toEqual({ scrollTop: 512, visibleCount: 80 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { create } from 'zustand';
|
||||
import { ALL_SENTINEL } from '../utils/componentHelpers/artistsHelpers';
|
||||
|
||||
export type ArtistBrowseViewMode = 'grid' | 'list';
|
||||
|
||||
/** Browse state restored when returning to Artists via back from artist detail. */
|
||||
export interface ArtistBrowseReturnState {
|
||||
filter: string;
|
||||
letterFilter: string;
|
||||
starredOnly: boolean;
|
||||
viewMode: ArtistBrowseViewMode;
|
||||
showArtistImages: boolean;
|
||||
scrollTop?: number;
|
||||
visibleCount?: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_ARTIST_BROWSE_RETURN_STATE: ArtistBrowseReturnState = {
|
||||
filter: '',
|
||||
letterFilter: ALL_SENTINEL,
|
||||
starredOnly: false,
|
||||
viewMode: 'grid',
|
||||
showArtistImages: true,
|
||||
};
|
||||
|
||||
interface ArtistBrowseSessionStore {
|
||||
returnStashByServer: Record<string, ArtistBrowseReturnState>;
|
||||
stashReturnState: (serverId: string, state: ArtistBrowseReturnState) => void;
|
||||
clearReturnStash: (serverId: string) => void;
|
||||
peekReturnStash: (serverId: string) => ArtistBrowseReturnState | null;
|
||||
}
|
||||
|
||||
export const useArtistBrowseSessionStore = create<ArtistBrowseSessionStore>((set, get) => ({
|
||||
returnStashByServer: {},
|
||||
|
||||
stashReturnState: (serverId, state) => {
|
||||
if (!serverId) return;
|
||||
set((s) => ({
|
||||
returnStashByServer: {
|
||||
...s.returnStashByServer,
|
||||
[serverId]: {
|
||||
filter: state.filter,
|
||||
letterFilter: state.letterFilter,
|
||||
starredOnly: state.starredOnly,
|
||||
viewMode: state.viewMode,
|
||||
showArtistImages: state.showArtistImages,
|
||||
...(typeof state.scrollTop === 'number' ? { scrollTop: state.scrollTop } : {}),
|
||||
...(typeof state.visibleCount === 'number' ? { visibleCount: state.visibleCount } : {}),
|
||||
},
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
clearReturnStash: (serverId) => {
|
||||
if (!serverId) return;
|
||||
const next = { ...get().returnStashByServer };
|
||||
delete next[serverId];
|
||||
set({ returnStashByServer: next });
|
||||
},
|
||||
|
||||
peekReturnStash: (serverId) => {
|
||||
if (!serverId) return null;
|
||||
const stash = get().returnStashByServer[serverId];
|
||||
if (!stash) return null;
|
||||
return {
|
||||
filter: stash.filter,
|
||||
letterFilter: stash.letterFilter,
|
||||
starredOnly: stash.starredOnly,
|
||||
viewMode: stash.viewMode,
|
||||
showArtistImages: stash.showArtistImages,
|
||||
...(typeof stash.scrollTop === 'number' ? { scrollTop: stash.scrollTop } : {}),
|
||||
...(typeof stash.visibleCount === 'number' ? { visibleCount: stash.visibleCount } : {}),
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
export function peekArtistBrowseScrollRestore(
|
||||
serverId: string,
|
||||
): { scrollTop: number; visibleCount: number } | null {
|
||||
const stash = useArtistBrowseSessionStore.getState().peekReturnStash(serverId);
|
||||
if (!stash) return null;
|
||||
if (typeof stash.scrollTop !== 'number' || typeof stash.visibleCount !== 'number') return null;
|
||||
return {
|
||||
scrollTop: Math.max(0, stash.scrollTop),
|
||||
visibleCount: Math.max(0, stash.visibleCount),
|
||||
};
|
||||
}
|
||||
|
||||
/** True when pathname is the Artists browse route (`/artists`). */
|
||||
export function isArtistsBrowsePath(pathname: string): boolean {
|
||||
return pathname === '/artists';
|
||||
}
|
||||
Reference in New Issue
Block a user