Compare commits

...

4 Commits

15 changed files with 3030 additions and 918 deletions

1561
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -16,8 +16,9 @@
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
@ -26,6 +27,7 @@
"axios": "^1.13.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"lucide-react": "^0.548.0",
"next-themes": "^0.4.6",
"orval": "^7.13.2",
@ -36,7 +38,7 @@
"react-hook-form": "^7.65.0",
"react-router": "^7.9.5",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.1.16",
"use-debounce": "^10.0.6",
"zod": "^4.1.12"
@ -44,7 +46,6 @@
"devDependencies": {
"@biomejs/biome": "2.4.9",
"@eslint/js": "^9.36.0",
"@types/node": "^24.9.2",
"@types/react": "^19.1.16",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.4",

View File

@ -1,5 +1,6 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import useWindowDimensions from "@/hooks/useWindowDimensions";
interface PaginationProps {
currentPage: number;
@ -7,11 +8,43 @@ interface PaginationProps {
onPageChange: (page: number) => void;
}
export const Pagination = ({
currentPage,
totalPages,
onPageChange,
}: PaginationProps) => {
export const Pagination = ({ currentPage, totalPages, onPageChange }: PaginationProps) => {
const { width } = useWindowDimensions();
const prevButton = (
<Button
variant="outline"
size="icon"
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
>
<ChevronLeft className="h-4 w-4" />
</Button>
);
const nextButton = (
<Button
variant="outline"
size="icon"
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
>
<ChevronRight className="h-4 w-4" />
</Button>
);
if (width < 768) {
return (
<div className="flex items-center justify-center gap-3">
{prevButton}
<span className="text-sm text-muted-foreground">
Page {currentPage} of {totalPages}
</span>
{nextButton}
</div>
);
}
const getPageNumbers = () => {
const pages: (number | string)[] = [];
const showEllipsis = totalPages > 7;
@ -20,45 +53,20 @@ export const Pagination = ({
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
// Always show first page
pages.push(1);
if (currentPage > 3) {
pages.push("...");
}
// Show pages around current page
for (
let i = Math.max(2, currentPage - 1);
i <= Math.min(totalPages - 1, currentPage + 1);
i++
) {
if (currentPage > 3) pages.push("...");
for (let i = Math.max(2, currentPage - 1); i <= Math.min(totalPages - 1, currentPage + 1); i++) {
pages.push(i);
}
if (currentPage < totalPages - 2) {
pages.push("...");
}
// Always show last page
if (totalPages > 1) {
pages.push(totalPages);
}
if (currentPage < totalPages - 2) pages.push("...");
if (totalPages > 1) pages.push(totalPages);
return pages;
};
return (
<div className="flex items-center justify-center gap-2">
<Button
variant="outline"
size="icon"
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
>
<ChevronLeft className="h-4 w-4" />
</Button>
{prevButton}
{getPageNumbers().map((page, index) => (
<div key={index}>
{page === "..." ? (
@ -74,15 +82,7 @@ export const Pagination = ({
)}
</div>
))}
<Button
variant="outline"
size="icon"
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
>
<ChevronRight className="h-4 w-4" />
</Button>
{nextButton}
</div>
);
};

View File

@ -0,0 +1,184 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<Command className="**:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,87 @@
import * as React from "react"
import { Popover as PopoverPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-1 text-sm", className)}
{...props}
/>
)
}
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
return (
<div
data-slot="popover-title"
className={cn("font-medium", className)}
{...props}
/>
)
}
function PopoverDescription({
className,
...props
}: React.ComponentProps<"p">) {
return (
<p
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
)
}
export {
Popover,
PopoverTrigger,
PopoverContent,
PopoverAnchor,
PopoverHeader,
PopoverTitle,
PopoverDescription,
}

119
src/components/ui/sheet.tsx Normal file
View File

@ -0,0 +1,119 @@
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "@/lib/utils";
function Sheet({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function SheetContent({
className,
children,
side = "left",
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left";
showCloseButton?: boolean;
}) {
const sideClasses = {
top: "inset-x-0 top-0 border-b data-[state=open]:slide-in-from-top data-[state=closed]:slide-out-to-top",
bottom: "inset-x-0 bottom-0 border-t data-[state=open]:slide-in-from-bottom data-[state=closed]:slide-out-to-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=open]:slide-in-from-left data-[state=closed]:slide-out-to-left sm:max-w-sm",
right: "inset-y-0 right-0 h-full w-3/4 border-l data-[state=open]:slide-in-from-right data-[state=closed]:slide-out-to-right sm:max-w-sm",
};
return (
<SheetPortal>
<SheetOverlay />
<DialogPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
sideClasses[side],
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="sheet-close"
className="ring-offset-background focus:ring-ring absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-6", className)}
{...props}
/>
);
}
function SheetTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
);
}
function SheetDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetHeader,
SheetOverlay,
SheetPortal,
SheetTitle,
SheetTrigger,
};

View File

@ -55,7 +55,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);
const [isSidebarOpen, setIsSidebarOpen] = useState(() => window.innerWidth >= 768);
useEffect(() => {
setShowAdultContent(isAuthenticated);

View File

@ -1,13 +1,13 @@
import { PanelLeftClose, Star, X } from "lucide-react";
import { PanelLeftClose, Star } from "lucide-react";
import { useGetGenres } from "@/api/generated/catalog/catalog.ts";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator.tsx";
import { Skeleton } from "@/components/ui/skeleton.tsx";
import { Switch } from "@/components/ui/switch.tsx";
import { useAuth } from "@/contexts/AuthContext.tsx";
import {MultiSelect} from "@/components/ui/multi-select.tsx";
interface FilterSidebarProps {
export interface FilterContentProps {
selectedGenres: number[];
selectedStatus: string[];
minRating: number;
@ -31,7 +31,7 @@ const RATINGS = [
{ label: "All Ratings", value: 0 },
];
export function FilterSidebar({
export function FilterContent({
selectedGenres,
selectedStatus,
minRating,
@ -44,26 +44,10 @@ export function FilterSidebar({
onShowAdultContentChange,
onHide,
isDisabled = false,
}: FilterSidebarProps) {
}: FilterContentProps) {
const { data: genresData, isPending: isPendingGenres } = useGetGenres();
const { isAuthenticated } = useAuth();
const toggleGenre = (genre: number) => {
if (selectedGenres.includes(genre)) {
onGenresChange(selectedGenres.filter((g) => g !== genre));
} else {
onGenresChange([...selectedGenres, genre]);
}
};
const toggleStatus = (status: string) => {
if (selectedStatus.includes(status)) {
onStatusChange(selectedStatus.filter((s) => s !== status));
} else {
onStatusChange([...selectedStatus, status]);
}
};
const clearAllFilters = () => {
onGenresChange([]);
onStatusChange([]);
@ -79,162 +63,137 @@ export function FilterSidebar({
userFavorites;
return (
<aside className="w-64 border-r border-border bg-sidebar">
<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">
<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
</Button>
)}
<div className="flex h-full flex-col">
{/* Header */}
<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">
Filters
</h2>
</div>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
onClick={clearAllFilters}
disabled={isDisabled}
className="h-8 px-2 text-xs"
>
Clear
</Button>
)}
</div>
{/* Filters */}
<div className="flex-1 overflow-y-auto px-6 py-6">
<div className="space-y-8">
{isAuthenticated && (
<>
<div>
<h3 className="mb-3 text-sm font-medium text-sidebar-foreground">
Content
</h3>
<div className="flex items-center justify-between rounded-md px-3 py-2">
<label className="text-sm text-sidebar-foreground">
Show Only Favorites
</label>
<Switch
checked={userFavorites}
onCheckedChange={onUserFavoritesChange}
disabled={isDisabled}
/>
</div>
<div className="flex items-center justify-between rounded-md px-3 py-2">
<label className="text-sm text-sidebar-foreground">
Show Adult Content
</label>
<Switch
checked={showAdultContent}
onCheckedChange={onShowAdultContentChange}
disabled={isDisabled}
/>
</div>
{/* Filters */}
<div className="flex-1 overflow-y-auto px-6 py-6">
<div className="space-y-8">
{isAuthenticated && (
<>
<div>
<h3 className="mb-3 text-sm font-medium text-sidebar-foreground">
Content
</h3>
<div className="flex items-center justify-between rounded-md px-3 py-2">
<label className="text-sm text-sidebar-foreground">
Show Only Favorites
</label>
<Switch
checked={userFavorites}
onCheckedChange={onUserFavoritesChange}
disabled={isDisabled}
/>
</div>
<div className="flex items-center justify-between rounded-md px-3 py-2">
<label className="text-sm text-sidebar-foreground">
Show Adult Content
</label>
<Switch
checked={showAdultContent}
onCheckedChange={onShowAdultContentChange}
disabled={isDisabled}
/>
</div>
<Separator className="bg-sidebar-border" />
</>
)}
{/* Genres */}
<div>
<h3 className="mb-3 text-sm font-medium text-sidebar-foreground">
Genres
</h3>
<div className="flex flex-wrap gap-2">
{isPendingGenres && <Skeleton className="h-[240px] w-full" />}
{!isPendingGenres &&
genresData?.data?.map((genre) => {
const isSelected = selectedGenres.includes(genre.id);
return (
<Badge
key={genre.id}
variant={isSelected ? "default" : "outline"}
className={`cursor-pointer transition-colors ${
isSelected
? "bg-sidebar-primary text-sidebar-primary-foreground hover:bg-sidebar-primary/90"
: "border-sidebar-border text-sidebar-foreground hover:bg-sidebar-accent"
} ${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" />}
</Badge>
);
})}
</div>
<Separator className="bg-sidebar-border" />
</>
)}
{/* Genres */}
<div>
<h3 className="mb-3 text-sm font-medium text-sidebar-foreground">
Genres
</h3>
<div className="flex flex-wrap gap-2">
{isPendingGenres && <Skeleton className="h-60 w-full" />}
{!isPendingGenres && (
<MultiSelect options={genresData?.data?.map((genre) => ({value: String(genre.id), label: genre.name})) ?? []} onValueChange={ (selectedIds) => onGenresChange(selectedIds.map((selectedId) => Number(selectedId)))} hideSelectAll />
)}
</div>
</div>
<Separator className="bg-sidebar-border" />
<Separator className="bg-sidebar-border" />
{/* Status */}
<div>
<h3 className="mb-3 text-sm font-medium text-sidebar-foreground">
Status
</h3>
<div className="flex flex-wrap gap-2">
{STATUSES.map((status) => {
const isSelected = selectedStatus.includes(status);
return (
<Badge
key={status}
variant={isSelected ? "default" : "outline"}
className={`cursor-pointer transition-colors ${
isSelected
? "bg-sidebar-primary text-sidebar-primary-foreground hover:bg-sidebar-primary/90"
: "border-sidebar-border text-sidebar-foreground hover:bg-sidebar-accent"
} ${isDisabled ? "opacity-50 pointer-events-none grayscale-[0.5]" : ""}`}
onClick={() => !isDisabled && toggleStatus(status)}
>
{status}
{isSelected && <X className="ml-1 h-3 w-3" />}
</Badge>
);
})}
</div>
{/* Status */}
<div>
<h3 className="mb-3 text-sm font-medium text-sidebar-foreground">
Status
</h3>
<div className="flex flex-wrap gap-2">
<MultiSelect options={STATUSES.map((status) => ({value: status, label: status}))} onValueChange={(selectedStatuses) => onStatusChange(selectedStatuses)} hideSelectAll />
</div>
</div>
<Separator className="bg-sidebar-border" />
<Separator className="bg-sidebar-border" />
{/* Rating */}
<div>
<h3 className="mb-3 text-sm font-medium text-sidebar-foreground">
Minimum Rating
</h3>
<div className="space-y-2">
{RATINGS.map((rating) => {
const isSelected = minRating === rating.value;
return (
<button
key={rating.value}
onClick={() => onRatingChange(rating.value)}
className={`flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors ${
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"}`}
/>
<span>{rating.label}</span>
</button>
);
})}
</div>
{/* Rating */}
<div>
<h3 className="mb-3 text-sm font-medium text-sidebar-foreground">
Minimum Rating
</h3>
<div className="space-y-2">
{RATINGS.map((rating) => {
const isSelected = minRating === rating.value;
return (
<button
key={rating.value}
onClick={() => onRatingChange(rating.value)}
className={`flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors ${
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"}`}
/>
<span>{rating.label}</span>
</button>
);
})}
</div>
</div>
</div>
</div>
</div>
);
}
export function FilterSidebar(props: FilterContentProps) {
return (
<aside className="w-64 border-r border-border bg-sidebar">
<div className="sticky top-0 h-screen">
<FilterContent {...props} />
</div>
</aside>
);
}

View File

@ -8,7 +8,7 @@ interface MangaGridProps {
export const MangaGrid = ({ manga, queryKey }: MangaGridProps) => {
return (
<div className="grid grid-cols-2 gap-6 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 auto-rows-fr">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 auto-rows-fr">
{manga.map((item) => (
<MangaCard key={item.id} manga={item} queryKey={queryKey} />
))}

View File

@ -26,20 +26,25 @@ export const MangaChapter = ({
const queryClient = useQueryClient();
const { mutate: mutateDownloadChapterArchive } = useDownloadContentArchive({
mutation: {
onSuccess: (data, { mangaContentId }) => {
const url = window.URL.createObjectURL(data);
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", `${mangaContentId}.cbz`);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
const { mutate: mutateDownloadChapterArchive, isPending: isDownloading } =
useDownloadContentArchive({
mutation: {
onMutate: ({ mangaContentId }) => setDownloadingId(mangaContentId),
onSuccess: (data, { mangaContentId }) => {
const url = window.URL.createObjectURL(data);
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", `${mangaContentId}.cbz`);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
},
onSettled: () => setDownloadingId(null),
},
},
});
});
const [downloadingId, setDownloadingId] = useState<number | null>(null);
const { mutate, isPending: isPendingFetchChapter } =
useFetchContentProviderContent({
@ -78,13 +83,12 @@ export const MangaChapter = ({
return (
<div
key={chapter.id}
className="flex items-center justify-between rounded-lg border border-border bg-background p-3"
className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between rounded-lg border border-border bg-background p-4"
>
<div className="flex items-center gap-3">
<div className="flex items-center gap-4">
<div
className={`flex h-8 w-8 items-center justify-center rounded-full ${
chapter.isRead ? "bg-primary/20" : "bg-muted"
}`}
className={`flex h-8 w-8 items-center justify-center rounded-full ${chapter.isRead ? "bg-primary/20" : "bg-muted"
}`}
>
{chapter.isRead ? (
<Check className="h-4 w-4 text-primary" />
@ -94,35 +98,57 @@ export const MangaChapter = ({
</span>
)}
</div>
<div>
<p className="text-sm font-medium text-foreground flex items-center gap-2">
{chapter.language?.code && (
<ReactCountryFlag
countryCode={chapter.language.code.split("-")[1]}
svg
style={{
width: "1.2em",
height: "1.2em",
}}
title={chapter.language.name}
/>
)}
{chapter.title}
</p>
{chapter.downloaded && (
<p className="text-xs text-muted-foreground">
In database
<div className="min-w-0 flex-1 flex items-center justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground flex items-center gap-2 flex-wrap min-w-0">
{chapter.language?.code && (
<ReactCountryFlag
countryCode={chapter.language.code.split("-")[1]}
svg
style={{
width: "1.2em",
height: "1.2em",
}}
title={chapter.language.name}
/>
)}
<span className="truncate flex-1">{chapter.title}</span>
</p>
{chapter.downloaded && (
<p className="text-xs text-muted-foreground">
In database
</p>
)}
</div>
{chapter.downloaded && (
<Button
size="sm"
variant="outline"
onClick={() =>
mutateDownloadChapterArchive({
mangaContentId: chapter.id,
params: { contentArchiveFileType: "CBZ" },
})
}
className="sm:hidden h-8 w-8 p-0"
disabled={isDownloading && downloadingId === chapter.id}
>
{isDownloading && downloadingId === chapter.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
</Button>
)}
</div>
</div>
{chapter.downloaded ? (
<div className="flex gap-2">
<div className="flex flex-col gap-3 sm:flex-row sm:gap-2 w-full sm:w-auto">
<Button
size="sm"
variant="outline"
onClick={() => handleReadChapter(chapter.id)}
className="gap-2"
className="gap-2 w-full sm:w-auto"
>
<Eye className="h-4 w-4" />
Read
@ -136,9 +162,14 @@ export const MangaChapter = ({
params: { contentArchiveFileType: "CBZ" },
})
}
className="gap-2"
className="hidden sm:flex gap-2 w-full sm:w-auto"
disabled={isDownloading && downloadingId === chapter.id}
>
<Download className="h-4 w-4" />
{isDownloading && downloadingId === chapter.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
Download
</Button>
</div>
@ -148,7 +179,7 @@ export const MangaChapter = ({
variant="outline"
onClick={() => fetchChapter(chapter.id)}
disabled={isPendingFetchChapter}
className="gap-2 cursor-pointer"
className="gap-2 cursor-pointer w-full sm:w-auto"
>
<Database className="h-4 w-4" />
{isPendingFetchChapter && fetchingId === chapter.id

View File

@ -15,6 +15,10 @@ export const useDynamicPageSize = (rows = 4) => {
// md: 3 columns
return 3 * rows;
}
// default: 2 columns
return 2 * rows;
if (width >= 640) {
// sm: 2 columns
return 2 * rows;
}
// default: 1 column
return 1 * rows;
};

View File

@ -3,7 +3,6 @@ import { useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router";
import { useGetMangaContentImages } from "@/api/generated/content/content.ts";
import { useMarkContentAsRead } from "@/api/generated/user-interaction/user-interaction.ts";
import { ThemeToggle } from "@/components/ThemeToggle.tsx";
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -314,76 +313,85 @@ const Chapter = () => {
</DialogContent>
</Dialog>
{/* HEADER */}
<header className="sticky top-0 z-50 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="px-4 py-4 sm:px-8">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="sm"
onClick={() => navigate(`/manga/${mangaId}`)}
className="gap-2"
>
<ArrowLeft className="h-4 w-4" />
<span className="hidden sm:inline">Back</span>
</Button>
<header className="sticky top-0 z-50 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 text-xs sm:text-sm">
<div className="px-2 py-2 sm:px-4 sm:py-4">
<div className="flex items-center justify-between gap-2 sm:gap-4">
<div className="flex items-center gap-1 sm:gap-4 flex-1 min-w-0">
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => navigate(`/manga/${mangaId}`)}
className="gap-2 px-2 sm:px-3"
>
<ArrowLeft className="h-4 w-4" />
<span className="hidden md:inline">Back</span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => navigate("/")}
className="gap-2"
>
<Home className="h-4 w-4" />
<span className="hidden sm:inline">Home</span>
</Button>
<div className="hidden sm:block">
<h1 className="text-sm font-semibold text-foreground">
{data.data.mangaTitle}
</h1>
<p className="text-xs text-muted-foreground text-center">
{chapterNumber}
</p>
<Button
variant="ghost"
size="sm"
onClick={() => navigate("/")}
className="gap-2 px-1.5 sm:px-3"
>
<Home className="h-4 w-4" />
<span className="hidden md:inline">Home</span>
</Button>
</div>
{previousContentId && (
<Button
variant="ghost"
size="sm"
onClick={goToPreviousChapter}
className="gap-1"
>
<ChevronLeft className="h-4 w-4" />
<span className="hidden sm:inline">Prev</span>
</Button>
)}
{nextContentId && (
<Button
variant="ghost"
size="sm"
onClick={goToNextChapter}
className="gap-1"
>
<span className="hidden sm:inline">Next</span>
<ChevronRight className="h-4 w-4" />
</Button>
)}
<div className="flex items-center gap-1 shrink-0">
{previousContentId && (
<Button
variant="ghost"
size="sm"
onClick={goToPreviousChapter}
className="gap-1 px-1.5"
>
<ChevronLeft className="h-4 w-4" />
<span className="hidden sm:inline">Prev</span>
</Button>
)}
{nextContentId && (
<Button
variant="ghost"
size="sm"
onClick={goToNextChapter}
className="gap-1 px-1.5"
>
<span className="hidden sm:inline">Next</span>
<ChevronRight className="h-4 w-4" />
</Button>
)}
</div>
{/* Title Info - now visible on all screens in the middle */}
<div className="flex-1 min-w-0 px-1 text-center">
<h1 className="text-[10px] sm:text-xs font-semibold text-foreground truncate">
{data.data.mangaTitle}
</h1>
<p className="text-[8px] sm:text-[10px] text-muted-foreground truncate">
Chapter {chapterNumber}
</p>
</div>
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground">
Page {currentPage} / {images.length}
<div className="flex items-center gap-2 sm:gap-4 shrink-0">
<span className="text-[10px] sm:text-sm text-muted-foreground whitespace-nowrap">
{currentPage} / {images.length}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setInfiniteScroll((v) => !v)}
className="text-xs"
className="h-8 px-2 sm:px-3 text-[10px] sm:text-xs"
>
{infiniteScroll ? "Single Page Mode" : "Scroll Mode"}
{infiniteScroll ? (
<span className="">Single</span>
) : (
<span className="">Scroll</span>
)}
<span className="hidden sm:inline"> Mode</span>
</Button>
<ThemeToggle />
</div>
</div>
</div>
@ -391,18 +399,10 @@ const Chapter = () => {
{/* MAIN */}
<main
className={`mx-auto max-w-4xl ${
infiniteScroll ? "px-0 py-0" : "px-4 py-8"
}`}
className={`mx-auto max-w-4xl ${infiniteScroll ? "px-0 py-0" : "px-4 py-8"
}`}
>
<div className="mb-4 sm:hidden">
<h1 className="text-lg font-semibold text-foreground">
{data.data.mangaTitle}
</h1>
<p className="text-sm text-muted-foreground">
{chapterNumber}
</p>
</div>
{/* Removed mobile header from main as it's now in the sticky header */}
{/* ------------------------------------------------------------------ */}
{/* MODE 1 --- INFINITE SCROLL MODE */}

View File

@ -1,4 +1,4 @@
import { BookOpen, PanelLeftOpen, Search } from "lucide-react";
import { BookOpen, PanelLeftOpen, Search, SlidersHorizontal } from "lucide-react";
import { useEffect, useRef } from "react";
import { useDebounce } from "use-debounce";
import { useGetMangas } from "@/api/generated/catalog/catalog.ts";
@ -8,12 +8,14 @@ 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 { Sheet, SheetContent } from "@/components/ui/sheet.tsx";
import { useUIState } from "@/contexts/UIStateContext.tsx";
import { FilterSidebar } from "@/features/home/components/FilterSidebar.tsx";
import { FilterContent, FilterSidebar } from "@/features/home/components/FilterSidebar.tsx";
import { MangaGrid } from "@/features/home/components/MangaGrid.tsx";
import { MangaLoadingState } from "@/components/MangaLoadingState.tsx";
import { SortDropdown } from "@/features/home/components/SortDropdown.tsx";
import { useDynamicPageSize } from "@/hooks/useDynamicPageSize.ts";
import useWindowDimensions from "@/hooks/useWindowDimensions.ts";
const ROWS_PER_PAGE = 4;
@ -40,7 +42,11 @@ const Home = () => {
setIsSidebarOpen,
} = useUIState();
const { width } = useWindowDimensions();
const isMobile = width < 768;
const [debouncedSearchText] = useDebounce(searchText, 500);
const {
data: mangasData,
@ -63,7 +69,6 @@ const Home = () => {
const startSearchRef = useRef(debouncedSearchText);
useEffect(() => {
// Resets current page only when search text actually changes
if (startSearchRef.current !== debouncedSearchText) {
setCurrentPage(1);
startSearchRef.current = debouncedSearchText;
@ -72,28 +77,38 @@ const Home = () => {
const totalPages = mangasData?.data?.totalPages;
const filterSidebarProps = {
selectedGenres,
selectedStatus,
minRating,
userFavorites,
showAdultContent,
onGenresChange: setSelectedGenres,
onStatusChange: setSelectedStatus,
onRatingChange: setMinRating,
onUserFavoritesChange: setUserFavorites,
onShowAdultContentChange: setShowAdultContent,
onHide: () => setIsSidebarOpen(false),
isDisabled: isFiltersDisabled,
};
return (
<div className="flex min-h-screen bg-background">
{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}
/>
{/* Mobile Sheet drawer */}
{isMobile && (
<Sheet open={isSidebarOpen} onOpenChange={setIsSidebarOpen}>
<SheetContent side="left" showCloseButton={false} className="p-0 w-72 bg-sidebar">
<FilterContent {...filterSidebarProps} />
</SheetContent>
</Sheet>
)}
<div className="flex-1 flex flex-col">
<header className="border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="px-8 py-6">
{/* Tablet + Desktop sidebar */}
{!isMobile && isSidebarOpen && <FilterSidebar {...filterSidebarProps} />}
<div className="flex-1 flex flex-col min-w-0">
<header className="border-b border-border bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60">
<div className="px-4 md:px-8 py-6">
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div className="flex items-start gap-3">
@ -102,21 +117,19 @@ const Home = () => {
<h1 className="text-3xl font-bold tracking-tight text-foreground">
MangaMochi
</h1>
<div className="mt-1 flex items-center gap-2 min-h-[1.25rem]">
<div className="mt-1 flex items-center gap-2 min-h-5">
{isFetching ? (
<Skeleton className="h-4 w-24" />
) : (
<>
<p className="text-sm text-muted-foreground">
{mangasData?.data?.totalElements ?? 0} titles available
</p>
</>
<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 && (
{!isMobile && !isSidebarOpen && (
<Button
variant="outline"
size="sm"
@ -139,9 +152,7 @@ const Home = () => {
type="search"
placeholder="Search manga or author..."
value={searchText}
onChange={(e) => {
setSearchText(e.target.value);
}}
onChange={(e) => setSearchText(e.target.value)}
disabled={isFiltersDisabled}
className="pl-10 bg-card border-border disabled:opacity-50"
/>
@ -150,7 +161,7 @@ const Home = () => {
</div>
</header>
<main className="flex-1 px-8 py-8 flex flex-col">
<main className="flex-1 px-4 md:px-8 py-8 flex flex-col">
{isPending ? (
<MangaLoadingState />
) : mangasData?.data?.content && mangasData.data.content.length > 0 ? (
@ -165,11 +176,22 @@ const Home = () => {
)}{" "}
of {mangasData.data.totalElements}
</p>
<SortDropdown
currentSort={sortOption}
onSortChange={setSortOption}
isDisabled={isFiltersDisabled}
/>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setIsSidebarOpen(true)}
className="md:hidden gap-2"
>
<SlidersHorizontal className="h-4 w-4" />
Filters
</Button>
<SortDropdown
currentSort={sortOption}
onSortChange={setSortOption}
isDisabled={isFiltersDisabled}
/>
</div>
</div>
)}
<MangaGrid

View File

@ -8,6 +8,7 @@ import {
ChevronDown,
Database,
Heart,
MoreVertical,
Star,
} from "lucide-react";
import { useCallback } from "react";
@ -30,6 +31,12 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible.tsx";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu.tsx";
import { useAuth } from "@/contexts/AuthContext.tsx";
import { useUIState } from "@/contexts/UIStateContext.tsx";
import { MangaChapter } from "@/features/manga/MangaChapter.tsx";
@ -155,9 +162,9 @@ const Manga = () => {
{/* Content Shell */}
<main className="px-8 py-8">
<div className="mx-auto max-w-7xl">
<div className="grid gap-8 lg:grid-cols-[300px_1fr]">
<div className="grid gap-8 md:grid-cols-[300px_1fr]">
{/* Cover Skeleton */}
<Skeleton className="aspect-2/3 w-full rounded-lg lg:sticky lg:top-8" />
<Skeleton className="aspect-2/3 w-full rounded-lg md:sticky md:top-8" />
{/* Details Skeleton */}
<div className="space-y-6">
@ -252,10 +259,10 @@ const Manga = () => {
<main className="px-8 py-8">
<div className="mx-auto max-w-7xl">
{/* Manga Info Section */}
<div className="grid gap-8 lg:grid-cols-[300px_1fr]">
<div className="grid gap-8 md:grid-cols-[300px_1fr]">
{/* Cover */}
<div className="lg:sticky lg:top-24">
<div className="relative aspect-2/3 overflow-hidden rounded-lg border border-border bg-muted">
<div className="md:sticky md:top-24">
<div className="relative aspect-2/3 overflow-hidden rounded-lg border border-border bg-muted w-full">
<img
src={
(mangaData.data?.coverImageKey &&
@ -273,14 +280,14 @@ const Manga = () => {
{/* Details */}
<div className="space-y-6">
<div>
<div className="mb-3 flex items-center justify-between gap-4">
<h1 className="text-balance text-4xl font-bold tracking-tight text-foreground">
<div className="mb-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<h1 className="text-balance text-3xl sm:text-4xl font-bold tracking-tight text-foreground">
{mangaData.data?.title}
</h1>
<div className="flex gap-4 items-center">
<div className="flex gap-2 items-center flex-wrap">
<Badge
variant="secondary"
className="border border-border bg-card text-foreground max-h-6"
className="border border-border bg-card text-foreground whitespace-nowrap"
>
{mangaData.data?.status}
</Badge>
@ -294,7 +301,7 @@ const Manga = () => {
)}
{isAuthenticated && (
<>
<div className="flex gap-2 ml-auto sm:ml-0">
<Button
size="icon"
variant="outline"
@ -338,7 +345,7 @@ const Manga = () => {
/>
)}
</Button>
</>
</div>
)}
</div>
</div>
@ -395,6 +402,18 @@ const Manga = () => {
dangerouslySetInnerHTML={{ __html: mangaData.data?.synopsis ?? "" }}
/>
<div className="flex flex-wrap gap-2">
{mangaData.data?.genres.map((genre) => (
<Badge
key={genre}
variant="outline"
className="border-border text-foreground"
>
{genre}
</Badge>
))}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="flex items-center gap-3 rounded-lg border border-border bg-card p-4">
<Star className="h-5 w-5 fill-primary text-primary" />
@ -446,18 +465,6 @@ const Manga = () => {
</div>
</div>
</div>
<div className="flex flex-wrap gap-2">
{mangaData.data?.genres.map((genre) => (
<Badge
key={genre}
variant="outline"
className="border-border text-foreground"
>
{genre}
</Badge>
))}
</div>
</div>
</div>
@ -489,83 +496,85 @@ const Manga = () => {
asChild
className={`flex-1 ${provider.chaptersAvailable > 0 ? "cursor-pointer" : "cursor-default"}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Database className="h-5 w-5 text-primary" />
<div className="text-left">
<p className="font-semibold text-foreground">
{provider.providerName}
</p>
<p className="text-sm text-muted-foreground">
{provider.chaptersDownloaded} downloaded {" "}
{provider.chaptersAvailable} available
</p>
</div>
<div className="flex items-center gap-4">
<Database className="h-5 w-5 text-primary" />
<div className="text-left">
<p className="font-semibold text-foreground">
{provider.providerName}
</p>
<p className="text-sm text-muted-foreground whitespace-nowrap">
{provider.chaptersDownloaded} downloaded {" "}
{provider.chaptersAvailable} available
</p>
</div>
</div>
</CollapsibleTrigger>
{provider.supportsChapterFetch && provider.active && (
<div className="flex gap-4 px-4">
{provider.chaptersAvailable > 0 && (
<Button
size="sm"
variant="outline"
disabled={isPending || fetchAllPending}
onClick={(e) => {
e.stopPropagation();
fetchAllMutate({
mangaContentProviderId: provider.id ?? -1,
});
}}
className="gap-2"
>
{fetchAllPending ? (
<Spinner />
) : (
<Database className="h-4 w-4" />
)}
Fetch all from Provider
</Button>
)}
<Button
size="sm"
variant="outline"
disabled={isPending || fetchAllPending}
onClick={(e) => {
e.stopPropagation();
mutate({
mangaContentProviderId: provider.id ?? -1,
});
}}
className="gap-2"
<div className="flex items-center gap-2">
<CollapsibleTrigger asChild>
<div
className={
provider.chaptersAvailable > 0
? "cursor-pointer"
: "invisible"
}
>
{isPending ? (
<Spinner />
) : (
<Database className="h-4 w-4" />
)}
Fetch list from Provider
</Button>
</div>
)}
<ChevronDown
className={`h-5 w-5 text-muted-foreground transition-transform ${expandedProviderIds.includes(provider.id ?? -1)
? "rotate-180"
: ""
}`}
/>
</div>
</CollapsibleTrigger>
<CollapsibleTrigger asChild>
<div
className={
provider.chaptersAvailable > 0
? "cursor-pointer"
: "invisible"
}
>
<ChevronDown
className={`h-5 w-5 text-muted-foreground transition-transform ${expandedProviderIds.includes(provider.id ?? -1)
? "rotate-180"
: ""
}`}
/>
</div>
</CollapsibleTrigger>
{provider.supportsChapterFetch && provider.active && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
disabled={isPending || fetchAllPending}
onClick={(e) => e.stopPropagation()}
>
{isPending || fetchAllPending ? (
<Spinner />
) : (
<MoreVertical className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{provider.chaptersAvailable > 0 && (
<DropdownMenuItem
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
fetchAllMutate({
mangaContentProviderId: provider.id ?? -1,
});
}}
className="gap-2"
>
<Database className="h-4 w-4" />
Fetch all from Provider
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
mutate({
mangaContentProviderId: provider.id ?? -1,
});
}}
className="gap-2"
>
<Database className="h-4 w-4" />
Fetch list from Provider
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</CardContent>
<CollapsibleContent>
<MangaChapter