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;
|
||||
}
|
||||
|
||||
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 {
|
||||
email: string;
|
||||
password: string;
|
||||
@ -234,9 +261,9 @@ export interface PageMangaImportJobDTO {
|
||||
|
||||
export interface PageableObject {
|
||||
offset?: number;
|
||||
paged?: boolean;
|
||||
pageNumber?: number;
|
||||
pageSize?: number;
|
||||
paged?: boolean;
|
||||
unpaged?: boolean;
|
||||
sort?: SortObject;
|
||||
}
|
||||
@ -422,6 +449,15 @@ malId?: number;
|
||||
aniListId?: number;
|
||||
};
|
||||
|
||||
export type ResolveMangaIngestReviewManuallyParams = {
|
||||
id: number;
|
||||
};
|
||||
|
||||
export type ResolveMangaIngestReviewManuallyBody = {
|
||||
coverImage: Blob;
|
||||
request: ManualMangaIngestRequestDTO;
|
||||
};
|
||||
|
||||
export type GetContentProvidersParams = {
|
||||
manualImport?: boolean;
|
||||
};
|
||||
|
||||
@ -26,6 +26,8 @@ import type {
|
||||
import type {
|
||||
DefaultResponseDTOListMangaIngestReviewDTO,
|
||||
DefaultResponseDTOVoid,
|
||||
ResolveMangaIngestReviewManuallyBody,
|
||||
ResolveMangaIngestReviewManuallyParams,
|
||||
ResolveMangaIngestReviewParams
|
||||
} from '../api.schemas';
|
||||
|
||||
@ -194,6 +196,76 @@ export const useResolveMangaIngestReview = <TError = unknown,
|
||||
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.
|
||||
* @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 { Card } from "@/components/ui/card.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { ManualResolveDialog } from "@/features/admin/components/ManualResolveDialog.tsx";
|
||||
|
||||
interface ImportReviewCardProps {
|
||||
importReview: MangaIngestReviewDTO;
|
||||
@ -21,6 +22,7 @@ export function ImportReviewCard({ importReview, queryKey }: ImportReviewCardPro
|
||||
|
||||
const [malId, setMalId] = useState("");
|
||||
const [aniListId, setAniListId] = useState("");
|
||||
const [isManualResolveDialogOpen, setIsManualResolveDialogOpen] = useState(false);
|
||||
|
||||
const { mutate: mutateDeleteImportReview } = useDeleteMangaIngestReview({
|
||||
mutation: {
|
||||
@ -116,6 +118,12 @@ export function ImportReviewCard({ importReview, queryKey }: ImportReviewCardPro
|
||||
>
|
||||
{isPendingResolveImportReview ? "Resolving..." : "Resolve Import"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setIsManualResolveDialogOpen(true)}
|
||||
variant="secondary"
|
||||
>
|
||||
Resolve Manually
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleRemove}
|
||||
variant="outline"
|
||||
@ -127,6 +135,11 @@ export function ImportReviewCard({ importReview, queryKey }: ImportReviewCardPro
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ManualResolveDialog
|
||||
reviewId={importReview.id}
|
||||
open={isManualResolveDialogOpen}
|
||||
onOpenChange={setIsManualResolveDialogOpen}
|
||||
/>
|
||||
</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