feature(home): add import functionality with MangaDex and manual file import dialogs
All checks were successful
ci/woodpecker/push/pipeline Pipeline was successful

This commit is contained in:
Rodrigo Verdiani 2025-10-29 22:01:40 -03:00
parent a2f1b48b9a
commit 8e92389f53
7 changed files with 578 additions and 1 deletions

37
package-lock.json generated
View File

@ -10,6 +10,7 @@
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-avatar": "^1.1.10",
"@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-separator": "^1.1.7",
@ -1560,6 +1561,42 @@
}
}
},
"node_modules/@radix-ui/react-dialog": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
"integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-direction": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",

View File

@ -12,6 +12,7 @@
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-avatar": "^1.1.10",
"@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-separator": "^1.1.7",

View File

@ -0,0 +1,141 @@
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 Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-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 DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground 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>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View File

@ -0,0 +1,69 @@
import { AlertCircle, Download, FileUp } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useAuth } from "@/contexts/AuthContext.tsx";
import { MangaDexImportDialog } from "@/features/home/components/MangaDexImportDialog.tsx";
import { MangaManualImportDialog } from "@/features/home/components/MangaManualImportDialog.tsx";
export function ImportDropdown() {
const { isAuthenticated } = useAuth();
const [mangaDexDialogOpen, setMangaDexDialogOpen] = useState(false);
const [fileImportDialogOpen, setFileImportDialogOpen] = useState(false);
if (!isAuthenticated) {
return null;
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-2 bg-transparent">
<Download className="h-4 w-4" />
Import
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="">
<DropdownMenuItem onClick={() => setMangaDexDialogOpen(true)}>
<Download className="mr-2 h-4 w-4" />
Import from MangaDex
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setFileImportDialogOpen(true)}>
<FileUp className="mr-2 h-4 w-4" />
Import from File
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
to="/import-review"
className="cursor-pointer gap-2 text-amber-600 dark:text-amber-500"
>
<AlertCircle className="h-4 w-4" />
<span>Import Review</span>
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<MangaDexImportDialog
mangaDexDialogOpen={mangaDexDialogOpen}
onMangaDexDialogOpenChange={setMangaDexDialogOpen}
/>
<MangaManualImportDialog
fileImportDialogOpen={fileImportDialogOpen}
onFileImportDialogOpenChange={setFileImportDialogOpen}
/>
</>
);
}

View File

