feat: Centralize UI state management

This commit is contained in:
Rodrigo Verdiani 2025-12-31 20:51:48 -03:00
parent b3fe4074d8
commit 9dace4ce94
5 changed files with 210 additions and 36 deletions

View 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;
};

View 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
};

View File

@ -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;

View File

@ -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>

View File

@ -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>
);