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;
|
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,
|
||||||
|
|||||||
@ -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"}`}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user