@ -0,0 +1,149 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useCallback } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { useImportFromMangaDex } from "@/api/generated/manga-import/manga-import.ts";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog.tsx";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form.tsx";
import { Input } from "@/components/ui/input";
interface MangaDexImportDialogProps {
mangaDexDialogOpen: boolean;
onMangaDexDialogOpenChange: (open: boolean) => void;
}
export const MangaDexImportDialog = ({
mangaDexDialogOpen,
onMangaDexDialogOpenChange,
}: MangaDexImportDialogProps) => {
const formSchema = z
.object({
value: z.string().min(1, "Please enter a MangaDex ID or URL."),
})
.refine(
(data: { value: string }) =>
data.value.length > 36 &&
!data.value.match(/title\/([0-9a-fA-F-]{36})/),
{
message: "Invalid MangaDex ID or URL",
path: ["value"],
abort: true,
},
)
.refine((data: { value: string }) => data.value.length !== 36, {
message: "Invalid MangaDex ID",
path: ["value"],
abort: true,
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
value: "",
},
});
const { mutate: importMangaDex, isPending: isPendingImportMangaDex } =
useImportFromMangaDex({
mutation: {
onSuccess: () => {
form.reset();
onMangaDexDialogOpenChange(false);
toast.success("Manga imported successfully!");
},
},
});
const handleSubmit = useCallback(
(data: z.infer<typeof formSchema>) => {
let id = data.value;
if (data.value.length > 36) {
const match = data.value.match(/title\/([0-9a-fA-F-]{36})/);
if (match) {
id = match[1];
} else {
alert("Invalid MangaDex URL or ID");
return;
}
}
if (id.length !== 36) {
alert("Invalid MangaDex ID");
return;
}
importMangaDex({ data: { id } });
},
[formSchema, importMangaDex],
);
return (
<Dialog open={mangaDexDialogOpen} onOpenChange={onMangaDexDialogOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Import from MangaDex</DialogTitle>
<DialogDescription>
Enter a MangaDex manga URL or ID to import it to your library.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
id="importForm"
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-4"
>
<FormField
control={form.control}
name="value"
render={({ field }) => (
<FormItem>
<FormLabel>MangaDex URL or ID</FormLabel>
<FormControl>
<Input
placeholder="e.g., https://mangadex.org/title/..."
disabled={isPendingImportMangaDex}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onMangaDexDialogOpenChange(false)}
>
Cancel
</Button>
<Button
type="submit"
disabled={isPendingImportMangaDex}
form="importForm"
>
Import
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,179 @@
import { FileUp } from "lucide-react";
import type React from "react";
import { useState } from "react";
import { toast } from "sonner";
import { useImportMultipleFiles } from "@/api/generated/manga-import/manga-import.ts";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
interface MangaManualImportDialogProps {
fileImportDialogOpen: boolean;
onFileImportDialogOpenChange: (open: boolean) => void;
}
export const MangaManualImportDialog = ({
fileImportDialogOpen,
onFileImportDialogOpenChange,
}: MangaManualImportDialogProps) => {
const [malId, setMalId] = useState("");
const [dragActive, setDragActive] = useState(false);
const [files, setFiles] = useState<File[] | null>(null);
const { mutate, isPending } = useImportMultipleFiles({
mutation: {
onSuccess: () => {
setFiles(null);
setMalId("");
onFileImportDialogOpenChange(false);
toast.success("Manga imported successfully!");
},
onError: () => toast.error("Failed to import manga."),
},
});
const handleFileImport = () => {
if (!files) {
alert("Please select one or more files to upload");
return;
}
if (!malId.trim()) {
alert("Please enter a MyAnimeList manga ID");
return;
}
let id = malId;
if (!/^\d+$/.test(malId)) {
const regex =
/https?:\/\/(?:www\.)?myanimelist\.net\/(manga)\/(\d+)(?:\/|$)/i;
const match = malId.match(regex);
if (match) {
id = match[2];
} else {
alert("Invalid MyAnimeList URL or ID");
return;
}
}
mutate({ data: { malId: id, files } });
};
const handleDrag = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === "dragenter" || e.type === "dragover") {
setDragActive(true);
} else if (e.type === "dragleave") {
setDragActive(false);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files) {
setFiles(Array.from(e.dataTransfer.files));
}
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
setFiles(Array.from(e.target.files));
}
};
return (
<Dialog
open={fileImportDialogOpen}
onOpenChange={onFileImportDialogOpenChange}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Import from File</DialogTitle>
<DialogDescription>
Upload one or more files and provide the MyAnimeList manga URL (or
ID) to import manga data.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="text-sm font-medium">
MyAnimeList Manga URL (or ID)
</label>
<Input
placeholder="e.g., https://myanimelist.net/manga/..."
value={malId}
onChange={(e) => setMalId(e.target.value)}
className="mt-2"
/>
</div>
<div>
<label className="text-sm font-medium">Upload File</label>
<div
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
className={`mt-2 rounded-lg border-2 border-dashed p-6 text-center transition-colors ${
dragActive
? "border-primary bg-primary/5"
: "border-muted-foreground/25"
}`}
>
<input
type="file"
id="file-input"
onChange={handleFileSelect}
className="hidden"
multiple
accept=".cbz"
/>
<label htmlFor="file-input" className="cursor-pointer">
<div className="flex flex-col items-center gap-2">
<FileUp className="h-8 w-8 text-muted-foreground" />
<div>
<p className="text-sm font-medium">
{files
? files.map((file) => file.name).join(", ")
: "Drag and drop your files here"}
</p>
<p className="text-xs text-muted-foreground">
or click to select (.CBZ, .CBR)
</p>
</div>
</div>
</label>
</div>
</div>
</div>
<DialogFooter>
<Button
disabled={isPending}
variant="outline"
onClick={() => {
setFiles(null);
onFileImportDialogOpenChange(false);
}}
>
Cancel
</Button>
<Button disabled={isPending} onClick={handleFileImport}>
Import
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -6,6 +6,7 @@ import { AuthHeader } from "@/components/AuthHeader.tsx";
import { Pagination } from "@/components/Pagination.tsx";
import { Input } from "@/components/ui/input.tsx";
import { FilterSidebar } from "@/features/home/components/FilterSidebar.tsx";
import { ImportDropdown } from "@/features/home/components/ImportDropdown.tsx";
import { MangaGrid } from "@/features/home/components/MangaGrid.tsx";
import {
SortDropdown,
@ -75,7 +76,7 @@ const Home = () => {
</div>
</div>
<div className="flex items-center gap-4">
{/*<ImportDropdown />*/}
<ImportDropdown />
{/*<ThemeToggle />*/}
<AuthHeader />
</div>