feat: Centralize UI state management
This commit is contained in:
parent
b3fe4074d8
commit
9dace4ce94
143
src/contexts/UIStateContext.tsx
Normal file
143
src/contexts/UIStateContext.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
import {
|
||||
type ReactNode,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { SortOption } from "@/features/home/components/SortDropdown.tsx";
|
||||
|
||||
interface UIStateContextType {
|
||||
/* Home Filter State */
|
||||
currentPage: number;
|
||||
setCurrentPage: (page: number) => void;
|
||||
selectedGenres: number[];
|
||||
setSelectedGenres: (genres: number[]) => void;
|
||||
selectedStatus: string[];
|
||||
setSelectedStatus: (status: string[]) => void;
|
||||
minRating: number;
|
||||
setMinRating: (rating: number) => void;
|
||||
userFavorites: boolean;
|
||||
setUserFavorites: (favorites: boolean) => void;
|
||||
showAdultContent: boolean;
|
||||
setShowAdultContent: (show: boolean) => void;
|
||||
sortOption: SortOption;
|
||||
setSortOption: (sort: SortOption) => void;
|
||||
searchText: string;
|
||||
setSearchText: (text: string) => void;
|
||||
resetFilters: () => void;
|
||||
|
||||
/* Manga Provider Card State */
|
||||
expandedProviderIds: number[];
|
||||
toggleProviderId: (id: number) => void;
|
||||
|
||||
/* Scroll Persistence */
|
||||
scrollPositions: Record<string, number>;
|
||||
setScrollPosition: (key: string, position: number) => void;
|
||||
}
|
||||
|
||||
const UIStateContext = createContext<UIStateContextType | undefined>(undefined);
|
||||
|
||||
export const UIStateProvider = ({ children }: { children: ReactNode }) => {
|
||||
/* Home Filter State */
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [selectedGenres, setSelectedGenres] = useState<number[]>([]);
|
||||
const [selectedStatus, setSelectedStatus] = useState<string[]>([]);
|
||||
const [minRating, setMinRating] = useState(0);
|
||||
const [userFavorites, setUserFavorites] = useState(false);
|
||||
const [showAdultContent, setShowAdultContent] = useState(false);
|
||||
const [sortOption, setSortOption] = useState<SortOption>("title-asc");
|
||||
const [searchText, setSearchText] = useState("");
|
||||
|
||||
/* Manga Provider Card State */
|
||||
const [expandedProviderIds, setExpandedProviderIds] = useState<number[]>([]);
|
||||
|
||||
/* Scroll Persistence */
|
||||
const [scrollPositions, setScrollPositions] = useState<
|
||||
Record<string, number>
|
||||
>({});
|
||||
|
||||
|
||||
|
||||
const resetFilters = useCallback(() => {
|
||||
setCurrentPage(1);
|
||||
setSelectedGenres([]);
|
||||
setSelectedStatus([]);
|
||||
setMinRating(0);
|
||||
setUserFavorites(false);
|
||||
setShowAdultContent(false);
|
||||
setSortOption("title-asc");
|
||||
setSearchText("");
|
||||
}, []);
|
||||
|
||||
const toggleProviderId = useCallback((id: number) => {
|
||||
setExpandedProviderIds((prev) => {
|
||||
if (prev.includes(id)) {
|
||||
return prev.filter((pId) => pId !== id);
|
||||
} else {
|
||||
return [...prev, id];
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setScrollPosition = useCallback((key: string, position: number) => {
|
||||
setScrollPositions((prev) => ({
|
||||
...prev,
|
||||
[key]: position,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
selectedGenres,
|
||||
setSelectedGenres,
|
||||
selectedStatus,
|
||||
setSelectedStatus,
|
||||
minRating,
|
||||
setMinRating,
|
||||
userFavorites,
|
||||
setUserFavorites,
|
||||
showAdultContent,
|
||||
setShowAdultContent,
|
||||
sortOption,
|
||||
setSortOption,
|
||||
searchText,
|
||||
setSearchText,
|
||||
resetFilters,
|
||||
expandedProviderIds,
|
||||
toggleProviderId,
|
||||
scrollPositions,
|
||||
setScrollPosition,
|
||||
}),
|
||||
[
|
||||
currentPage,
|
||||
selectedGenres,
|
||||
selectedStatus,
|
||||
minRating,
|
||||
userFavorites,
|
||||
showAdultContent,
|
||||
sortOption,
|
||||
searchText,
|
||||
resetFilters,
|
||||
expandedProviderIds,
|
||||
toggleProviderId,
|
||||
scrollPositions,
|
||||
setScrollPosition,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<UIStateContext.Provider value={value}>{children}</UIStateContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useUIState = () => {
|
||||
const context = useContext(UIStateContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useUIState must be used within a UIStateProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
19
src/hooks/useScrollPersistence.ts
Normal file
19
src/hooks/useScrollPersistence.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { useLayoutEffect } from "react";
|
||||
import { useUIState } from "@/contexts/UIStateContext.tsx";
|
||||
|
||||
export const useScrollPersistence = (key: string) => {
|
||||
const { scrollPositions, setScrollPosition } = useUIState();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// Restore scroll position
|
||||
const savedPosition = scrollPositions[key];
|
||||
if (savedPosition !== undefined) {
|
||||
window.scrollTo(0, savedPosition);
|
||||
}
|
||||
|
||||
// Save scroll position on unmount or before key changes
|
||||
return () => {
|
||||
setScrollPosition(key, window.scrollY);
|
||||
};
|
||||
}, [key, setScrollPosition]); // eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
};
|
||||
@ -1,5 +1,5 @@
|
||||
import { BookOpen, Search } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import { useGetMangas } from "@/api/generated/manga/manga.ts";
|
||||
import { AuthHeader } from "@/components/AuthHeader.tsx";
|
||||
@ -11,20 +11,31 @@ import { ImportDropdown } from "@/features/home/components/ImportDropdown.tsx";
|
||||
import { MangaGrid } from "@/features/home/components/MangaGrid.tsx";
|
||||
import {
|
||||
SortDropdown,
|
||||
type SortOption,
|
||||
} from "@/features/home/components/SortDropdown.tsx";
|
||||
import { useUIState } from "@/contexts/UIStateContext.tsx";
|
||||
|
||||
const ITEMS_PER_PAGE = 12;
|
||||
|
||||
const Home = () => {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [selectedGenres, setSelectedGenres] = useState<number[]>([]);
|
||||
const [selectedStatus, setSelectedStatus] = useState<string[]>([]);
|
||||
const [minRating, setMinRating] = useState(0);
|
||||
const [userFavorites, setUserFavorites] = useState(false);
|
||||
const [showAdultContent, setShowAdultContent] = useState(false);
|
||||
const [sortOption, setSortOption] = useState<SortOption>("title-asc");
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const {
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
selectedGenres,
|
||||
setSelectedGenres,
|
||||
selectedStatus,
|
||||
setSelectedStatus,
|
||||
minRating,
|
||||
setMinRating,
|
||||
userFavorites,
|
||||
setUserFavorites,
|
||||
showAdultContent,
|
||||
setShowAdultContent,
|
||||
sortOption,
|
||||
setSortOption,
|
||||
searchText,
|
||||
setSearchText,
|
||||
} = useUIState();
|
||||
|
||||
const [debouncedSearchText] = useDebounce(searchText, 500);
|
||||
|
||||
const { data: mangasData, queryKey: mangasQueryKey } = useGetMangas({
|
||||
@ -38,9 +49,14 @@ const Home = () => {
|
||||
score: minRating,
|
||||
});
|
||||
|
||||
const startSearchRef = useRef(debouncedSearchText);
|
||||
|
||||
useEffect(() => {
|
||||
// Resets current page on search text change
|
||||
setCurrentPage(1);
|
||||
// Resets current page only when search text actually changes
|
||||
if (startSearchRef.current !== debouncedSearchText) {
|
||||
setCurrentPage(1);
|
||||
startSearchRef.current = debouncedSearchText;
|
||||
}
|
||||
}, [debouncedSearchText]);
|
||||
|
||||
const totalPages = mangasData?.data?.totalPages;
|
||||
|
||||
@ -10,7 +10,7 @@ import {
|
||||
Heart,
|
||||
Star,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@ -34,6 +34,8 @@ import {
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible.tsx";
|
||||
import { useAuth } from "@/contexts/AuthContext.tsx";
|
||||
import { useUIState } from "@/contexts/UIStateContext.tsx";
|
||||
import { useScrollPersistence } from "@/hooks/useScrollPersistence.ts";
|
||||
import { MangaChapter } from "@/features/manga/MangaChapter.tsx";
|
||||
import { formatToTwoDigitsDateRange } from "@/utils/dateFormatter.ts";
|
||||
|
||||
@ -62,7 +64,8 @@ const Manga = () => {
|
||||
|
||||
const isPending = fetchPending || fetchAllPending;
|
||||
|
||||
const [openProviders, setOpenProviders] = useState<Set<number>>(new Set());
|
||||
const { expandedProviderIds, toggleProviderId } = useUIState();
|
||||
useScrollPersistence(`manga-${mangaId}`);
|
||||
|
||||
const { mutate: mutateFavorite, isPending: isPendingFavorite } =
|
||||
useSetFavorite({
|
||||
@ -137,15 +140,7 @@ const Manga = () => {
|
||||
}
|
||||
|
||||
const toggleProvider = (providerId: number) => {
|
||||
setOpenProviders((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(providerId)) {
|
||||
next.delete(providerId);
|
||||
} else {
|
||||
next.add(providerId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
toggleProviderId(providerId);
|
||||
};
|
||||
|
||||
return (
|
||||
@ -181,8 +176,8 @@ const Manga = () => {
|
||||
src={
|
||||
(mangaData.data?.coverImageKey &&
|
||||
import.meta.env.VITE_OMV_BASE_URL +
|
||||
"/" +
|
||||
mangaData.data?.coverImageKey) ||
|
||||
"/" +
|
||||
mangaData.data?.coverImageKey) ||
|
||||
"/placeholder.svg"
|
||||
}
|
||||
alt={mangaData.data?.title ?? ""}
|
||||
@ -234,11 +229,10 @@ const Manga = () => {
|
||||
disabled={isPendingFavoriteChange}
|
||||
>
|
||||
<Heart
|
||||
className={`h-4 w-4 transition-colors ${
|
||||
mangaData?.data?.favorite
|
||||
? "fill-red-500 text-red-500"
|
||||
: "text-muted-foreground hover:text-red-500"
|
||||
}`}
|
||||
className={`h-4 w-4 transition-colors ${mangaData?.data?.favorite
|
||||
? "fill-red-500 text-red-500"
|
||||
: "text-muted-foreground hover:text-red-500"
|
||||
}`}
|
||||
/>
|
||||
</Button>
|
||||
</>
|
||||
@ -292,7 +286,7 @@ const Manga = () => {
|
||||
<p className="text-sm text-muted-foreground">Chapters</p>
|
||||
<p className="text-lg font-semibold text-foreground">
|
||||
{mangaData.data?.chapterCount &&
|
||||
mangaData.data?.chapterCount > 0
|
||||
mangaData.data?.chapterCount > 0
|
||||
? mangaData.data?.chapterCount
|
||||
: "-"}
|
||||
</p>
|
||||
@ -355,7 +349,7 @@ const Manga = () => {
|
||||
{mangaData.data?.providers.map((provider) => (
|
||||
<Card key={provider.id} className="border-border bg-card">
|
||||
<Collapsible
|
||||
open={openProviders.has(provider.id)}
|
||||
open={expandedProviderIds.includes(provider.id)}
|
||||
onOpenChange={() => toggleProvider(provider.id)}
|
||||
>
|
||||
<CollapsibleTrigger className="w-full">
|
||||
@ -406,9 +400,8 @@ const Manga = () => {
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={`h-5 w-5 text-muted-foreground transition-transform ${
|
||||
openProviders.has(provider.id) ? "rotate-180" : ""
|
||||
}`}
|
||||
className={`h-5 w-5 text-muted-foreground transition-transform ${expandedProviderIds.includes(provider.id) ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</CardContent>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
@ -2,6 +2,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import { Toaster } from "sonner";
|
||||
import { AuthProvider } from "@/contexts/AuthContext.tsx";
|
||||
import { UIStateProvider } from "@/contexts/UIStateContext.tsx";
|
||||
import { ThemeProvider } from "@/providers/ThemeProvider.tsx";
|
||||
|
||||
interface ProvidersProps {
|
||||
@ -13,7 +14,9 @@ export const Providers = ({ children }: ProvidersProps) => {
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<ThemeProvider>
|
||||
<Toaster />
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
<AuthProvider>
|
||||
<UIStateProvider>{children}</UIStateProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user