feat: add manual resolve dialog for ingest reviews with image upload

This commit is contained in:
Rodrigo Verdiani 2026-07-14 14:10:55 -03:00
parent 234b42296b
commit 73558f4eaa
5 changed files with 612 additions and 1 deletions

View File

@ -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;
};

View File

@ -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
*/

View 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 };

View File

@ -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>
);
}

View 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>
);
}