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-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@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-slot": "^1.2.3",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
@ -26,6 +27,7 @@
|
|||||||
"axios": "^1.13.1",
|
"axios": "^1.13.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"lucide-react": "^0.548.0",
|
"lucide-react": "^0.548.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"orval": "^7.13.2",
|
"orval": "^7.13.2",
|
||||||
@ -36,7 +38,7 @@
|
|||||||
"react-hook-form": "^7.65.0",
|
"react-hook-form": "^7.65.0",
|
||||||
"react-router": "^7.9.5",
|
"react-router": "^7.9.5",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.1.16",
|
"tailwindcss": "^4.1.16",
|
||||||
"use-debounce": "^10.0.6",
|
"use-debounce": "^10.0.6",
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
@ -44,7 +46,6 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.4.9",
|
"@biomejs/biome": "2.4.9",
|
||||||
"@eslint/js": "^9.36.0",
|
"@eslint/js": "^9.36.0",
|
||||||
"@types/node": "^24.9.2",
|
|
||||||
"@types/react": "^19.1.16",
|
"@types/react": "^19.1.16",
|
||||||
"@types/react-dom": "^19.1.9",
|
"@types/react-dom": "^19.1.9",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import useWindowDimensions from "@/hooks/useWindowDimensions";
|
||||||
|
|
||||||
interface PaginationProps {
|
interface PaginationProps {
|
||||||
currentPage: number;
|
currentPage: number;
|
||||||
@ -7,11 +8,43 @@ interface PaginationProps {
|
|||||||
onPageChange: (page: number) => void;
|
onPageChange: (page: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Pagination = ({
|
export const Pagination = ({ currentPage, totalPages, onPageChange }: PaginationProps) => {
|
||||||
currentPage,
|
const { width } = useWindowDimensions();
|
||||||
totalPages,
|
|
||||||
onPageChange,
|
const prevButton = (
|
||||||
}: PaginationProps) => {
|
<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 getPageNumbers = () => {
|
||||||
const pages: (number | string)[] = [];
|
const pages: (number | string)[] = [];
|
||||||
const showEllipsis = totalPages > 7;
|
const showEllipsis = totalPages > 7;
|
||||||
@ -20,45 +53,20 @@ export const Pagination = ({
|
|||||||
return Array.from({ length: totalPages }, (_, i) => i + 1);
|
return Array.from({ length: totalPages }, (_, i) => i + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always show first page
|
|
||||||
pages.push(1);
|
pages.push(1);
|
||||||
|
if (currentPage > 3) pages.push("...");
|
||||||
if (currentPage > 3) {
|
for (let i = Math.max(2, currentPage - 1); i <= Math.min(totalPages - 1, currentPage + 1); i++) {
|
||||||
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);
|
pages.push(i);
|
||||||
}
|
}
|
||||||
|
if (currentPage < totalPages - 2) pages.push("...");
|
||||||
if (currentPage < totalPages - 2) {
|
if (totalPages > 1) pages.push(totalPages);
|
||||||
pages.push("...");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always show last page
|
|
||||||
if (totalPages > 1) {
|
|
||||||
pages.push(totalPages);
|
|
||||||
}
|
|
||||||
|
|
||||||
return pages;
|
return pages;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center gap-2">
|
<div className="flex items-center justify-center gap-2">
|
||||||
<Button
|
{prevButton}
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => onPageChange(currentPage - 1)}
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{getPageNumbers().map((page, index) => (
|
{getPageNumbers().map((page, index) => (
|
||||||
<div key={index}>
|
<div key={index}>
|
||||||
{page === "..." ? (
|
{page === "..." ? (
|
||||||
@ -74,15 +82,7 @@ export const Pagination = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{nextButton}
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => onPageChange(currentPage + 1)}
|
|
||||||
disabled={currentPage === totalPages}
|
|
||||||
>
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</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 [showAdultContent, setShowAdultContent] = useState(false);
|
||||||
const [sortOption, setSortOption] = useState<SortOption>("title-asc");
|
const [sortOption, setSortOption] = useState<SortOption>("title-asc");
|
||||||
const [searchText, setSearchText] = useState("");
|
const [searchText, setSearchText] = useState("");
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
const [isSidebarOpen, setIsSidebarOpen] = useState(() => window.innerWidth >= 768);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setShowAdultContent(isAuthenticated);
|
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 { useGetGenres } from "@/api/generated/catalog/catalog.ts";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator.tsx";
|
import { Separator } from "@/components/ui/separator.tsx";
|
||||||
import { Skeleton } from "@/components/ui/skeleton.tsx";
|
import { Skeleton } from "@/components/ui/skeleton.tsx";
|
||||||
import { Switch } from "@/components/ui/switch.tsx";
|
import { Switch } from "@/components/ui/switch.tsx";
|
||||||
import { useAuth } from "@/contexts/AuthContext.tsx";
|
import { useAuth } from "@/contexts/AuthContext.tsx";
|
||||||
|
import {MultiSelect} from "@/components/ui/multi-select.tsx";
|
||||||
|
|
||||||
interface FilterSidebarProps {
|
export interface FilterContentProps {
|
||||||
selectedGenres: number[];
|
selectedGenres: number[];
|
||||||
selectedStatus: string[];
|
selectedStatus: string[];
|
||||||
minRating: number;
|
minRating: number;
|
||||||
@ -31,7 +31,7 @@ const RATINGS = [
|
|||||||
{ label: "All Ratings", value: 0 },
|
{ label: "All Ratings", value: 0 },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function FilterSidebar({
|
export function FilterContent({
|
||||||
selectedGenres,
|
selectedGenres,
|
||||||
selectedStatus,
|
selectedStatus,
|
||||||
minRating,
|
minRating,
|
||||||
@ -44,26 +44,10 @@ export function FilterSidebar({
|
|||||||
onShowAdultContentChange,
|
onShowAdultContentChange,
|
||||||
onHide,
|
onHide,
|
||||||
isDisabled = false,
|
isDisabled = false,
|
||||||
}: FilterSidebarProps) {
|
}: FilterContentProps) {
|
||||||
const { data: genresData, isPending: isPendingGenres } = useGetGenres();
|
const { data: genresData, isPending: isPendingGenres } = useGetGenres();
|
||||||
const { isAuthenticated } = useAuth();
|
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 = () => {
|
const clearAllFilters = () => {
|
||||||
onGenresChange([]);
|
onGenresChange([]);
|
||||||
onStatusChange([]);
|
onStatusChange([]);
|
||||||
@ -79,162 +63,137 @@ export function FilterSidebar({
|
|||||||
userFavorites;
|
userFavorites;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-64 border-r border-border bg-sidebar">
|
<div className="flex h-full flex-col">
|
||||||
<div className="sticky top-0 flex h-screen flex-col">
|
{/* Header */}
|
||||||
{/* Header */}
|
<div className="flex items-center justify-between border-b border-sidebar-border px-6 py-6">
|
||||||
<div className="flex items-center justify-between border-b border-sidebar-border px-6 py-6">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<Button
|
||||||
<Button
|
variant="ghost"
|
||||||
variant="ghost"
|
size="icon"
|
||||||
size="icon"
|
onClick={onHide}
|
||||||
onClick={onHide}
|
className="h-8 w-8 text-sidebar-foreground hover:bg-sidebar-accent"
|
||||||
className="h-8 w-8 text-sidebar-foreground hover:bg-sidebar-accent"
|
>
|
||||||
>
|
<PanelLeftClose className="h-5 w-5" />
|
||||||
<PanelLeftClose className="h-5 w-5" />
|
</Button>
|
||||||
</Button>
|
<h2 className="text-lg font-semibold text-sidebar-foreground">
|
||||||
<h2 className="text-lg font-semibold text-sidebar-foreground">
|
Filters
|
||||||
Filters
|
</h2>
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
{hasActiveFilters && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={clearAllFilters}
|
|
||||||
disabled={isDisabled}
|
|
||||||
className="h-8 px-2 text-xs"
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={clearAllFilters}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className="h-8 px-2 text-xs"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-6">
|
<div className="flex-1 overflow-y-auto px-6 py-6">
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{isAuthenticated && (
|
{isAuthenticated && (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-3 text-sm font-medium text-sidebar-foreground">
|
<h3 className="mb-3 text-sm font-medium text-sidebar-foreground">
|
||||||
Content
|
Content
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center justify-between rounded-md px-3 py-2">
|
<div className="flex items-center justify-between rounded-md px-3 py-2">
|
||||||
<label className="text-sm text-sidebar-foreground">
|
<label className="text-sm text-sidebar-foreground">
|
||||||
Show Only Favorites
|
Show Only Favorites
|
||||||
</label>
|
</label>
|
||||||
<Switch
|
<Switch
|
||||||
checked={userFavorites}
|
checked={userFavorites}
|
||||||
onCheckedChange={onUserFavoritesChange}
|
onCheckedChange={onUserFavoritesChange}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between rounded-md px-3 py-2">
|
<div className="flex items-center justify-between rounded-md px-3 py-2">
|
||||||
<label className="text-sm text-sidebar-foreground">
|
<label className="text-sm text-sidebar-foreground">
|
||||||
Show Adult Content
|
Show Adult Content
|
||||||
</label>
|
</label>
|
||||||
<Switch
|
<Switch
|
||||||
checked={showAdultContent}
|
checked={showAdultContent}
|
||||||
onCheckedChange={onShowAdultContentChange}
|
onCheckedChange={onShowAdultContentChange}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</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>
|
</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Separator className="bg-sidebar-border" />
|
<Separator className="bg-sidebar-border" />
|
||||||
|
|
||||||
{/* Status */}
|
{/* Status */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-3 text-sm font-medium text-sidebar-foreground">
|
<h3 className="mb-3 text-sm font-medium text-sidebar-foreground">
|
||||||
Status
|
Status
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{STATUSES.map((status) => {
|
<MultiSelect options={STATUSES.map((status) => ({value: status, label: status}))} onValueChange={(selectedStatuses) => onStatusChange(selectedStatuses)} hideSelectAll />
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Separator className="bg-sidebar-border" />
|
<Separator className="bg-sidebar-border" />
|
||||||
|
|
||||||
{/* Rating */}
|
{/* Rating */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-3 text-sm font-medium text-sidebar-foreground">
|
<h3 className="mb-3 text-sm font-medium text-sidebar-foreground">
|
||||||
Minimum Rating
|
Minimum Rating
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{RATINGS.map((rating) => {
|
{RATINGS.map((rating) => {
|
||||||
const isSelected = minRating === rating.value;
|
const isSelected = minRating === rating.value;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={rating.value}
|
key={rating.value}
|
||||||
onClick={() => onRatingChange(rating.value)}
|
onClick={() => onRatingChange(rating.value)}
|
||||||
className={`flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors ${
|
className={`flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors ${
|
||||||
isSelected
|
isSelected
|
||||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||||
: "text-sidebar-foreground hover:bg-sidebar-accent/50"
|
: "text-sidebar-foreground hover:bg-sidebar-accent/50"
|
||||||
} ${isDisabled ? "opacity-50 pointer-events-none" : ""}`}
|
} ${isDisabled ? "opacity-50 pointer-events-none" : ""}`}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
>
|
>
|
||||||
<Star
|
<Star
|
||||||
className={`h-4 w-4 ${isSelected ? "fill-sidebar-primary text-sidebar-primary" : "text-muted-foreground"}`}
|
className={`h-4 w-4 ${isSelected ? "fill-sidebar-primary text-sidebar-primary" : "text-muted-foreground"}`}
|
||||||
/>
|
/>
|
||||||
<span>{rating.label}</span>
|
<span>{rating.label}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,7 +8,7 @@ interface MangaGridProps {
|
|||||||
|
|
||||||
export const MangaGrid = ({ manga, queryKey }: MangaGridProps) => {
|
export const MangaGrid = ({ manga, queryKey }: MangaGridProps) => {
|
||||||
return (
|
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) => (
|
{manga.map((item) => (
|
||||||
<MangaCard key={item.id} manga={item} queryKey={queryKey} />
|
<MangaCard key={item.id} manga={item} queryKey={queryKey} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -26,20 +26,25 @@ export const MangaChapter = ({
|
|||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { mutate: mutateDownloadChapterArchive } = useDownloadContentArchive({
|
const { mutate: mutateDownloadChapterArchive, isPending: isDownloading } =
|
||||||
mutation: {
|
useDownloadContentArchive({
|
||||||
onSuccess: (data, { mangaContentId }) => {
|
mutation: {
|
||||||
const url = window.URL.createObjectURL(data);
|
onMutate: ({ mangaContentId }) => setDownloadingId(mangaContentId),
|
||||||
const link = document.createElement("a");
|
onSuccess: (data, { mangaContentId }) => {
|
||||||
link.href = url;
|
const url = window.URL.createObjectURL(data);
|
||||||
link.setAttribute("download", `${mangaContentId}.cbz`);
|
const link = document.createElement("a");
|
||||||
document.body.appendChild(link);
|
link.href = url;
|
||||||
link.click();
|
link.setAttribute("download", `${mangaContentId}.cbz`);
|
||||||
link.remove();
|
document.body.appendChild(link);
|
||||||
window.URL.revokeObjectURL(url);
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
},
|
||||||
|
onSettled: () => setDownloadingId(null),
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
});
|
|
||||||
|
const [downloadingId, setDownloadingId] = useState<number | null>(null);
|
||||||
|
|
||||||
const { mutate, isPending: isPendingFetchChapter } =
|
const { mutate, isPending: isPendingFetchChapter } =
|
||||||
useFetchContentProviderContent({
|
useFetchContentProviderContent({
|
||||||
@ -78,13 +83,12 @@ export const MangaChapter = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={chapter.id}
|
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
|
<div
|
||||||
className={`flex h-8 w-8 items-center justify-center rounded-full ${
|
className={`flex h-8 w-8 items-center justify-center rounded-full ${chapter.isRead ? "bg-primary/20" : "bg-muted"
|
||||||
chapter.isRead ? "bg-primary/20" : "bg-muted"
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{chapter.isRead ? (
|
{chapter.isRead ? (
|
||||||
<Check className="h-4 w-4 text-primary" />
|
<Check className="h-4 w-4 text-primary" />
|
||||||
@ -94,35 +98,57 @@ export const MangaChapter = ({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="min-w-0 flex-1 flex items-center justify-between gap-2">
|
||||||
<p className="text-sm font-medium text-foreground flex items-center gap-2">
|
<div className="min-w-0 flex-1">
|
||||||
{chapter.language?.code && (
|
<p className="text-sm font-medium text-foreground flex items-center gap-2 flex-wrap min-w-0">
|
||||||
<ReactCountryFlag
|
{chapter.language?.code && (
|
||||||
countryCode={chapter.language.code.split("-")[1]}
|
<ReactCountryFlag
|
||||||
svg
|
countryCode={chapter.language.code.split("-")[1]}
|
||||||
style={{
|
svg
|
||||||
width: "1.2em",
|
style={{
|
||||||
height: "1.2em",
|
width: "1.2em",
|
||||||
}}
|
height: "1.2em",
|
||||||
title={chapter.language.name}
|
}}
|
||||||
/>
|
title={chapter.language.name}
|
||||||
)}
|
/>
|
||||||
{chapter.title}
|
)}
|
||||||
</p>
|
<span className="truncate flex-1">{chapter.title}</span>
|
||||||
{chapter.downloaded && (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
In database
|
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
{chapter.downloaded ? (
|
{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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => handleReadChapter(chapter.id)}
|
onClick={() => handleReadChapter(chapter.id)}
|
||||||
className="gap-2"
|
className="gap-2 w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
Read
|
Read
|
||||||
@ -136,9 +162,14 @@ export const MangaChapter = ({
|
|||||||
params: { contentArchiveFileType: "CBZ" },
|
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
|
Download
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -148,7 +179,7 @@ export const MangaChapter = ({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => fetchChapter(chapter.id)}
|
onClick={() => fetchChapter(chapter.id)}
|
||||||
disabled={isPendingFetchChapter}
|
disabled={isPendingFetchChapter}
|
||||||
className="gap-2 cursor-pointer"
|
className="gap-2 cursor-pointer w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
<Database className="h-4 w-4" />
|
<Database className="h-4 w-4" />
|
||||||
{isPendingFetchChapter && fetchingId === chapter.id
|
{isPendingFetchChapter && fetchingId === chapter.id
|
||||||
|
|||||||
@ -15,6 +15,10 @@ export const useDynamicPageSize = (rows = 4) => {
|
|||||||
// md: 3 columns
|
// md: 3 columns
|
||||||
return 3 * rows;
|
return 3 * rows;
|
||||||
}
|
}
|
||||||
// default: 2 columns
|
if (width >= 640) {
|
||||||
return 2 * rows;
|
// 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 { useNavigate, useParams } from "react-router";
|
||||||
import { useGetMangaContentImages } from "@/api/generated/content/content.ts";
|
import { useGetMangaContentImages } from "@/api/generated/content/content.ts";
|
||||||
import { useMarkContentAsRead } from "@/api/generated/user-interaction/user-interaction.ts";
|
import { useMarkContentAsRead } from "@/api/generated/user-interaction/user-interaction.ts";
|
||||||
import { ThemeToggle } from "@/components/ThemeToggle.tsx";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@ -314,76 +313,85 @@ const Chapter = () => {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
{/* HEADER */}
|
{/* HEADER */}
|
||||||
<header className="sticky top-0 z-50 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
<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-4 py-4 sm:px-8">
|
<div className="px-2 py-2 sm:px-4 sm:py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-2 sm:gap-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-1 sm:gap-4 flex-1 min-w-0">
|
||||||
<Button
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
onClick={() => navigate(`/manga/${mangaId}`)}
|
size="sm"
|
||||||
className="gap-2"
|
onClick={() => navigate(`/manga/${mangaId}`)}
|
||||||
>
|
className="gap-2 px-2 sm:px-3"
|
||||||
<ArrowLeft className="h-4 w-4" />
|
>
|
||||||
<span className="hidden sm:inline">Back</span>
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
<span className="hidden md:inline">Back</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => navigate("/")}
|
onClick={() => navigate("/")}
|
||||||
className="gap-2"
|
className="gap-2 px-1.5 sm:px-3"
|
||||||
>
|
>
|
||||||
<Home className="h-4 w-4" />
|
<Home className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">Home</span>
|
<span className="hidden md:inline">Home</span>
|
||||||
</Button>
|
</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>
|
||||||
|
|
||||||
{previousContentId && (
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
<Button
|
{previousContentId && (
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
onClick={goToPreviousChapter}
|
size="sm"
|
||||||
className="gap-1"
|
onClick={goToPreviousChapter}
|
||||||
>
|
className="gap-1 px-1.5"
|
||||||
<ChevronLeft className="h-4 w-4" />
|
>
|
||||||
<span className="hidden sm:inline">Prev</span>
|
<ChevronLeft className="h-4 w-4" />
|
||||||
</Button>
|
<span className="hidden sm:inline">Prev</span>
|
||||||
)}
|
</Button>
|
||||||
{nextContentId && (
|
)}
|
||||||
<Button
|
{nextContentId && (
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
onClick={goToNextChapter}
|
size="sm"
|
||||||
className="gap-1"
|
onClick={goToNextChapter}
|
||||||
>
|
className="gap-1 px-1.5"
|
||||||
<span className="hidden sm:inline">Next</span>
|
>
|
||||||
<ChevronRight className="h-4 w-4" />
|
<span className="hidden sm:inline">Next</span>
|
||||||
</Button>
|
<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>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-2 sm:gap-4 shrink-0">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-[10px] sm:text-sm text-muted-foreground whitespace-nowrap">
|
||||||
Page {currentPage} / {images.length}
|
{currentPage} / {images.length}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setInfiniteScroll((v) => !v)}
|
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>
|
</Button>
|
||||||
<ThemeToggle />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -391,18 +399,10 @@ const Chapter = () => {
|
|||||||
|
|
||||||
{/* MAIN */}
|
{/* MAIN */}
|
||||||
<main
|
<main
|
||||||
className={`mx-auto max-w-4xl ${
|
className={`mx-auto max-w-4xl ${infiniteScroll ? "px-0 py-0" : "px-4 py-8"
|
||||||
infiniteScroll ? "px-0 py-0" : "px-4 py-8"
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<div className="mb-4 sm:hidden">
|
{/* Removed mobile header from main as it's now in the sticky header */}
|
||||||
<h1 className="text-lg font-semibold text-foreground">
|
|
||||||
{data.data.mangaTitle}
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{chapterNumber}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ------------------------------------------------------------------ */}
|
{/* ------------------------------------------------------------------ */}
|
||||||
{/* MODE 1 --- INFINITE SCROLL MODE */}
|
{/* 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 { useEffect, useRef } from "react";
|
||||||
import { useDebounce } from "use-debounce";
|
import { useDebounce } from "use-debounce";
|
||||||
import { useGetMangas } from "@/api/generated/catalog/catalog.ts";
|
import { useGetMangas } from "@/api/generated/catalog/catalog.ts";
|
||||||
@ -8,12 +8,14 @@ import { Pagination } from "@/components/Pagination.tsx";
|
|||||||
import { ThemeToggle } from "@/components/ThemeToggle.tsx";
|
import { ThemeToggle } from "@/components/ThemeToggle.tsx";
|
||||||
import { Input } from "@/components/ui/input.tsx";
|
import { Input } from "@/components/ui/input.tsx";
|
||||||
import { Skeleton } from "@/components/ui/skeleton.tsx";
|
import { Skeleton } from "@/components/ui/skeleton.tsx";
|
||||||
|
import { Sheet, SheetContent } from "@/components/ui/sheet.tsx";
|
||||||
import { useUIState } from "@/contexts/UIStateContext.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 { MangaGrid } from "@/features/home/components/MangaGrid.tsx";
|
||||||
import { MangaLoadingState } from "@/components/MangaLoadingState.tsx";
|
import { MangaLoadingState } from "@/components/MangaLoadingState.tsx";
|
||||||
import { SortDropdown } from "@/features/home/components/SortDropdown.tsx";
|
import { SortDropdown } from "@/features/home/components/SortDropdown.tsx";
|
||||||
import { useDynamicPageSize } from "@/hooks/useDynamicPageSize.ts";
|
import { useDynamicPageSize } from "@/hooks/useDynamicPageSize.ts";
|
||||||
|
import useWindowDimensions from "@/hooks/useWindowDimensions.ts";
|
||||||
|
|
||||||
const ROWS_PER_PAGE = 4;
|
const ROWS_PER_PAGE = 4;
|
||||||
|
|
||||||
@ -40,8 +42,12 @@ const Home = () => {
|
|||||||
setIsSidebarOpen,
|
setIsSidebarOpen,
|
||||||
} = useUIState();
|
} = useUIState();
|
||||||
|
|
||||||
|
const { width } = useWindowDimensions();
|
||||||
|
const isMobile = width < 768;
|
||||||
|
|
||||||
const [debouncedSearchText] = useDebounce(searchText, 500);
|
const [debouncedSearchText] = useDebounce(searchText, 500);
|
||||||
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: mangasData,
|
data: mangasData,
|
||||||
queryKey: mangasQueryKey,
|
queryKey: mangasQueryKey,
|
||||||
@ -63,7 +69,6 @@ const Home = () => {
|
|||||||
const startSearchRef = useRef(debouncedSearchText);
|
const startSearchRef = useRef(debouncedSearchText);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Resets current page only when search text actually changes
|
|
||||||
if (startSearchRef.current !== debouncedSearchText) {
|
if (startSearchRef.current !== debouncedSearchText) {
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
startSearchRef.current = debouncedSearchText;
|
startSearchRef.current = debouncedSearchText;
|
||||||
@ -72,28 +77,38 @@ const Home = () => {
|
|||||||
|
|
||||||
const totalPages = mangasData?.data?.totalPages;
|
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 (
|
return (
|
||||||
<div className="flex min-h-screen bg-background">
|
<div className="flex min-h-screen bg-background">
|
||||||
{isSidebarOpen && (
|
{/* Mobile Sheet drawer */}
|
||||||
<FilterSidebar
|
{isMobile && (
|
||||||
selectedGenres={selectedGenres}
|
<Sheet open={isSidebarOpen} onOpenChange={setIsSidebarOpen}>
|
||||||
selectedStatus={selectedStatus}
|
<SheetContent side="left" showCloseButton={false} className="p-0 w-72 bg-sidebar">
|
||||||
minRating={minRating}
|
<FilterContent {...filterSidebarProps} />
|
||||||
userFavorites={userFavorites}
|
</SheetContent>
|
||||||
showAdultContent={showAdultContent}
|
</Sheet>
|
||||||
onGenresChange={setSelectedGenres}
|
|
||||||
onStatusChange={setSelectedStatus}
|
|
||||||
onRatingChange={setMinRating}
|
|
||||||
onUserFavoritesChange={setUserFavorites}
|
|
||||||
onShowAdultContentChange={setShowAdultContent}
|
|
||||||
onHide={() => setIsSidebarOpen(false)}
|
|
||||||
isDisabled={isFiltersDisabled}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col">
|
{/* Tablet + Desktop sidebar */}
|
||||||
<header className="border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
{!isMobile && isSidebarOpen && <FilterSidebar {...filterSidebarProps} />}
|
||||||
<div className="px-8 py-6">
|
|
||||||
|
<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 flex-col gap-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
@ -102,21 +117,19 @@ const Home = () => {
|
|||||||
<h1 className="text-3xl font-bold tracking-tight text-foreground">
|
<h1 className="text-3xl font-bold tracking-tight text-foreground">
|
||||||
MangaMochi
|
MangaMochi
|
||||||
</h1>
|
</h1>
|
||||||
<div className="mt-1 flex items-center gap-2 min-h-[1.25rem]">
|
<div className="mt-1 flex items-center gap-2 min-h-5">
|
||||||
{isFetching ? (
|
{isFetching ? (
|
||||||
<Skeleton className="h-4 w-24" />
|
<Skeleton className="h-4 w-24" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<p className="text-sm text-muted-foreground">
|
||||||
<p className="text-sm text-muted-foreground">
|
{mangasData?.data?.totalElements ?? 0} titles available
|
||||||
{mangasData?.data?.totalElements ?? 0} titles available
|
</p>
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{!isSidebarOpen && (
|
{!isMobile && !isSidebarOpen && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@ -139,9 +152,7 @@ const Home = () => {
|
|||||||
type="search"
|
type="search"
|
||||||
placeholder="Search manga or author..."
|
placeholder="Search manga or author..."
|
||||||
value={searchText}
|
value={searchText}
|
||||||
onChange={(e) => {
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
setSearchText(e.target.value);
|
|
||||||
}}
|
|
||||||
disabled={isFiltersDisabled}
|
disabled={isFiltersDisabled}
|
||||||
className="pl-10 bg-card border-border disabled:opacity-50"
|
className="pl-10 bg-card border-border disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
@ -150,7 +161,7 @@ const Home = () => {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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 ? (
|
{isPending ? (
|
||||||
<MangaLoadingState />
|
<MangaLoadingState />
|
||||||
) : mangasData?.data?.content && mangasData.data.content.length > 0 ? (
|
) : mangasData?.data?.content && mangasData.data.content.length > 0 ? (
|
||||||
@ -165,11 +176,22 @@ const Home = () => {
|
|||||||
)}{" "}
|
)}{" "}
|
||||||
of {mangasData.data.totalElements}
|
of {mangasData.data.totalElements}
|
||||||
</p>
|
</p>
|
||||||
<SortDropdown
|
<div className="flex items-center gap-2">
|
||||||
currentSort={sortOption}
|
<Button
|
||||||
onSortChange={setSortOption}
|
variant="outline"
|
||||||
isDisabled={isFiltersDisabled}
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
<MangaGrid
|
<MangaGrid
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
Database,
|
Database,
|
||||||
Heart,
|
Heart,
|
||||||
|
MoreVertical,
|
||||||
Star,
|
Star,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
@ -30,6 +31,12 @@ import {
|
|||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from "@/components/ui/collapsible.tsx";
|
} from "@/components/ui/collapsible.tsx";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu.tsx";
|
||||||
import { useAuth } from "@/contexts/AuthContext.tsx";
|
import { useAuth } from "@/contexts/AuthContext.tsx";
|
||||||
import { useUIState } from "@/contexts/UIStateContext.tsx";
|
import { useUIState } from "@/contexts/UIStateContext.tsx";
|
||||||
import { MangaChapter } from "@/features/manga/MangaChapter.tsx";
|
import { MangaChapter } from "@/features/manga/MangaChapter.tsx";
|
||||||
@ -155,9 +162,9 @@ const Manga = () => {
|
|||||||
{/* Content Shell */}
|
{/* Content Shell */}
|
||||||
<main className="px-8 py-8">
|
<main className="px-8 py-8">
|
||||||
<div className="mx-auto max-w-7xl">
|
<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 */}
|
{/* 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 */}
|
{/* Details Skeleton */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@ -252,10 +259,10 @@ const Manga = () => {
|
|||||||
<main className="px-8 py-8">
|
<main className="px-8 py-8">
|
||||||
<div className="mx-auto max-w-7xl">
|
<div className="mx-auto max-w-7xl">
|
||||||
{/* Manga Info Section */}
|
{/* Manga Info Section */}
|
||||||
<div className="grid gap-8 lg:grid-cols-[300px_1fr]">
|
<div className="grid gap-8 md:grid-cols-[300px_1fr]">
|
||||||
{/* Cover */}
|
{/* Cover */}
|
||||||
<div className="lg:sticky lg:top-24">
|
<div className="md:sticky md:top-24">
|
||||||
<div className="relative aspect-2/3 overflow-hidden rounded-lg border border-border bg-muted">
|
<div className="relative aspect-2/3 overflow-hidden rounded-lg border border-border bg-muted w-full">
|
||||||
<img
|
<img
|
||||||
src={
|
src={
|
||||||
(mangaData.data?.coverImageKey &&
|
(mangaData.data?.coverImageKey &&
|
||||||
@ -273,14 +280,14 @@ const Manga = () => {
|
|||||||
{/* Details */}
|
{/* Details */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-3 flex items-center justify-between gap-4">
|
<div className="mb-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<h1 className="text-balance text-4xl font-bold tracking-tight text-foreground">
|
<h1 className="text-balance text-3xl sm:text-4xl font-bold tracking-tight text-foreground">
|
||||||
{mangaData.data?.title}
|
{mangaData.data?.title}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex gap-4 items-center">
|
<div className="flex gap-2 items-center flex-wrap">
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
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}
|
{mangaData.data?.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
@ -294,7 +301,7 @@ const Manga = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isAuthenticated && (
|
{isAuthenticated && (
|
||||||
<>
|
<div className="flex gap-2 ml-auto sm:ml-0">
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@ -338,7 +345,7 @@ const Manga = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -395,6 +402,18 @@ const Manga = () => {
|
|||||||
dangerouslySetInnerHTML={{ __html: mangaData.data?.synopsis ?? "" }}
|
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="grid gap-4 sm:grid-cols-2">
|
||||||
<div className="flex items-center gap-3 rounded-lg border border-border bg-card p-4">
|
<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" />
|
<Star className="h-5 w-5 fill-primary text-primary" />
|
||||||
@ -446,18 +465,6 @@ const Manga = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -489,83 +496,85 @@ const Manga = () => {
|
|||||||
asChild
|
asChild
|
||||||
className={`flex-1 ${provider.chaptersAvailable > 0 ? "cursor-pointer" : "cursor-default"}`}
|
className={`flex-1 ${provider.chaptersAvailable > 0 ? "cursor-pointer" : "cursor-default"}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex items-center gap-4">
|
<Database className="h-5 w-5 text-primary" />
|
||||||
<Database className="h-5 w-5 text-primary" />
|
<div className="text-left">
|
||||||
<div className="text-left">
|
<p className="font-semibold text-foreground">
|
||||||
<p className="font-semibold text-foreground">
|
{provider.providerName}
|
||||||
{provider.providerName}
|
</p>
|
||||||
</p>
|
<p className="text-sm text-muted-foreground whitespace-nowrap">
|
||||||
<p className="text-sm text-muted-foreground">
|
{provider.chaptersDownloaded} downloaded •{" "}
|
||||||
{provider.chaptersDownloaded} downloaded •{" "}
|
{provider.chaptersAvailable} available
|
||||||
{provider.chaptersAvailable} available
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
|
|
||||||
{provider.supportsChapterFetch && provider.active && (
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex gap-4 px-4">
|
<CollapsibleTrigger asChild>
|
||||||
{provider.chaptersAvailable > 0 && (
|
<div
|
||||||
<Button
|
className={
|
||||||
size="sm"
|
provider.chaptersAvailable > 0
|
||||||
variant="outline"
|
? "cursor-pointer"
|
||||||
disabled={isPending || fetchAllPending}
|
: "invisible"
|
||||||
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 ? (
|
<ChevronDown
|
||||||
<Spinner />
|
className={`h-5 w-5 text-muted-foreground transition-transform ${expandedProviderIds.includes(provider.id ?? -1)
|
||||||
) : (
|
? "rotate-180"
|
||||||
<Database className="h-4 w-4" />
|
: ""
|
||||||
)}
|
}`}
|
||||||
Fetch list from Provider
|
/>
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
</CollapsibleTrigger>
|
||||||
)}
|
|
||||||
|
|
||||||
<CollapsibleTrigger asChild>
|
{provider.supportsChapterFetch && provider.active && (
|
||||||
<div
|
<DropdownMenu>
|
||||||
className={
|
<DropdownMenuTrigger asChild>
|
||||||
provider.chaptersAvailable > 0
|
<Button
|
||||||
? "cursor-pointer"
|
variant="ghost"
|
||||||
: "invisible"
|
size="icon"
|
||||||
}
|
disabled={isPending || fetchAllPending}
|
||||||
>
|
onClick={(e) => e.stopPropagation()}
|
||||||
<ChevronDown
|
>
|
||||||
className={`h-5 w-5 text-muted-foreground transition-transform ${expandedProviderIds.includes(provider.id ?? -1)
|
{isPending || fetchAllPending ? (
|
||||||
? "rotate-180"
|
<Spinner />
|
||||||
: ""
|
) : (
|
||||||
}`}
|
<MoreVertical className="h-4 w-4" />
|
||||||
/>
|
)}
|
||||||
</div>
|
</Button>
|
||||||
</CollapsibleTrigger>
|
</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>
|
</CardContent>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<MangaChapter
|
<MangaChapter
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user