Skip to content

Commit 5a6c327

Browse files
feat: enhance layout and filter state management
- Introduced HomeState context for persistent state across navigation - Updated Layout component to provide home state and scroll position management - Refactored useFilterState to utilize persistent home state - Improved HomePage to restore scroll position from home state - Enhanced SpecPage layout responsiveness
1 parent 2fe2172 commit 5a6c327

File tree

4 files changed

+183
-44
lines changed

4 files changed

+183
-44
lines changed

app/src/components/Layout.tsx

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,51 @@
1-
import { useState, useEffect, createContext, useContext } from 'react';
1+
import { useState, useEffect, createContext, useContext, useRef, useCallback } from 'react';
22
import { Outlet } from 'react-router-dom';
33
import Box from '@mui/material/Box';
44
import Container from '@mui/material/Container';
55

66
import { API_URL } from '../constants';
7-
import type { LibraryInfo, SpecInfo } from '../types';
7+
import type { LibraryInfo, SpecInfo, PlotImage, ActiveFilters, FilterCounts } from '../types';
88

99
interface AppData {
1010
specsData: SpecInfo[];
1111
librariesData: LibraryInfo[];
1212
stats: { specs: number; plots: number; libraries: number } | null;
1313
}
1414

15+
// Persistent home state that survives navigation
16+
interface HomeState {
17+
allImages: PlotImage[];
18+
displayedImages: PlotImage[];
19+
activeFilters: ActiveFilters;
20+
filterCounts: FilterCounts | null;
21+
globalCounts: FilterCounts | null;
22+
orCounts: Record<string, number>[];
23+
hasMore: boolean;
24+
scrollY: number;
25+
initialized: boolean;
26+
}
27+
28+
interface HomeStateContext {
29+
homeState: HomeState;
30+
homeStateRef: React.MutableRefObject<HomeState>;
31+
setHomeState: React.Dispatch<React.SetStateAction<HomeState>>;
32+
saveScrollPosition: () => void;
33+
}
34+
35+
const initialHomeState: HomeState = {
36+
allImages: [],
37+
displayedImages: [],
38+
activeFilters: [],
39+
filterCounts: null,
40+
globalCounts: null,
41+
orCounts: [],
42+
hasMore: false,
43+
scrollY: 0,
44+
initialized: false,
45+
};
46+
1547
const AppDataContext = createContext<AppData | null>(null);
48+
const HomeStateContext = createContext<HomeStateContext | null>(null);
1649

