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;
searchText: string;
setSearchText: (text: string) => void;
isSidebarOpen: boolean;
setIsSidebarOpen: (open: boolean) => void;
resetFilters: () => void;
/* Manga Provider Card State */
@ -49,6 +51,7 @@ export const UIStateProvider = ({ children }: { children: ReactNode }) => {
const [showAdultContent, setShowAdultContent] = useState(false);
const [sortOption, setSortOption] = useState<SortOption>("title-asc");
const [searchText, setSearchText] = useState("");
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
/* Manga Provider Card State */
const [expandedProviderIds, setExpandedProviderIds] = useState<number[]>([]);
@ -104,6 +107,8 @@ export const UIStateProvider = ({ children }: { children: ReactNode }) => {
setSortOption,
searchText,
setSearchText,
isSidebarOpen,
setIsSidebarOpen,
resetFilters,
expandedProviderIds,
toggleProviderId,
@ -119,6 +124,7 @@ export const UIStateProvider = ({ children }: { children: ReactNode }) => {
showAdultContent,
sortOption,
searchText,
isSidebarOpen,
resetFilters,
expandedProviderIds,
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 { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@ -18,6 +18,8 @@ interface FilterSidebarProps {
onRatingChange: (rating: number) => void;
onUserFavoritesChange: (favorites: boolean) => void;
onShowAdultContentChange: (showAdult: boolean) => void;
onHide: () => void;
isDisabled?: boolean;
}
const STATUSES = ["Ongoing", "Completed", "Hiatus"];
@ -40,6 +42,8 @@ export function FilterSidebar({
onRatingChange,
onUserFavoritesChange,
onShowAdultContentChange,
onHide,
isDisabled = false,
}: FilterSidebarProps) {
const { data: genresData, isPending: isPendingGenres } = useGetGenres();
const { isAuthenticated } = useAuth();
@ -79,14 +83,25 @@ export function FilterSidebar({
<div className="sticky top-0 flex h-screen flex-col">
{/* Header */}
<div className="flex items-center justify-between border-b border-sidebar-border px-6 py-6">
<h2 className="text-lg font-semibold text-sidebar-foreground">
Filters
</h2>
<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">
Filters
</h2>
</div>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
onClick={clearAllFilters}
disabled={isDisabled}
className="h-8 px-2 text-xs"
>
Clear
@ -110,6 +125,7 @@ export function FilterSidebar({
<Switch
checked={userFavorites}
onCheckedChange={onUserFavoritesChange}
disabled={isDisabled}
/>
</div>
<div className="flex items-center justify-between rounded-md px-3 py-2">
@ -119,6 +135,7 @@ export function FilterSidebar({
<Switch
checked={showAdultContent}
onCheckedChange={onShowAdultContentChange}
disabled={isDisabled}
/>
</div>
</div>
@ -145,8 +162,8 @@ export function FilterSidebar({
isSelected
? "bg-sidebar-primary text-sidebar-primary-foreground hover:bg-sidebar-primary/90"
: "border-sidebar-border text-sidebar-foreground hover:bg-sidebar-accent"
}`}
onClick={() => toggleGenre(genre.id)}
} ${isDisabled ? "opacity-50 pointer-events-none grayscale-[0.5]" : ""}`}
onClick={() => !isDisabled && toggleGenre(genre.id)}
>
{genre.name}
{isSelected && <X className="ml-1 h-3 w-3" />}
@ -174,8 +191,8 @@ export function FilterSidebar({
isSelected
? "bg-sidebar-primary text-sidebar-primary-foreground hover:bg-sidebar-primary/90"
: "border-sidebar-border text-sidebar-foreground hover:bg-sidebar-accent"
}`}
onClick={() => toggleStatus(status)}
} ${isDisabled ? "opacity-50 pointer-events-none grayscale-[0.5]" : ""}`}
onClick={() => !isDisabled && toggleStatus(status)}
>
{status}
{isSelected && <X className="ml-1 h-3 w-3" />}
@ -203,7 +220,8 @@ export function FilterSidebar({
isSelected
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground hover:bg-sidebar-accent/50"
}`}
} ${isDisabled ? "opacity-50 pointer-events-none" : ""}`}
disabled={isDisabled}
>
<Star
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 {
currentSort: SortOption;
onSortChange: (sort: SortOption) => void;
isDisabled?: boolean;
}
export const SortDropdown = ({
currentSort,
onSortChange,
isDisabled = false,
}: SortDropdownProps) => {
const getSortLabel = () => {
switch (currentSort) {
@ -51,7 +53,12 @@ export const SortDropdown = ({
return (
<DropdownMenu>
<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" />
{getSortLabel()}
</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 { useDebounce } from "use-debounce";
import { useGetMangas } from "@/api/generated/catalog/catalog.ts";
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 { ThemeToggle } from "@/components/ThemeToggle.tsx";
import { Input } from "@/components/ui/input.tsx";
import { Skeleton } from "@/components/ui/skeleton.tsx";
import { useUIState } from "@/contexts/UIStateContext.tsx";
import { FilterSidebar } from "@/features/home/components/FilterSidebar.tsx";
import { MangaGrid } from "@/features/home/components/MangaGrid.tsx";
@ -35,6 +36,8 @@ const Home = () => {
setSortOption,
searchText,
setSearchText,
isSidebarOpen,
setIsSidebarOpen,
} = useUIState();
const [debouncedSearchText] = useDebounce(searchText, 500);
@ -54,6 +57,7 @@ const Home = () => {
userFavorites,
score: minRating,
});
const isFiltersDisabled = isFetching;
const startSearchRef = useRef(debouncedSearchText);
@ -69,18 +73,22 @@ const Home = () => {
return (
<div className="flex min-h-screen bg-background">
<FilterSidebar
selectedGenres={selectedGenres}
selectedStatus={selectedStatus}
minRating={minRating}
userFavorites={userFavorites}
showAdultContent={showAdultContent}
onGenresChange={setSelectedGenres}
onStatusChange={setSelectedStatus}
onRatingChange={setMinRating}
onUserFavoritesChange={setUserFavorites}
onShowAdultContentChange={setShowAdultContent}
/>
{isSidebarOpen && (
<FilterSidebar
selectedGenres={selectedGenres}
selectedStatus={selectedStatus}
minRating={minRating}
userFavorites={userFavorites}
showAdultContent={showAdultContent}
onGenresChange={setSelectedGenres}
onStatusChange={setSelectedStatus}
onRatingChange={setMinRating}
onUserFavoritesChange={setUserFavorites}
onShowAdultContentChange={setShowAdultContent}
onHide={() => setIsSidebarOpen(false)}
isDisabled={isFiltersDisabled}
/>
)}
<div className="flex-1 flex flex-col">
<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">
MangaMochi
</h1>
<div className="mt-1 flex items-center gap-2">
<p className="text-sm text-muted-foreground">
{mangasData?.data?.totalElements ?? 0} titles available
</p>
{isFetching && (
<Spinner className="size-3 text-muted-foreground" />
<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">
{mangasData?.data?.totalElements ?? 0} titles available
</p>
</>
)}
</div>
</div>
</div>
<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 />
<AuthHeader />
</div>
@ -119,7 +141,8 @@ const Home = () => {
onChange={(e) => {
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>
@ -144,6 +167,7 @@ const Home = () => {
<SortDropdown
currentSort={sortOption}
onSortChange={setSortOption}
isDisabled={isFiltersDisabled}
/>
</div>
)}