Files
Reactive-Resume/src/dialogs/resume/import.tsx
T
2026-04-09 00:28:31 +02:00

309 lines
11 KiB
TypeScript

import { zodResolver } from "@hookform/resolvers/zod";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { DownloadSimpleIcon, FileIcon, UploadSimpleIcon } from "@phosphor-icons/react";
import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { useEffect, useRef, useState } from "react";
import { useForm, useWatch } from "react-hook-form";
import { toast } from "sonner";
import z from "zod";
import type { ResumeData } from "@/schema/resume/data";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox";
import { DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { useAIStore } from "@/integrations/ai/store";
import { JSONResumeImporter } from "@/integrations/import/json-resume";
import { ReactiveResumeJSONImporter } from "@/integrations/import/reactive-resume-json";
import { ReactiveResumeV4JSONImporter } from "@/integrations/import/reactive-resume-v4-json";
import { client, orpc } from "@/integrations/orpc/client";
import { cn } from "@/utils/style";
import { type DialogProps, useDialogStore } from "../store";
const formSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("") }),
z.object({
type: z.literal("pdf"),
file: z.instanceof(File).refine((file) => file.type === "application/pdf", { message: "File must be a PDF" }),
}),
z.object({
type: z.literal("docx"),
file: z
.instanceof(File)
.refine(
(file) =>
file.type === "application/msword" ||
file.type === "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
{ message: "File must be a Microsoft Word document" },
),
}),
z.object({
type: z.literal("reactive-resume-json"),
file: z
.instanceof(File)
.refine((file) => file.type === "application/json", { message: "File must be a JSON file" }),
}),
z.object({
type: z.literal("reactive-resume-v4-json"),
file: z
.instanceof(File)
.refine((file) => file.type === "application/json", { message: "File must be a JSON file" }),
}),
z.object({
type: z.literal("json-resume-json"),
file: z
.instanceof(File)
.refine((file) => file.type === "application/json", { message: "File must be a JSON file" }),
}),
]);
type FormValues = z.infer<typeof formSchema>;
function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
// remove data URL prefix (e.g., "data:application/pdf;base64," or "data:application/vnd...;base64,")
resolve(result.split(",")[1]);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
export function ImportResumeDialog(_: DialogProps<"resume.import">) {
const navigate = useNavigate();
const { enabled: isAIEnabled, provider, model, apiKey, baseURL } = useAIStore();
const closeDialog = useDialogStore((state) => state.closeDialog);
const prevTypeRef = useRef<string>("");
const inputRef = useRef<HTMLInputElement>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const { mutateAsync: importResume } = useMutation(orpc.resume.import.mutationOptions());
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
type: "",
},
});
const type = useWatch({ control: form.control, name: "type" });
useEffect(() => {
if (prevTypeRef.current === type) return;
prevTypeRef.current = type;
form.resetField("file");
}, [form.resetField, type]);
const onSelectFile = () => {
if (!inputRef.current) return;
inputRef.current.click();
};
const onUploadFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
form.setValue("file", file, { shouldDirty: true });
};
const { blockEvents } = useFormBlocker(form);
const onSubmit = async (values: FormValues) => {
if (values.type === "") return;
setIsLoading(true);
const toastId = toast.loading(t`Importing your resume...`, {
description: t`This may take a few minutes, depending on the response of the AI provider. Please do not close the window or refresh the page.`,
});
try {
let data: ResumeData | undefined;
if (values.type === "json-resume-json") {
const json = await values.file.text();
const importer = new JSONResumeImporter();
data = importer.parse(json);
}
if (values.type === "reactive-resume-json") {
const json = await values.file.text();
const importer = new ReactiveResumeJSONImporter();
data = importer.parse(json);
}
if (values.type === "reactive-resume-v4-json") {
const json = await values.file.text();
const importer = new ReactiveResumeV4JSONImporter();
data = importer.parse(json);
}
if (values.type === "pdf") {
if (!isAIEnabled)
throw new Error(t`This feature requires AI Integration to be enabled. Please enable it in the settings.`);
// const arrayBuffer = await values.file.arrayBuffer();
const base64 = await fileToBase64(values.file);
data = await client.ai.parsePdf({
provider,
model,
apiKey,
baseURL,
file: { name: values.file.name, data: base64 },
});
}
if (values.type === "docx") {
if (!isAIEnabled)
throw new Error(t`This feature requires AI Integration to be enabled. Please enable it in the settings.`);
const base64 = await fileToBase64(values.file);
const mediaType =
values.file.type === "application/msword"
? ("application/msword" as const)
: ("application/vnd.openxmlformats-officedocument.wordprocessingml.document" as const);
data = await client.ai.parseDocx({
provider,
model,
apiKey,
baseURL,
mediaType,
file: { name: values.file.name, data: base64 },
});
}
if (!data) throw new Error("No data was returned from the AI provider.");
const id = await importResume({ data });
toast.success(t`Your resume has been imported successfully.`, { id: toastId, description: null });
closeDialog();
void navigate({ to: `/builder/$resumeId`, params: { resumeId: id } });
} catch (error: unknown) {
if (error instanceof Error) {
toast.error(error.message, { id: toastId, description: null });
} else {
toast.error(t`An unknown error occurred while importing your resume.`, { id: toastId, description: null });
}
} finally {
setIsLoading(false);
}
};
return (
<DialogContent {...blockEvents}>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<DownloadSimpleIcon />
<Trans>Import an existing resume</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Continue where you left off by importing an existing resume you created using Reactive Resume or any another
resume builder. Supported formats include PDF, Microsoft Word, as well as JSON files from Reactive Resume.
</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Type</Trans>
</FormLabel>
<FormControl
render={
<Combobox
showClear={false}
value={field.value}
onValueChange={field.onChange}
options={[
{ value: "reactive-resume-json", label: "Reactive Resume (JSON)" },
{ value: "reactive-resume-v4-json", label: "Reactive Resume v4 (JSON)" },
{ value: "json-resume-json", label: "JSON Resume" },
{
value: "pdf",
label: (
<div className="flex items-center gap-x-2">
PDF <Badge>{t`AI`}</Badge>
</div>
),
},
{
value: "docx",
label: (
<div className="flex items-center gap-x-2">
Microsoft Word <Badge>{t`AI`}</Badge>
</div>
),
},
]}
/>
}
/>
<FormMessage />
</FormItem>
)}
/>
<FormField
key={type}
control={form.control}
name="file"
render={({ field }) => (
<FormItem className={cn(!type && "hidden")}>
<FormControl>
<Input type="file" className="hidden" ref={inputRef} onChange={onUploadFile} />
<Button
variant="outline"
className="h-auto w-full flex-col border-dashed py-8 font-normal"
onClick={onSelectFile}
>
{field.value ? (
<>
<FileIcon weight="thin" size={32} />
<p>{field.value.name}</p>
</>
) : (
<>
<UploadSimpleIcon weight="thin" size={32} />
<Trans>Click here to select a file to import</Trans>
</>
)}
</Button>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="submit" disabled={!type || isLoading}>
{isLoading ? <Spinner /> : null}
{isLoading ? t`Importing...` : t`Import`}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
);
}