1750
export function useAppData() {
1851
const context = useContext(AppDataContext);
@@ -22,11 +55,34 @@ export function useAppData() {
2255
return context;
2356
}
2457

58+
export function useHomeState() {
59+
const context = useContext(HomeStateContext);
60+
if (!context) {
61+
throw new Error('useHomeState must be used within Layout');
62+
}
63+
return context;
64+
}
65+
2566
export function Layout() {
2667
const [specsData, setSpecsData] = useState<SpecInfo[]>([]);
2768
const [librariesData, setLibrariesData] = useState<LibraryInfo[]>([]);
2869
const [stats, setStats] = useState<{ specs: number; plots: number; libraries: number } | null>(null);
2970

71+
// Persistent home state (both ref for sync access and state for reactivity)
72+
const [homeState, setHomeState] = useState<HomeState>(initialHomeState);
73+
const homeStateRef = useRef<HomeState>(initialHomeState);
74+
75+
// Keep ref in sync with state
76+
useEffect(() => {
77+
homeStateRef.current = homeState;
78+
}, [homeState]);
79+
80+
// Save scroll position synchronously to ref (called before navigation)
81+
const saveScrollPosition = useCallback(() => {
82+
homeStateRef.current = { ...homeStateRef.current, scrollY: window.scrollY };
83+
setHomeState((prev) => ({ ...prev, scrollY: window.scrollY }));
84+
}, []);
85+
3086
// Load shared data on mount
3187
useEffect(() => {
3288
const fetchData = async () => {
@@ -60,11 +116,13 @@ export function Layout() {
60116

61117
return (
62118
<AppDataContext.Provider value={{ specsData, librariesData, stats }}>
63-
<Box sx={{ minHeight: '100vh', bgcolor: '#fafafa', py: 5, position: 'relative' }}>
64-
<Container maxWidth={false} sx={{ px: { xs: 2, sm: 4, md: 8, lg: 12 } }}>
65-
<Outlet />
66-
</Container>
67-
</Box>
119+
<HomeStateContext.Provider value={{ homeState, homeStateRef, setHomeState, saveScrollPosition }}>
120+
<Box sx={{ minHeight: '100vh', bgcolor: '#fafafa', py: 5, position: 'relative' }}>
121+
<Container maxWidth={false} sx={{ px: { xs: 2, sm: 4, md: 8, lg: 12 } }}>
122+
<Outlet />
123+
</Container>
124+
</Box>
125+
</HomeStateContext.Provider>
68126
</AppDataContext.Provider>
69127
);
70128
}

app/src/hooks/useFilterState.ts

Lines changed: 95 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,55 @@
11
/**
22
* Hook for managing filter state and URL synchronization.
33
*
4-
* Encapsulates all filter-related state and callbacks used in App.tsx.
4+
* Uses persistent state from Layout context to survive navigation.
55
*/
66

77
import { useState, useCallback, useEffect, useRef } from 'react';
88

99
import type { PlotImage, FilterCategory, ActiveFilters, FilterCounts } from '../types';
1010
import { FILTER_CATEGORIES } from '../types';
1111
import { API_URL, BATCH_SIZE } from '../constants';
12+
import { useHomeState } from '../components/Layout';
1213

1314
/**
14-
* Fisher-Yates shuffle algorithm.
15+
* Seeded random number generator (mulberry32).
1516
*/
16-
function shuffleArray<T>(array: T[]): T[] {
17+
function seededRandom(seed: number): () => number {
18+
return () => {
19+
let t = (seed += 0x6d2b79f5);
20+
t = Math.imul(t ^ (t >>> 15), t | 1);
21+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
22+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
23+
};
24+
}
25+
26+
/**
27+
* Fisher-Yates shuffle algorithm with optional seed for deterministic results.
28+
*/
29+
function shuffleArray<T>(array: T[], seed?: number): T[] {
1730
const shuffled = [...array];
31+
const random = seed !== undefined ? seededRandom(seed) : Math.random;
1832
for (let i = shuffled.length - 1; i > 0; i--) {
19-
const j = Math.floor(Math.random() * (i + 1));
33+
const j = Math.floor(random() * (i + 1));
2034
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
2135
}
2236
return shuffled;
2337
}
2438

39+
/**
40+
* Generate a hash from filter state for deterministic shuffle.
41+
*/
42+
function hashFilters(filters: ActiveFilters): number {
43+
const str = JSON.stringify(filters);
44+
let hash = 0;
45+
for (let i = 0; i < str.length; i++) {
46+
const char = str.charCodeAt(i);
47+
hash = (hash << 5) - hash + char;
48+
hash = hash & hash;
49+
}
50+
return Math.abs(hash);
51+
}
52+
2553
/**
2654
* Parse URL params into ActiveFilters.
2755
* URL format: ?lib=matplotlib&lib=seaborn (AND) or ?lib=matplotlib,seaborn (OR within group)
@@ -106,19 +134,35 @@ export function useFilterState({
106134
onTrackPageview,
107135
onTrackEvent,
108136
}: UseFilterStateOptions): UseFilterStateReturn {
109-
// Filter state - initialize from URL params immediately
110-
const [activeFilters, setActiveFilters] = useState<ActiveFilters>(() => parseUrlFilters());
111-
const [filterCounts, setFilterCounts] = useState<FilterCounts | null>(null);
112-
const [globalCounts, setGlobalCounts] = useState<FilterCounts | null>(null);
113-
const [orCounts, setOrCounts] = useState<Record<string, number>[]>([]);
137+
const { homeStateRef, setHomeState } = useHomeState();
138+
139+
// Initialize from persistent state (ref) or URL params (all using lazy initializers)
140+
const [activeFilters, setActiveFilters] = useState<ActiveFilters>(() =>
141+
homeStateRef.current.initialized ? homeStateRef.current.activeFilters : parseUrlFilters()
142+
);
143+
const [filterCounts, setFilterCounts] = useState<FilterCounts | null>(() =>
144+
homeStateRef.current.initialized ? homeStateRef.current.filterCounts : null
145+
);
146+
const [globalCounts, setGlobalCounts] = useState<FilterCounts | null>(() =>
147+
homeStateRef.current.initialized ? homeStateRef.current.globalCounts : null
148+
);
149+
const [orCounts, setOrCounts] = useState<Record<string, number>[]>(() =>
150+
homeStateRef.current.initialized ? homeStateRef.current.orCounts : []
151+
);
114152

115-
// Image state
116-
const [allImages, setAllImages] = useState<PlotImage[]>([]);
117-
const [displayedImages, setDisplayedImages] = useState<PlotImage[]>([]);
118-
const [hasMore, setHasMore] = useState(false);
153+
// Image state - restore from persistent state if available
154+
const [allImages, setAllImages] = useState<PlotImage[]>(() =>
155+
homeStateRef.current.initialized ? homeStateRef.current.allImages : []
156+
);
157+
const [displayedImages, setDisplayedImages] = useState<PlotImage[]>(() =>
158+
homeStateRef.current.initialized ? homeStateRef.current.displayedImages : []
159+
);
160+
const [hasMore, setHasMore] = useState(() =>
161+
homeStateRef.current.initialized ? homeStateRef.current.hasMore : false
162+
);
119163

120164
// UI state
121-
const [loading, setLoading] = useState(true);
165+
const [loading, setLoading] = useState(() => !homeStateRef.current.initialized);
122166
const [error, setError] = useState<string>('');
123167
const [randomAnimation, setRandomAnimation] = useState<{
124168
index: number;
@@ -130,6 +174,23 @@ export function useFilterState({
130174
const activeFiltersRef = useRef(activeFilters);
131175
activeFiltersRef.current = activeFilters;
132176

177+
// Sync state changes back to persistent context
178+
useEffect(() => {
179+
if (allImages.length > 0 || displayedImages.length > 0) {
180+
setHomeState((prev) => ({
181+
...prev,
182+
allImages,
183+
displayedImages,
184+
activeFilters,
185+
filterCounts,
186+
globalCounts,
187+
orCounts,
188+
hasMore,
189+
initialized: true,
190+
}));
191+
}
192+
}, [allImages, displayedImages, activeFilters, filterCounts, globalCounts, orCounts, hasMore, setHomeState]);
193+
133194
// Add a new filter group (creates new chip - AND with other groups)
134195
const handleAddFilter = useCallback((category: FilterCategory, value: string) => {
135196
setActiveFilters((prev) => [...prev, { category, values: [value] }]);
@@ -234,8 +295,23 @@ export function useFilterState({
234295
onTrackPageview();
235296
}, [activeFilters, onTrackPageview]);
236297

298+
// Track if we should skip initial fetch (restored from persistent state)
299+
const initializedRef = useRef(homeStateRef.current.initialized);
300+
const filtersMatchRef = useRef(
301+
homeStateRef.current.initialized && JSON.stringify(homeStateRef.current.activeFilters) === JSON.stringify(activeFilters)
302+
);
303+
237304
// Load filtered images when filters change
238305
useEffect(() => {
306+
// Skip fetch on first mount if restored from persistent state with same filters
307+
if (initializedRef.current && filtersMatchRef.current) {
308+
initializedRef.current = false;
309+
filtersMatchRef.current = false;
310+
return;
311+
}
312+
initializedRef.current = false;
313+
filtersMatchRef.current = false;
314+
239315
const abortController = new AbortController();
240316

241317
const fetchFilteredImages = async () => {
@@ -265,9 +341,12 @@ export function useFilterState({
265341
setGlobalCounts(data.globalCounts || data.counts);
266342
setOrCounts(data.orCounts || []);
267343

268-
// Shuffle and set images
269-
const shuffled = shuffleArray<PlotImage>(data.images || []);
344+
// Shuffle with deterministic seed based on filters
345+
const seed = hashFilters(activeFilters);
346+
const shuffled = shuffleArray<PlotImage>(data.images || [], seed);
270347
setAllImages(shuffled);
348+
349+
// Initial display count
271350
setDisplayedImages(shuffled.slice(0, BATCH_SIZE));
272351
setHasMore(shuffled.length > BATCH_SIZE);
273352
} catch (err) {

app/src/pages/HomePage.tsx

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,31 +9,18 @@ import type { PlotImage } from '../types';
99
import type { ImageSize } from '../constants';
1010
import { useInfiniteScroll, useAnalytics, useFilterState, isFiltersEmpty } from '../hooks';
1111
import { Header, Footer, FilterBar, ImagesGrid, FullscreenModal } from '../components';
12-
import { useAppData } from '../components/Layout';
12+
import { useAppData, useHomeState } from '../components/Layout';
1313

1414
export function HomePage() {
1515
const navigate = useNavigate();
1616
const { specsData, librariesData, stats } = useAppData();
17+
const { homeStateRef, saveScrollPosition } = useHomeState();
1718

18-
// Handle scroll restoration on back navigation
19+
// Disable browser's automatic scroll restoration
1920
useEffect(() => {
2021
if ('scrollRestoration' in history) {
2122
history.scrollRestoration = 'manual';
2223
}
23-
24-
// Check for saved scroll position from back navigation
25-
const savedScrollY = sessionStorage.getItem('homeScrollY');
26-
if (savedScrollY) {
27-
// Delay scroll restoration to allow images to load
28-
const timer = setTimeout(() => {
29-
window.scrollTo(0, parseInt(savedScrollY, 10));
30-
// Clear the saved position
31-
sessionStorage.removeItem('homeScrollY');
32-
}, 150);
33-
return () => clearTimeout(timer);
34-
} else {
35-
window.scrollTo(0, 0);
36-
}
3724
}, []);
3825

3926
// Custom hooks
@@ -69,6 +56,21 @@ export function HomePage() {
6956
setHasMore,
7057
});
7158

59+
// Restore scroll position from persistent state (ref for sync access)
60+
const scrollRestoredRef = useRef(false);
61+
useEffect(() => {
62+
if (scrollRestoredRef.current) return;
63+
const savedScrollY = homeStateRef.current.scrollY;
64+
if (savedScrollY > 0 && displayedImages.length > 0) {
65+
requestAnimationFrame(() => {
66+
window.scrollTo(0, savedScrollY);
67+
scrollRestoredRef.current = true;
68+
});
69+
} else if (displayedImages.length > 0) {
70+
scrollRestoredRef.current = true;
71+
}
72+
}, [homeStateRef, displayedImages.length]);
73+
7274
// UI state
7375
const [modalImage, setModalImage] = useState<PlotImage | null>(null);
7476
const [openImageTooltip, setOpenImageTooltip] = useState<string | null>(null);
@@ -102,16 +104,16 @@ export function HomePage() {
102104
document.activeElement.blur();
103105
}
104106

105-
// Save scroll position for back navigation
106-
sessionStorage.setItem('homeScrollY', String(window.scrollY));
107+
// Save scroll position synchronously to ref before navigation
108+
saveScrollPosition();
107109

108-
// Navigate to spec page
110+
// Navigate to spec page immediately
109111
const specId = img.spec_id || '';
110112
const library = img.library;
111113
navigate(`/${specId}/${library}`);
112114
trackEvent('navigate_to_spec', { spec: specId, library });
113115
},
114-
[navigate, trackEvent]
116+
[navigate, trackEvent, saveScrollPosition]
115117
);
116118

117119
// Close tooltip when clicking anywhere

app/src/pages/SpecPage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ export function SpecPage() {
247247
<Box
248248
sx={{
249249
position: 'relative',
250-
maxWidth: 1100,
250+
maxWidth: { xs: '100%', md: 1200, lg: 1400, xl: 1600 },
251251
mx: 'auto',
252252
borderRadius: 2,
253253
overflow: 'hidden',

0 commit comments

Comments
 (0)