feat: add collapsible filter sidebar with loading states and disabled inputs during fetch
This commit is contained in:
parent
0ef91b6f6f
commit
451b45f2d3
@ -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,
|
||||
|
||||
@ -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"}`}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user