Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion scripts/ui-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@
"src/pages/Browse.tsx": {
"raw-palette": 1,
"focus-ring": 2,
"em-dash": 10
"em-dash": 9
},
"src/pages/Conflicts.tsx": {
"raw-palette": 23,
Expand Down
25 changes: 25 additions & 0 deletions src/lib/browseFilters.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { browseRemoteFilters } from './browseFilters';

describe('Browse remote hero filters', () => {
it('uses the hero category for Mods', () => {
expect(browseRemoteFilters({
section: 'Mod', search: 'blue', heroCategoryId: 42,
selectedHeroName: 'Haze', effectiveCategoryId: 42,
})).toEqual({ search: 'blue', categoryId: 42 });
});

it.each(['Sound', 'Wip'])('searches %s by hero name without a ModCategory id', (section) => {
expect(browseRemoteFilters({
section, search: ' blue ', heroCategoryId: 42,
selectedHeroName: 'Haze', effectiveCategoryId: 42,
})).toEqual({ search: 'blue Haze', categoryId: undefined });
});

it('keeps a Sound category with the No hero filter', () => {
expect(browseRemoteFilters({
section: 'Sound', search: '', heroCategoryId: 'none',
selectedHeroName: undefined, effectiveCategoryId: 7,
})).toEqual({ search: undefined, categoryId: 7 });
});
});
18 changes: 18 additions & 0 deletions src/lib/browseFilters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { BrowseUiState } from '../stores/appStore';

export function browseRemoteFilters({
section, search, heroCategoryId, selectedHeroName, effectiveCategoryId,
}: Pick<BrowseUiState, 'section' | 'search' | 'heroCategoryId'> & {
selectedHeroName: string | undefined;
effectiveCategoryId: number | undefined;
}): { search: string | undefined; categoryId: number | undefined } {
// Hero ids come from ModCategory. Sounds and WiPs must search by name
// instead of sending that id to their unrelated category trees.
if (section !== 'Mod' && typeof heroCategoryId === 'number') {
return {
search: [search.trim(), selectedHeroName].filter(Boolean).join(' ') || undefined,
categoryId: undefined,
};
}
return { search: search || undefined, categoryId: effectiveCategoryId };
}
37 changes: 18 additions & 19 deletions src/pages/Browse.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
} from '../lib/api';
import { getActiveDeadlockPath, shouldBlurNsfw } from '../lib/appSettings';
import { useStableCallback } from '../lib/useStableCallback';
import { browseRemoteFilters } from '../lib/browseFilters';
import {
isModDownloadPending,
getVisibleDownloadQueue,
Expand Down Expand Up @@ -498,6 +499,7 @@ export default function Browse() {
const perPage = DEFAULT_PER_PAGE; // Fixed value for infinite scroll
const [sections, setSections] = useState<GameBananaSection[]>([]);
const [categories, setCategories] = useState<GameBananaCategoryNode[]>([]);
const [categoriesSection, setCategoriesSection] = useState<string | null>(null);
// Hero list comes from the Mod section's category tree (Mod -> Skins -> heroes).
// Cached separately so hero filtering still works on the Sound tab, where the
// current section's category tree has no Skins parent.
Expand Down Expand Up @@ -798,6 +800,7 @@ export default function Browse() {
// having no items, and stale ids restored from a previous session.
const rootCategoryNameById = useMemo(() => {
const map = new Map<number, string>();
if (categoriesSection !== section) return map;
const walk = (nodes: GameBananaCategoryNode[], rootName: string | null) => {
for (const node of nodes) {
const root = rootName ?? node.name;
Expand All @@ -807,7 +810,7 @@ export default function Browse() {
};
walk(categories, null);
return map;
}, [categories]);
}, [categories, categoriesSection, section]);

const effectiveCategoryName =
effectiveCategoryId === undefined ? undefined : rootCategoryNameById.get(effectiveCategoryId);
Expand Down Expand Up @@ -1134,15 +1137,18 @@ export default function Browse() {
setError(null);

try {
const remoteFilters = browseRemoteFilters({
section, search: effectiveSearch, heroCategoryId, selectedHeroName, effectiveCategoryId,
});
const response = await browseMods(
page,
perPage,
// In artist mode the grid is scoped by submitter; text search and
// category don't combine cleanly with that on the API, so they're
// dropped while viewing an artist.
submitter ? undefined : (effectiveSearch || undefined),
submitter ? undefined : remoteFilters.search,
section,
submitter ? undefined : effectiveCategoryId,
submitter ? undefined : remoteFilters.categoryId,
sort !== 'default' ? sort : undefined,
submitter?.id
);
Expand Down Expand Up @@ -1228,6 +1234,8 @@ export default function Browse() {
section,
perPage,
effectiveCategoryId,
heroCategoryId,
selectedHeroName,
fetchFilterStamp,
useLocalSearch,
submitter,
Expand Down Expand Up @@ -1473,35 +1481,25 @@ export default function Browse() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

// Track whether `section` actually changed (vs. the effect just firing on
// mount because deps populated for the first time). Without this guard the
// hero/category filters were reset to 'all' on every remount, which then
// triggered a refetch and threw away the cached scroll position.
const lastLoadedSectionRef = useRef<string | null>(null);
// The store switches filters with the section. Category loading must never
// reset them, including a selection made while this request is in flight.
useEffect(() => {
let active = true;
const selected = sections.find((entry) => entry.modelName === section);
if (!selected) {
setCategories([]);
setCategoriesSection(null);
return () => {
active = false;
};
}

const sectionChanged = lastLoadedSectionRef.current !== null && lastLoadedSectionRef.current !== section;
lastLoadedSectionRef.current = section;

const loadCategories = async () => {
try {
const data = await getGamebananaCategories(selected.categoryModelName);
if (!active) return;
setCategories(data);
// Only reset the hero/category filter when the user actually
// switched sections — not on every remount.
if (sectionChanged) {
setHeroCategoryId('all');
setCategoryId('all');
}
setCategoriesSection(section);
} catch (err) {
if (active) {
setError(String(err));
Expand All @@ -1514,7 +1512,7 @@ export default function Browse() {
return () => {
active = false;
};
}, [sections, section, setCategoryId, setHeroCategoryId]);
}, [sections, section]);

// fetchMods and searchLocal share `lastFetchedStampRef`, and its key
// (`page` + fetchFilterStamp) describes the FILTERS only, not which backend
Expand Down Expand Up @@ -2010,6 +2008,7 @@ export default function Browse() {
}, [modCategories]);

const categoryOptions = useMemo(() => {
if (categoriesSection !== section) return [];
// Per-hero entries under Skins are handled by the dedicated Hero filter, so
// exclude them here. Everything else (Skins, Model Replacement, HUD,
// Gameplay Modifications, Maps, Music, Killsounds, ...) becomes a mod-type
Expand All @@ -2032,7 +2031,7 @@ export default function Browse() {
}

return Array.from(byLabel.values()).sort((a, b) => a.label.localeCompare(b.label));
}, [categories, heroOptions]);
}, [categories, categoriesSection, section, heroOptions]);

const installedIds = useMemo(() => {
const ids = new Set<number>();
Expand Down
71 changes: 71 additions & 0 deletions src/stores/appStore.browseUi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,77 @@ vi.mock('../i18n', () => ({

const sqooky: BrowseArtistRef = { id: 3826762, name: 'Sqooky!' };

describe('appStore Browse section filters', () => {
beforeEach(() => useAppStore.getState().resetBrowseUi());

it('keeps the selected hero and shared filters through all sections', () => {
const { setBrowseUi } = useAppStore.getState();
setBrowseUi({ heroCategoryId: 42, search: 'blue', sort: 'popular', addedWithin: 'custom', addedFrom: '2026-10-01', addedTo: '2026-10-08' });
for (const section of ['Sound', 'Wip', 'Mod']) {
setBrowseUi({ section });
expect(useAppStore.getState().browseUi).toMatchObject({
section, heroCategoryId: 42, search: 'blue', sort: 'popular',
addedWithin: 'custom', addedFrom: '2026-10-01', addedTo: '2026-10-08',
});
}
});

it('restores each section category without carrying ids to another section', () => {
const { setBrowseUi } = useAppStore.getState();
setBrowseUi({ categoryId: 100 });
setBrowseUi({ section: 'Sound' });
expect(useAppStore.getState().browseUi.categoryId).toBe('all');
setBrowseUi({ categoryId: 200 });
setBrowseUi({ section: 'Wip', categoryId: 300 });
for (const { section, categoryId } of [
{ section: 'Mod', categoryId: 100 },
{ section: 'Sound', categoryId: 200 },
{ section: 'Wip', categoryId: 300 },
]) {
setBrowseUi({ section });
expect(useAppStore.getState().browseUi.categoryId).toBe(categoryId);
}
});

it('keeps No hero specific to Sounds and restores it when returning', () => {
const { setBrowseUi } = useAppStore.getState();
setBrowseUi({ section: 'Sound', heroCategoryId: 'none' });
for (const section of ['Mod', 'Wip']) {
setBrowseUi({ section });
expect(useAppStore.getState().browseUi.heroCategoryId).toBe('all');
}
setBrowseUi({ section: 'Sound' });
expect(useAppStore.getState().browseUi.heroCategoryId).toBe('none');
setBrowseUi({ section: 'Mod', heroCategoryId: 42 });
setBrowseUi({ section: 'Sound' });
expect(useAppStore.getState().browseUi.heroCategoryId).toBe(42);
});

it('honors explicit navigation filters and clearing a restored category', () => {
const { setBrowseUi } = useAppStore.getState();
setBrowseUi({ categoryId: 100 });
setBrowseUi({ section: 'Sound', categoryId: 200 });
setBrowseUi({ section: 'Mod', heroCategoryId: 42, categoryId: 'all' });
expect(useAppStore.getState().browseUi).toMatchObject({ heroCategoryId: 42, categoryId: 'all' });
setBrowseUi({ section: 'Sound' });
setBrowseUi({ categoryId: 'all', heroCategoryId: 'all' });
setBrowseUi({ section: 'Mod' });
setBrowseUi({ section: 'Sound' });
expect(useAppStore.getState().browseUi).toMatchObject({ heroCategoryId: 'all', categoryId: 'all' });
});

it('resets section memories with the Browse session', () => {
const { setBrowseUi, resetBrowseUi } = useAppStore.getState();
setBrowseUi({ categoryId: 100 });
setBrowseUi({ section: 'Sound', heroCategoryId: 'none' });
resetBrowseUi();
setBrowseUi({ section: 'Sound' });
expect(useAppStore.getState().browseUi).toMatchObject({ heroCategoryId: 'all', categoryId: 'all' });
setBrowseUi({ section: 'Mod' });
expect(useAppStore.getState().browseUi.categoryId).toBe('all');
});
});

describe('appStore Browse artist overrides', () => {
beforeEach(() => {
useAppStore.getState().resetBrowseUi();
Expand Down
26 changes: 25 additions & 1 deletion src/stores/appStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,9 @@ export interface BrowseUiState {
// For Mod section it collapses to 'all' since every Skin lives under a hero.
heroCategoryId: number | 'all' | 'none';
categoryId: number | 'all';
// Category ids belong to their section. Remember them for this session only.
categoryIdsBySection: Partial<Record<string, number | 'all'>>;
soundNoHero: boolean;
// Artist mode: when set, the grid lists only this submitter's mods
// (GameBanana Generic_Submitter filter) and Browse shows an artist banner.
// Session-only; carries display fields so the banner needs no extra fetch.
Expand Down Expand Up @@ -284,6 +287,8 @@ const DEFAULT_BROWSE_UI: BrowseUiState = {
addedTo: '',
heroCategoryId: 'all',
categoryId: 'all',
categoryIdsBySection: {},
soundNoHero: false,
};

// Cached page state: lets the Browse tab resume exactly where the user left
Expand Down Expand Up @@ -1281,7 +1286,26 @@ export const useAppStore = create<AppState>((set, get) => ({
// a time without restating the rest. layout + sort also mirror to
// localStorage so they persist across app restarts.
setBrowseUi: (partial: Partial<BrowseUiState>) => {
const next = { ...get().browseUi, ...partial };
const current = get().browseUi;
const next = { ...current, ...partial };
if (next.section !== current.section) {
next.categoryIdsBySection = {
...current.categoryIdsBySection,
[current.section]: current.categoryId,
};
next.categoryId = partial.categoryId ?? next.categoryIdsBySection[next.section] ?? 'all';
next.heroCategoryId = partial.heroCategoryId ?? (
next.section === 'Sound' && current.soundNoHero
? 'none'
: current.heroCategoryId === 'none' ? 'all' : current.heroCategoryId
);
}
if (partial.heroCategoryId !== undefined) {
next.soundNoHero = next.section === 'Sound' && partial.heroCategoryId === 'none';
}
if (next.section !== 'Sound' && next.heroCategoryId === 'none') {
next.heroCategoryId = 'all';
}
// An override belongs to the exact navigation that supplied it. Any
// ordinary submitter change must drop it so a later Installed/Browse link
// cannot accidentally reveal a creator the user chose to hide.
Expand Down
Loading