feat: add collapsible filter sidebar with loading states and disabled inputs during fetch

This commit is contained in:
Rodrigo Verdiani 2026-04-05 20:00:30 -03:00
parent 0ef91b6f6f
commit 451b45f2d3
4 changed files with 86 additions and 31 deletions

View File

@ -26,6 +26,8 @@ interface UIStateContextType {
setSortOption: (sort: SortOption) => void; setSortOption: (sort: SortOption) => void;
searchText: string; searchText: string;
setSearchText: (text: string) => void; setSearchText: (text: string) => void;
isSidebarOpen: boolean;
setIsSidebarOpen: (open: boolean) => void;
resetFilters: () => void; resetFilters: () => void;
/* Manga Provider Card State */ /* Manga Provider Card State */
@ -49,6 +51,7 @@ export const UIStateProvider = ({ children }: { children: ReactNode }) => {
const [showAdultContent, setShowAdultContent] = useState(false); const [showAdultContent, setShowAdultContent] = useState(false);
const [sortOption, setSortOption] = useState<SortOption>("title-asc"); const [sortOption, setSortOption] = useState<SortOption>("title-asc");
const [searchText, setSearchText] = useState(""); const [searchText, setSearchText] = useState("");
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
/* Manga Provider Card State */ /* Manga Provider Card State */
const [expandedProviderIds, setExpandedProviderIds] = useState<number[]>([]); const [expandedProviderIds, setExpandedProviderIds] = useState<number[]>([]);
@ -104,6 +107,8 @@ export const UIStateProvider = ({ children }: { children: ReactNode }) => {
setSortOption, setSortOption,
searchText, searchText,
setSearchText, setSearchText,
isSidebarOpen,
setIsSidebarOpen,
resetFilters, resetFilters,
expandedProviderIds, expandedProviderIds,
toggleProviderId, toggleProviderId,
@ -119,6 +124,7 @@ export const UIStateProvider = ({ children }: { children: ReactNode }) => {
showAdultContent, showAdultContent,
sortOption, sortOption,
searchText, searchText,
isSidebarOpen,
resetFilters, resetFilters,
expandedProviderIds, expandedProviderIds,
toggleProviderId, toggleProviderId,

View File

@ -1,4 +1,4 @@
import { Star, X } from "lucide-react"; import { PanelLeftClose, Star, X } from "lucide-react";
import { useGetGenres } from "@/api/generated/catalog/catalog.ts"; import { useGetGenres } from "@/api/generated/catalog/catalog.ts";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -18,6 +18,8 @@ interface FilterSidebarProps {
onRatingChange: (rating: number) => void; onRatingChange: (rating: number) => void;
onUserFavoritesChange: (favorites: boolean) => void; onUserFavoritesChange: (favorites: boolean) => void;
onShowAdultContentChange: (showAdult: boolean) => void; onShowAdultContentChange: (showAdult: boolean) => void;
onHide: () => void;
isDisabled?: boolean;
} }
const STATUSES = ["Ongoing", "Completed", "Hiatus"]; const STATUSES = ["Ongoing", "Completed", "Hiatus"];
@ -40,6 +42,8 @@ export function FilterSidebar({
onRatingChange, onRatingChange,
onUserFavoritesChange, onUserFavoritesChange,
onShowAdultContentChange, onShowAdultContentChange,
onHide,
isDisabled = false,
}: FilterSidebarProps) { }: FilterSidebarProps) {
const { data: genresData, isPending: isPendingGenres } = useGetGenres(); const { data: genresData, isPending: isPendingGenres } = useGetGenres();
const { isAuthenticated } = useAuth(); const { isAuthenticated } = useAuth();
@ -79,14 +83,25 @@ export function FilterSidebar({
<div className="sticky top-0 flex h-screen flex-col"> <div className="sticky top-0 flex h-screen flex-col">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between border-b border-sidebar-border px-6 py-6"> <div className="flex items-center justify-between border-b border-sidebar-border px-6 py-6">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={onHide}
className="h-8 w-8 text-sidebar-foreground hover:bg-sidebar-accent"
>
<PanelLeftClose className="h-5 w-5" />
</Button>
<h2 className="text-lg font-semibold text-sidebar-foreground"> <h2 className="text-lg font-semibold text-sidebar-foreground">
Filters Filters
</h2> </h2>
</div>
{hasActiveFilters && ( {hasActiveFilters && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={clearAllFilters} onClick={clearAllFilters}
disabled={isDisabled}
className="h-8 px-2 text-xs" className="h-8 px-2 text-xs"
> >
Clear Clear
@ -110,6 +125,7 @@ export function FilterSidebar({
<Switch <Switch
checked={userFavorites} checked={userFavorites}
onCheckedChange={onUserFavoritesChange} onCheckedChange={onUserFavoritesChange}
disabled={isDisabled}
/> />
</div> </div>
<div className="flex items-center justify-between rounded-md px-3 py-2"> <div className="flex items-center justify-between rounded-md px-3 py-2">
@ -119,6 +135,7 @@ export function FilterSidebar({
<Switch <Switch
checked={showAdultContent} checked={showAdultContent}
onCheckedChange={onShowAdultContentChange} onCheckedChange={onShowAdultContentChange}
disabled={isDisabled}
/> />
</div> </div>
</div> </div>
@ -145,8 +162,8 @@ export function FilterSidebar({
isSelected isSelected
? "bg-sidebar-primary text-sidebar-primary-foreground hover:bg-sidebar-primary/90" ? "bg-sidebar-primary text-sidebar-primary-foreground hover:bg-sidebar-primary/90"
: "border-sidebar-border text-sidebar-foreground hover:bg-sidebar-accent" : "border-sidebar-border text-sidebar-foreground hover:bg-sidebar-accent"
}`} } ${isDisabled ? "opacity-50 pointer-events-none grayscale-[0.5]" : ""}`}
onClick={() => toggleGenre(genre.id)} onClick={() => !isDisabled && toggleGenre(genre.id)}
> >
{genre.name} {genre.name}
{isSelected && <X className="ml-1 h-3 w-3" />} {isSelected && <X className="ml-1 h-3 w-3" />}
@ -174,8 +191,8 @@ export function FilterSidebar({
isSelected isSelected
? "bg-sidebar-primary text-sidebar-primary-foreground hover:bg-sidebar-primary/90" ? "bg-sidebar-primary text-sidebar-primary-foreground hover:bg-sidebar-primary/90"
: "border-sidebar-border text-sidebar-foreground hover:bg-sidebar-accent" : "border-sidebar-border text-sidebar-foreground hover:bg-sidebar-accent"
}`} } ${isDisabled ? "opacity-50 pointer-events-none grayscale-[0.5]" : ""}`}
onClick={() => toggleStatus(status)} onClick={() => !isDisabled && toggleStatus(status)}
> >
{status} {status}
{isSelected && <X className="ml-1 h-3 w-3" />} {isSelected && <X className="ml-1 h-3 w-3" />}
@ -203,7 +220,8 @@ export function FilterSidebar({
isSelected isSelected
? "bg-sidebar-accent text-sidebar-accent-foreground" ? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground hover:bg-sidebar-accent/50" : "text-sidebar-foreground hover:bg-sidebar-accent/50"
}`} } ${isDisabled ? "opacity-50 pointer-events-none" : ""}`}
disabled={isDisabled}
> >
<Star <Star
className={`h-4 w-4 ${isSelected ? "fill-sidebar-primary text-sidebar-primary" : "text-muted-foreground"}`} className={`h-4 w-4 ${isSelected ? "fill-sidebar-primary text-sidebar-primary" : "text-muted-foreground"}`}

View File

@ -21,11 +21,13 @@ export type SortOption =
interface SortDropdownProps { interface SortDropdownProps {
currentSort: SortOption; currentSort: SortOption;
onSortChange: (sort: SortOption) => void; onSortChange: (sort: SortOption) => void;
isDisabled?: boolean;
} }
export const SortDropdown = ({ export const SortDropdown = ({
currentSort, currentSort,
onSortChange, onSortChange,
isDisabled = false,
}: SortDropdownProps) => { }: SortDropdownProps) => {
const getSortLabel = () => { const getSortLabel = () => {
switch (currentSort) { switch (currentSort) {
@ -51,7 +53,12 @@ export const SortDropdown = ({
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-2 bg-transparent"> <Button
variant="outline"
size="sm"
className="gap-2 bg-transparent"
disabled={isDisabled}
>
<ArrowUpDown className="h-4 w-4" /> <ArrowUpDown className="h-4 w-4" />
{getSortLabel()} {getSortLabel()}
</Button> </Button>

View File

@ -1,12 +1,13 @@
import { BookOpen, Search } from "lucide-react"; import { BookOpen, PanelLeftOpen, Search } from "lucide-react";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useDebounce } from "use-debounce"; import { useDebounce } from "use-debounce";
import { useGetMangas } from "@/api/generated/catalog/catalog.ts"; import { useGetMangas } from "@/api/generated/catalog/catalog.ts";
import { AuthHeader } from "@/components/AuthHeader.tsx"; import { AuthHeader } from "@/components/AuthHeader.tsx";
import { Spinner } from "@/components/ui/spinner.tsx"; import { Button } from "@/components/ui/button.tsx";
import { Pagination } from "@/components/Pagination.tsx"; import { Pagination } from "@/components/Pagination.tsx";
import { ThemeToggle } from "@/components/ThemeToggle.tsx"; import { ThemeToggle } from "@/components/ThemeToggle.tsx";
import { Input } from "@/components/ui/input.tsx"; import { Input } from "@/components/ui/input.tsx";
import { Skeleton } from "@/components/ui/skeleton.tsx";
import { useUIState } from "@/contexts/UIStateContext.tsx"; import { useUIState } from "@/contexts/UIStateContext.tsx";
import { FilterSidebar } from "@/features/home/components/FilterSidebar.tsx"; import { FilterSidebar } from "@/features/home/components/FilterSidebar.tsx";
import { MangaGrid } from "@/features/home/components/MangaGrid.tsx"; import { MangaGrid } from "@/features/home/components/MangaGrid.tsx";
@ -35,6 +36,8 @@ const Home = () => {
setSortOption, setSortOption,
searchText, searchText,
setSearchText, setSearchText,
isSidebarOpen,
setIsSidebarOpen,
} = useUIState(); } = useUIState();
const [debouncedSearchText] = useDebounce(searchText, 500); const [debouncedSearchText] = useDebounce(searchText, 500);
@ -54,6 +57,7 @@ const Home = () => {
userFavorites, userFavorites,
score: minRating, score: minRating,
}); });
const isFiltersDisabled = isFetching;
const startSearchRef = useRef(debouncedSearchText); const startSearchRef = useRef(debouncedSearchText);
@ -69,6 +73,7 @@ const Home = () => {
return ( return (
<div className="flex min-h-screen bg-background"> <div className="flex min-h-screen bg-background">
{isSidebarOpen && (
<FilterSidebar <FilterSidebar
selectedGenres={selectedGenres} selectedGenres={selectedGenres}
selectedStatus={selectedStatus} selectedStatus={selectedStatus}
@ -80,7 +85,10 @@ const Home = () => {
onRatingChange={setMinRating} onRatingChange={setMinRating}
onUserFavoritesChange={setUserFavorites} onUserFavoritesChange={setUserFavorites}
onShowAdultContentChange={setShowAdultContent} onShowAdultContentChange={setShowAdultContent}
onHide={() => setIsSidebarOpen(false)}
isDisabled={isFiltersDisabled}
/> />
)}
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col">
<header className="border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"> <header className="border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
@ -93,17 +101,31 @@ const Home = () => {
<h1 className="text-3xl font-bold tracking-tight text-foreground"> <h1 className="text-3xl font-bold tracking-tight text-foreground">
MangaMochi MangaMochi
</h1> </h1>
<div className="mt-1 flex items-center gap-2"> <div className="mt-1 flex items-center gap-2 min-h-[1.25rem]">
{isFetching ? (
<Skeleton className="h-4 w-24" />
) : (
<>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{mangasData?.data?.totalElements ?? 0} titles available {mangasData?.data?.totalElements ?? 0} titles available
</p> </p>
{isFetching && ( </>
<Spinner className="size-3 text-muted-foreground" />
)} )}
</div> </div>
</div> </div>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{!isSidebarOpen && (
<Button
variant="outline"
size="sm"
onClick={() => setIsSidebarOpen(true)}
className="gap-2"
>
<PanelLeftOpen className="h-4 w-4" />
Filters
</Button>
)}
<ThemeToggle /> <ThemeToggle />
<AuthHeader /> <AuthHeader />
</div> </div>
@ -119,7 +141,8 @@ const Home = () => {
onChange={(e) => { onChange={(e) => {
setSearchText(e.target.value); setSearchText(e.target.value);
}} }}
className="pl-10 bg-card border-border" disabled={isFiltersDisabled}
className="pl-10 bg-card border-border disabled:opacity-50"
/> />
</div> </div>
</div> </div>
@ -144,6 +167,7 @@ const Home = () => {
<SortDropdown <SortDropdown
currentSort={sortOption} currentSort={sortOption}
onSortChange={setSortOption} onSortChange={setSortOption}
isDisabled={isFiltersDisabled}
/> />
</div> </div>
)} )}