feat: add manual resolve dialog for ingest reviews with image upload
This commit is contained in:
parent
234b42296b
commit
73558f4eaa
@ -57,6 +57,33 @@ export interface PresignedImportResponseDTO {
|
|||||||
fileKey?: string;
|
fileKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ManualMangaIngestRequestDTOStatus = typeof ManualMangaIngestRequestDTOStatus[keyof typeof ManualMangaIngestRequestDTOStatus];
|
||||||
|
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
||||||
|
export const ManualMangaIngestRequestDTOStatus = {
|
||||||
|
ONGOING: 'ONGOING',
|
||||||
|
COMPLETED: 'COMPLETED',
|
||||||
|
HIATUS: 'HIATUS',
|
||||||
|
CANCELLED: 'CANCELLED',
|
||||||
|
UNKNOWN: 'UNKNOWN',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export interface ManualMangaIngestRequestDTO {
|
||||||
|
/** @minLength 1 */
|
||||||
|
title: string;
|
||||||
|
synopsis?: string;
|
||||||
|
status?: ManualMangaIngestRequestDTOStatus;
|
||||||
|
score?: number;
|
||||||
|
publishedFrom?: string;
|
||||||
|
publishedTo?: string;
|
||||||
|
chapterCount?: number;
|
||||||
|
adult?: boolean;
|
||||||
|
authors?: string[];
|
||||||
|
genres?: string[];
|
||||||
|
alternativeTitles?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface AuthenticationRequestDTO {
|
export interface AuthenticationRequestDTO {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
@ -234,9 +261,9 @@ export interface PageMangaImportJobDTO {
|
|||||||
|
|
||||||
export interface PageableObject {
|
export interface PageableObject {
|
||||||
offset?: number;
|
offset?: number;
|
||||||
|
paged?: boolean;
|
||||||
pageNumber?: number;
|
pageNumber?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
paged?: boolean;
|
|
||||||
unpaged?: boolean;
|
unpaged?: boolean;
|
||||||
sort?: SortObject;
|
sort?: SortObject;
|
||||||
}
|
}
|
||||||
@ -422,6 +449,15 @@ malId?: number;
|
|||||||
aniListId?: number;
|
aniListId?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ResolveMangaIngestReviewManuallyParams = {
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResolveMangaIngestReviewManuallyBody = {
|
||||||
|
coverImage: Blob;
|
||||||
|
request: ManualMangaIngestRequestDTO;
|
||||||
|
};
|
||||||
|
|
||||||
export type GetContentProvidersParams = {
|
export type GetContentProvidersParams = {
|
||||||
manualImport?: boolean;
|
manualImport?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -26,6 +26,8 @@ import type {
|
|||||||
import type {
|
import type {
|
||||||
DefaultResponseDTOListMangaIngestReviewDTO,
|
DefaultResponseDTOListMangaIngestReviewDTO,
|
||||||
DefaultResponseDTOVoid,
|
DefaultResponseDTOVoid,
|
||||||
|
ResolveMangaIngestReviewManuallyBody,
|
||||||
|
ResolveMangaIngestReviewManuallyParams,
|
||||||
ResolveMangaIngestReviewParams
|
ResolveMangaIngestReviewParams
|
||||||
} from '../api.schemas';
|
} from '../api.schemas';
|
||||||
|
|
||||||
@ -194,6 +196,76 @@ export const useResolveMangaIngestReview = <TError = unknown,
|
|||||||
return useMutation(mutationOptions, queryClient);
|
return useMutation(mutationOptions, queryClient);
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
* Resolve a manga ingest review by manually providing all manga data and a cover image.
|
||||||
|
* @summary Resolve manga ingest review manually
|
||||||
|
*/
|
||||||
|
export const resolveMangaIngestReviewManually = (
|
||||||
|
resolveMangaIngestReviewManuallyBody: ResolveMangaIngestReviewManuallyBody,
|
||||||
|
params: ResolveMangaIngestReviewManuallyParams,
|
||||||
|
options?: SecondParameter<typeof customInstance>,signal?: AbortSignal
|
||||||
|
) => {
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append(`coverImage`, resolveMangaIngestReviewManuallyBody.coverImage)
|
||||||
|
formData.append(`request`, JSON.stringify(resolveMangaIngestReviewManuallyBody.request));
|
||||||
|
|
||||||
|
return customInstance<DefaultResponseDTOVoid>(
|
||||||
|
{url: `/catalog/ingest-reviews/manual`, method: 'POST',
|
||||||
|
headers: {'Content-Type': 'multipart/form-data', },
|
||||||
|
data: formData,
|
||||||
|
params, signal
|
||||||
|
},
|
||||||
|
options);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getResolveMangaIngestReviewManuallyMutationOptions = <TError = unknown,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof resolveMangaIngestReviewManually>>, TError,{data: ResolveMangaIngestReviewManuallyBody;params: ResolveMangaIngestReviewManuallyParams}, TContext>, request?: SecondParameter<typeof customInstance>}
|
||||||
|
): UseMutationOptions<Awaited<ReturnType<typeof resolveMangaIngestReviewManually>>, TError,{data: ResolveMangaIngestReviewManuallyBody;params: ResolveMangaIngestReviewManuallyParams}, TContext> => {
|
||||||
|
|
||||||
|
const mutationKey = ['resolveMangaIngestReviewManually'];
|
||||||
|
const {mutation: mutationOptions, request: requestOptions} = options ?
|
||||||
|
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
|
||||||
|
options
|
||||||
|
: {...options, mutation: {...options.mutation, mutationKey}}
|
||||||
|
: {mutation: { mutationKey, }, request: undefined};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const mutationFn: MutationFunction<Awaited<ReturnType<typeof resolveMangaIngestReviewManually>>, {data: ResolveMangaIngestReviewManuallyBody;params: ResolveMangaIngestReviewManuallyParams}> = (props) => {
|
||||||
|
const {data,params} = props ?? {};
|
||||||
|
|
||||||
|
return resolveMangaIngestReviewManually(data,params,requestOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { mutationFn, ...mutationOptions }}
|
||||||
|
|
||||||
|
export type ResolveMangaIngestReviewManuallyMutationResult = NonNullable<Awaited<ReturnType<typeof resolveMangaIngestReviewManually>>>
|
||||||
|
export type ResolveMangaIngestReviewManuallyMutationBody = ResolveMangaIngestReviewManuallyBody
|
||||||
|
export type ResolveMangaIngestReviewManuallyMutationError = unknown
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Resolve manga ingest review manually
|
||||||
|
*/
|
||||||
|
export const useResolveMangaIngestReviewManually = <TError = unknown,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof resolveMangaIngestReviewManually>>, TError,{data: ResolveMangaIngestReviewManuallyBody;params: ResolveMangaIngestReviewManuallyParams}, TContext>, request?: SecondParameter<typeof customInstance>}
|
||||||
|
, queryClient?: QueryClient): UseMutationResult<
|
||||||
|
Awaited<ReturnType<typeof resolveMangaIngestReviewManually>>,
|
||||||
|
TError,
|
||||||
|
{data: ResolveMangaIngestReviewManuallyBody;params: ResolveMangaIngestReviewManuallyParams},
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
|
||||||
|
const mutationOptions = getResolveMangaIngestReviewManuallyMutationOptions(options);
|
||||||
|
|
||||||
|
return useMutation(mutationOptions, queryClient);
|
||||||
|
}
|
||||||
|
/**
|
||||||
* Delete pending manga ingest review by ID.
|
* Delete pending manga ingest review by ID.
|
||||||
* @summary Delete pending manga ingest review
|
* @summary Delete pending manga ingest review
|
||||||
*/
|
*/
|
||||||
|
|||||||
21
src/components/ui/textarea.tsx
Normal file
21
src/components/ui/textarea.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import type * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
"placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex min-h-[80px] w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none md:text-sm",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea };
|
||||||
@ -10,6 +10,7 @@ import {
|
|||||||
import { Button } from "@/components/ui/button.tsx";
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
import { Card } from "@/components/ui/card.tsx";
|
import { Card } from "@/components/ui/card.tsx";
|
||||||
import { Input } from "@/components/ui/input.tsx";
|
import { Input } from "@/components/ui/input.tsx";
|
||||||
|
import { ManualResolveDialog } from "@/features/admin/components/ManualResolveDialog.tsx";
|
||||||
|
|
||||||
interface ImportReviewCardProps {
|
interface ImportReviewCardProps {
|
||||||
importReview: MangaIngestReviewDTO;
|
importReview: MangaIngestReviewDTO;
|
||||||
@ -21,6 +22,7 @@ export function ImportReviewCard({ importReview, queryKey }: ImportReviewCardPro
|
|||||||
|
|
||||||
const [malId, setMalId] = useState("");
|
const [malId, setMalId] = useState("");
|
||||||
const [aniListId, setAniListId] = useState("");
|
const [aniListId, setAniListId] = useState("");
|
||||||
|
const [isManualResolveDialogOpen, setIsManualResolveDialogOpen] = useState(false);
|
||||||
|
|
||||||
const { mutate: mutateDeleteImportReview } = useDeleteMangaIngestReview({
|
const { mutate: mutateDeleteImportReview } = useDeleteMangaIngestReview({
|
||||||
mutation: {
|
mutation: {
|
||||||
@ -116,6 +118,12 @@ export function ImportReviewCard({ importReview, queryKey }: ImportReviewCardPro
|
|||||||
>
|
>
|
||||||
{isPendingResolveImportReview ? "Resolving..." : "Resolve Import"}
|
{isPendingResolveImportReview ? "Resolving..." : "Resolve Import"}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setIsManualResolveDialogOpen(true)}
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
Resolve Manually
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleRemove}
|
onClick={handleRemove}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@ -127,6 +135,11 @@ export function ImportReviewCard({ importReview, queryKey }: ImportReviewCardPro
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ManualResolveDialog
|
||||||
|
reviewId={importReview.id}
|
||||||
|
open={isManualResolveDialogOpen}
|
||||||
|
onOpenChange={setIsManualResolveDialogOpen}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
469
src/features/admin/components/ManualResolveDialog.tsx
Normal file
469
src/features/admin/components/ManualResolveDialog.tsx
Normal file
@ -0,0 +1,469 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { ImageIcon, Upload } from "lucide-react";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { DefaultResponseDTOVoid, ManualMangaIngestRequestDTO } from "@/api/generated/api.schemas.ts";
|
||||||
|
import { customInstance } from "@/api/api.ts";
|
||||||
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog.tsx";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form.tsx";
|
||||||
|
import { Input } from "@/components/ui/input.tsx";
|
||||||
|
import { Label } from "@/components/ui/label.tsx";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select.tsx";
|
||||||
|
import { Spinner } from "@/components/ui/spinner.tsx";
|
||||||
|
import { Switch } from "@/components/ui/switch.tsx";
|
||||||
|
import { Textarea } from "@/components/ui/textarea.tsx";
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = ["ONGOING", "COMPLETED", "HIATUS", "CANCELLED", "UNKNOWN"] as const;
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
title: z.string().min(1, "Title is required"),
|
||||||
|
synopsis: z.string().optional(),
|
||||||
|
status: z.enum(STATUS_OPTIONS).optional(),
|
||||||
|
score: z.string().optional(),
|
||||||
|
publishedFrom: z.string().optional(),
|
||||||
|
publishedTo: z.string().optional(),
|
||||||
|
chapterCount: z.string().optional(),
|
||||||
|
adult: z.boolean().optional(),
|
||||||
|
authors: z.string().optional(),
|
||||||
|
genres: z.string().optional(),
|
||||||
|
alternativeTitles: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
function parseCommaSeparated(value?: string): string[] | undefined {
|
||||||
|
if (!value?.trim()) return undefined;
|
||||||
|
return value
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ManualResolveDialogProps {
|
||||||
|
reviewId: number;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ManualResolveDialog({
|
||||||
|
reviewId,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: ManualResolveDialogProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [coverImage, setCoverImage] = useState<File | null>(null);
|
||||||
|
const [coverImagePreview, setCoverImagePreview] = useState<string | null>(null);
|
||||||
|
const [coverImageError, setCoverImageError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
title: "",
|
||||||
|
synopsis: "",
|
||||||
|
status: undefined,
|
||||||
|
score: "",
|
||||||
|
publishedFrom: "",
|
||||||
|
publishedTo: "",
|
||||||
|
chapterCount: "",
|
||||||
|
adult: false,
|
||||||
|
authors: "",
|
||||||
|
genres: "",
|
||||||
|
alternativeTitles: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: resolveManually, isPending } = useMutation({
|
||||||
|
mutationFn: ({ formData, id }: { formData: FormData; id: number }) =>
|
||||||
|
customInstance<DefaultResponseDTOVoid>({
|
||||||
|
url: `/catalog/ingest-reviews/manual`,
|
||||||
|
method: "POST",
|
||||||
|
params: { id },
|
||||||
|
data: formData,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["/catalog/ingest-reviews"] });
|
||||||
|
form.reset();
|
||||||
|
setCoverImage(null);
|
||||||
|
setCoverImagePreview(null);
|
||||||
|
onOpenChange(false);
|
||||||
|
toast.success("Manga created successfully");
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error("Failed to create manga");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCoverImageChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) {
|
||||||
|
setCoverImage(null);
|
||||||
|
setCoverImagePreview(null);
|
||||||
|
setCoverImageError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file.type.startsWith("image/")) {
|
||||||
|
setCoverImageError("Please select an image file");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCoverImage(file);
|
||||||
|
setCoverImageError(null);
|
||||||
|
setCoverImagePreview(URL.createObjectURL(file));
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
(data: FormValues) => {
|
||||||
|
if (!coverImage) {
|
||||||
|
setCoverImageError("Cover image is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request: ManualMangaIngestRequestDTO = {
|
||||||
|
title: data.title,
|
||||||
|
synopsis: data.synopsis || undefined,
|
||||||
|
status: data.status || undefined,
|
||||||
|
score: data.score ? Number(data.score) : undefined,
|
||||||
|
publishedFrom: data.publishedFrom || undefined,
|
||||||
|
publishedTo: data.publishedTo || undefined,
|
||||||
|
chapterCount: data.chapterCount ? Number(data.chapterCount) : undefined,
|
||||||
|
adult: data.adult || undefined,
|
||||||
|
authors: parseCommaSeparated(data.authors),
|
||||||
|
genres: parseCommaSeparated(data.genres),
|
||||||
|
alternativeTitles: parseCommaSeparated(data.alternativeTitles),
|
||||||
|
};
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("coverImage", coverImage);
|
||||||
|
formData.append("request", new Blob([JSON.stringify(request)], { type: "application/json" }));
|
||||||
|
|
||||||
|
resolveManually({ formData, id: reviewId });
|
||||||
|
},
|
||||||
|
[coverImage, resolveManually, reviewId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpenChange = useCallback(
|
||||||
|
(isOpen: boolean) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
form.reset();
|
||||||
|
setCoverImage(null);
|
||||||
|
setCoverImagePreview(null);
|
||||||
|
setCoverImageError(null);
|
||||||
|
}
|
||||||
|
onOpenChange(isOpen);
|
||||||
|
},
|
||||||
|
[form, onOpenChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Resolve Manually</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Fill in all manga details and upload a cover image to create the manga entry.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
id="manualResolveForm"
|
||||||
|
onSubmit={form.handleSubmit(handleSubmit)}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Cover Image</Label>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<label className="flex cursor-pointer items-center gap-2 rounded-md border border-dashed border-muted-foreground/50 px-4 py-3 text-sm text-muted-foreground hover:border-primary hover:text-primary transition-colors">
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
<span>{coverImage ? coverImage.name : "Choose image..."}</span>
|
||||||
|
<Input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleCoverImageChange}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{coverImagePreview && (
|
||||||
|
<div className="relative h-16 w-12 shrink-0 overflow-hidden rounded-md border">
|
||||||
|
<img
|
||||||
|
src={coverImagePreview}
|
||||||
|
alt="Cover preview"
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!coverImagePreview && (
|
||||||
|
<div className="flex h-16 w-12 shrink-0 items-center justify-center rounded-md border bg-muted/50">
|
||||||
|
<ImageIcon className="h-5 w-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{coverImageError && (
|
||||||
|
<p className="text-destructive text-sm">{coverImageError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="title"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Title *</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Manga title" disabled={isPending} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="synopsis"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Synopsis</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Manga synopsis"
|
||||||
|
disabled={isPending}
|
||||||
|
className="resize-y"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="status"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Status</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
value={field.value}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{STATUS_OPTIONS.map((status) => (
|
||||||
|
<SelectItem key={status} value={status}>
|
||||||
|
{status.charAt(0) + status.slice(1).toLowerCase()}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="score"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Score</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
max="10"
|
||||||
|
placeholder="0.00"
|
||||||
|
disabled={isPending}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="publishedFrom"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormLabel>Published From</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="datetime-local" disabled={isPending} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="publishedTo"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormLabel>Published To</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="datetime-local" disabled={isPending} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="chapterCount"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Chapter Count</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
placeholder="0"
|
||||||
|
disabled={isPending}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="adult"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center justify-between rounded-md border p-3">
|
||||||
|
<div>
|
||||||
|
<FormLabel className="cursor-pointer">Adult Content</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Mark this manga as containing adult content
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="authors"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Authors</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Separate authors by comma"
|
||||||
|
disabled={isPending}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Comma-separated list of author names</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="genres"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Genres</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Separate genres by comma"
|
||||||
|
disabled={isPending}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Comma-separated list of genres</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="alternativeTitles"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Alternative Titles</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Separate titles by comma"
|
||||||
|
disabled={isPending}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Comma-separated list of alternative titles</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="manualResolveForm"
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<>
|
||||||
|
<Spinner className="mr-2" />
|
||||||
|
Creating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Create Manga"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user