Compare commits
4 Commits
fe7d5957e3
...
0de1ebb593
| Author | SHA1 | Date | |
|---|---|---|---|
| 0de1ebb593 | |||
| 866f01f281 | |||
| 80144a4092 | |||
| 5bce119f1a |
1561
package-lock.json
generated
1561
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
||||
@ -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,49 +8,10 @@ interface PaginationProps {
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export const Pagination = ({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
}: PaginationProps) => {
|
||||
const getPageNumbers = () => {
|
||||
const pages: (number | string)[] = [];
|
||||
const showEllipsis = totalPages > 7;
|
||||
export const Pagination = ({ currentPage, totalPages, onPageChange }: PaginationProps) => {
|
||||
const { width } = useWindowDimensions();
|
||||
|
||||
if (!showEllipsis) {
|
||||
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++
|
||||
) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
if (currentPage < totalPages - 2) {
|
||||
pages.push("...");
|
||||
}
|
||||
|
||||
// Always show last page
|
||||
if (totalPages > 1) {
|
||||
pages.push(totalPages);
|
||||
}
|
||||
|
||||
return pages;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
const prevButton = (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
@ -58,7 +20,53 @@ export const Pagination = ({
|
||||
>
|
||||
<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;
|
||||
|
||||
if (!showEllipsis) {
|
||||
return Array.from({ length: totalPages }, (_, i) => i + 1);
|
||||
}
|
||||
|
||||
pages.push(1);
|
||||
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("...");
|
||||
if (totalPages > 1) pages.push(totalPages);
|
||||
|
||||
return pages;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
||||
184
src/components/ui/command.tsx
Normal file
184
src/components/ui/command.tsx
Normal 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,
|
||||
}
|
||||
1059
src/components/ui/multi-select.tsx
Normal file
1059
src/components/ui/multi-select.tsx
Normal file
File diff suppressed because it is too large
Load Diff
87
src/components/ui/popover.tsx
Normal file
87
src/components/ui/popover.tsx
Normal 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
119
src/components/ui/sheet.tsx
Normal 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,
|
||||
};
|
||||
@ -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);
|
||||
|
||||
@ -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,8 +63,7 @@ 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">
|
||||
<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">
|
||||
@ -150,26 +133,10 @@ export function FilterSidebar({
|
||||
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>
|
||||
);
|
||||
})}
|
||||
{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>
|
||||
|
||||
@ -181,24 +148,7 @@ export function FilterSidebar({
|
||||
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>
|
||||
);
|
||||
})}
|
||||
<MultiSelect options={STATUSES.map((status) => ({value: status, label: status}))} onValueChange={(selectedStatuses) => onStatusChange(selectedStatuses)} hideSelectAll />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -235,6 +185,15 @@ export function FilterSidebar({
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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} />
|
||||
))}
|
||||
|
||||
@ -26,8 +26,10 @@ export const MangaChapter = ({
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { mutate: mutateDownloadChapterArchive } = useDownloadContentArchive({
|
||||
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");
|
||||
@ -38,9 +40,12 @@ export const MangaChapter = ({
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
},
|
||||
onSettled: () => setDownloadingId(null),
|
||||
},
|
||||
});
|
||||
|
||||
const [downloadingId, setDownloadingId] = useState<number | null>(null);
|
||||
|
||||
const { mutate, isPending: isPendingFetchChapter } =
|
||||
useFetchContentProviderContent({
|
||||
mutation: {
|
||||
@ -78,12 +83,11 @@ 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 ? (
|
||||
@ -94,8 +98,9 @@ export const MangaChapter = ({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground flex items-center gap-2">
|
||||
<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]}
|
||||
@ -107,7 +112,7 @@ export const MangaChapter = ({
|
||||
title={chapter.language.name}
|
||||
/>
|
||||
)}
|
||||
{chapter.title}
|
||||
<span className="truncate flex-1">{chapter.title}</span>
|
||||
</p>
|
||||
{chapter.downloaded && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@ -115,14 +120,35 @@ export const MangaChapter = ({
|
||||
</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}
|
||||
>
|
||||
{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
|
||||
|
||||
@ -15,6 +15,10 @@ export const useDynamicPageSize = (rows = 4) => {
|
||||
// md: 3 columns
|
||||
return 3 * rows;
|
||||
}
|
||||
// default: 2 columns
|
||||
if (width >= 640) {
|
||||
// sm: 2 columns
|
||||
return 2 * rows;
|
||||
}
|
||||
// default: 1 column
|
||||
return 1 * rows;
|
||||
};
|
||||
|
||||
@ -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,45 +313,39 @@ 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">
|
||||
<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"
|
||||
className="gap-2 px-2 sm:px-3"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Back</span>
|
||||
<span className="hidden md:inline">Back</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate("/")}
|
||||
className="gap-2"
|
||||
className="gap-2 px-1.5 sm:px-3"
|
||||
>
|
||||
<Home className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Home</span>
|
||||
<span className="hidden md: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>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{previousContentId && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={goToPreviousChapter}
|
||||
className="gap-1"
|
||||
className="gap-1 px-1.5"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Prev</span>
|
||||
@ -363,7 +356,7 @@ const Chapter = () => {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={goToNextChapter}
|
||||
className="gap-1"
|
||||
className="gap-1 px-1.5"
|
||||
>
|
||||
<span className="hidden sm:inline">Next</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
@ -371,19 +364,34 @@ const Chapter = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Page {currentPage} / {images.length}
|
||||
{/* 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-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 */}
|
||||
|
||||
@ -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,8 +42,12 @@ const Home = () => {
|
||||
setIsSidebarOpen,
|
||||
} = useUIState();
|
||||
|
||||
const { width } = useWindowDimensions();
|
||||
const isMobile = width < 768;
|
||||
|
||||
const [debouncedSearchText] = useDebounce(searchText, 500);
|
||||
|
||||
|
||||
const {
|
||||
data: mangasData,
|
||||
queryKey: mangasQueryKey,
|
||||
@ -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>
|
||||
</>
|
||||
)}
|
||||
</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,12 +176,23 @@ const Home = () => {
|
||||
)}{" "}
|
||||
of {mangasData.data.totalElements}
|
||||
</p>
|
||||
<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
|
||||
manga={mangasData?.data?.content}
|
||||
|
||||
@ -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,67 +496,21 @@ 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">
|
||||
<p className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{provider.chaptersDownloaded} downloaded •{" "}
|
||||
{provider.chaptersAvailable} available
|
||||
</p>
|
||||
</div>
|
||||
</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"
|
||||
>
|
||||
{isPending ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<Database className="h-4 w-4" />
|
||||
)}
|
||||
Fetch list from Provider
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<CollapsibleTrigger asChild>
|
||||
<div
|
||||
className={
|
||||
@ -566,6 +527,54 @@ const Manga = () => {
|
||||
/>
|
||||
</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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user