"use client"; import { useMemo, useState, useTransition } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useEffect } from "react"; import { DownloadIcon, FileTextIcon, LoaderCircleIcon, PlusIcon, Trash2Icon, TriangleAlertIcon, } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { parseResumeText, type ParseNote } from "@/lib/resume-parse"; import { readPdf, type PageBoxes } from "@/lib/resume-pdf-layout"; import { buildBaseResumeAction, importResumeAction } from "@/server/actions"; import type { ResumeImport } from "@/lib/data/me"; /** * Paste a resume, check what it read, keep what is right. * * The empty workspace is the reason people leave before they start, and until * now the only way out of it was typing a career into a form. The parse is a * draft and is shown as one: every role it found is editable and removable * here, and nothing reaches the database until the button at the bottom. * * The better path is still the conversation — an assistant reads the document * and calls import_resume — and the dialog says so, because a person who * connects Claude once never needs this screen again. */ export function ImportDialog({ hasResumes = false }: { hasResumes?: boolean }) { const router = useRouter(); const params = useSearchParams(); const [open, setOpen] = useState(false); const [text, setText] = useState(""); const [draft, setDraft] = useState(null); const [source, setSource] = useState(""); const [warnings, setWarnings] = useState([]); const [notes, setNotes] = useState([]); /** What happened to a dropped PDF, if one was dropped. */ const [pdfState, setPdfState] = useState<"idle" | "reading" | "columns" | "empty" | "failed">( "idle", ); const [pending, startTransition] = useTransition(); useEffect(() => { if (params.get("import")) setOpen(true); }, [params]); const roleCount = draft?.roles?.length ?? 0; const summary = useMemo(() => { if (!draft) return ""; const counts = [ [draft.profile?.fullName ? 1 : 0, "name"], [roleCount, "job"], [draft.education?.length ?? 0, "school"], [draft.skillGroups?.reduce((sum, group) => sum + (group.skills?.length ?? 0), 0) ?? 0, "skill"], [draft.certifications?.length ?? 0, "certification"], [draft.projects?.length ?? 0, "project"], ] as const; return counts .filter(([n]) => n > 0) .map(([n, word]) => (word === "name" ? `a name` : `${n} ${word}${n > 1 ? "s" : ""}`)) .join(", "); }, [draft, roleCount]); /** * Read a dropped PDF, or say why it cannot be read. * * pdfjs is imported here rather than at the top of the file so it is fetched * only by someone who actually has a PDF — it is a megabyte of parser, and * most people paste text. * * The worker is not optional. Setting workerSrc to "" to keep everything on * the main thread throws 'No "GlobalWorkerOptions.workerSrc" specified' — * pdfjs v4 has no no-worker mode. `new URL(..., import.meta.url)` is what * makes the bundler emit the worker as an asset and hand back its real URL, * so this keeps working under a hashed build rather than depending on a path * that happens to be right in development. */ const readPdfFile = (file: File) => { setPdfState("reading"); startTransition(async () => { try { const pdfjs = await import("pdfjs-dist"); pdfjs.GlobalWorkerOptions.workerSrc = new URL( "pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url, ).toString(); const doc = await pdfjs.getDocument({ data: new Uint8Array(await file.arrayBuffer()), isEvalSupported: false, disableFontFace: true, }).promise; const pages: PageBoxes[] = []; for (let number = 1; number <= doc.numPages; number++) { const page = await doc.getPage(number); const viewport = page.getViewport({ scale: 1 }); const content = await page.getTextContent(); pages.push({ width: viewport.width, height: viewport.height, boxes: content.items.flatMap((item) => "str" in item ? [{ text: item.str, x: item.transform[4], y: item.transform[5], width: item.width }] : [], ), }); } const reading = readPdf(pages); if (reading.kind === "read") { setText(reading.text); setPdfState("idle"); toast.success(`Read ${reading.pages} page${reading.pages === 1 ? "" : "s"} of ${file.name}`); } else if (reading.kind === "columns") { // Named, not hidden. A two-column PDF's text comes out interleaved — // a line of your jobs, a line of your sidebar — and handing that to // the parser produces a mess whose cause is invisible. setPdfState("columns"); } else { setPdfState("empty"); } } catch { setPdfState("failed"); } }); }; /** * A LinkedIn archive, unpacked in the browser. * * The zip never crosses MCP or a server action: five megabytes is 6.7MB of * base64 in a JSON-RPC body, and the 4MB bodySizeLimit in next.config.ts is * real. It is unpacked here and the same `translateLinkedInArchive` the tool * calls fills the same `draft` state the paste path fills — one translation, * two entry points, which is why it is a pure module. */ const readArchiveFile = (file: File) => { setPdfState("reading"); startTransition(async () => { try { const { unzipEntries, translateLinkedInArchive } = await import("@/lib/linkedin-archive"); const entries = await unzipEntries(new Uint8Array(await file.arrayBuffer())); if (entries.length === 0) { setPdfState("empty"); return; } const translated = translateLinkedInArchive(entries); const roles = translated.payload.roles ?? []; if (roles.length === 0 && !translated.payload.profile) { setPdfState("empty"); return; } setDraft(translated.payload); setSource(""); setWarnings(translated.report.problems); setNotes([]); setPdfState("idle"); const read = translated.report.read.reduce((total, row) => total + row.rows, 0); toast.success( `Read ${read} row${read === 1 ? "" : "s"} from ${translated.report.read.length} file${translated.report.read.length === 1 ? "" : "s"}`, ); } catch { setPdfState("failed"); } }); }; const read = () => { const body = text.trim(); if (!body) return; const result = parseResumeText(body); setDraft(result.draft); setSource(result.sourceText); setWarnings(result.warnings); setNotes(result.notes); }; /** * What the parser is unsure about at this exact field. * * The notes are keyed by where the doubt is, so they can sit under the input * rather than in a list at the top: a warning that says "check the employer * on one of these" is a warning you have to go hunting with. */ const noteAt = (path: string) => notes.find((note) => note.path === path)?.message; const commit = () => { if (!draft) return; startTransition(async () => { try { const report = await importResumeAction(draft, source); const created = report.roles.created.length; const merged = report.roles.merged; const addedBullets = merged.reduce((sum, role) => sum + role.bulletsAdded, 0); const untouched = report.roles.skipped.length; // A second import is the interesting case, and "3 were already here" // is not what happened to them: say what they gained. const parts = [ created > 0 ? `Brought in ${created} job${created === 1 ? "" : "s"}` : "", addedBullets > 0 ? `added ${addedBullets} bullet${addedBullets === 1 ? "" : "s"} to ${merged.length} you already had` : "", untouched > 0 ? `${untouched} already here, unchanged` : "", ].filter(Boolean); if (created === 0 && merged.length === 0 && untouched === 0) { // Nothing in the text was recognised as a job at all — which is not // the same as "nothing new", and is not a success. The raw text is // still kept as a note, which is worth doing; dressing that as an // import sent people back to a Me page that still looked empty with // no idea what had happened. toast.message("Saved as a note", { description: "Nothing in it was read as a job. Check the headings, or paste it to Claude and ask it to bring it in.", }); } else { toast.success(parts.length ? parts.join("; ") : "Nothing new in that one"); } setOpen(false); setText(""); setDraft(null); router.refresh(); // The point of pasting a resume is having a resume. Somebody with none // is put in front of one built from what just landed; somebody who // already has documents is topping up their material, and being thrown // into a new one would be the app deciding what they came for. if (created > 0 || merged.length > 0) { if (!hasResumes) { const base = await buildBaseResumeAction(); router.push(`/resumes/${base.id}`); } else { toast.message("Want a resume from this?", { action: { label: "Build one", onClick: () => { void buildBaseResumeAction().then((base) => router.push(`/resumes/${base.id}`)); }, }, }); } } } catch (error) { toast.error(error instanceof Error ? error.message : "Could not import that."); } }); }; const editProfile = (patch: Partial>) => setDraft((current) => current ? { ...current, profile: { ...(current.profile ?? {}), ...patch } } : current, ); const editRole = (index: number, patch: Partial[number]>) => { setDraft((current) => current ? { ...current, roles: (current.roles ?? []).map((role, position) => position === index ? { ...role, ...patch } : role, ), } : current, ); }; const editBullets = ( index: number, change: (bullets: { text: string }[]) => { text: string }[], ) => { setDraft((current) => current ? { ...current, roles: (current.roles ?? []).map((role, position) => position === index ? { ...role, bullets: change(role.bullets ?? []) } : role, ), } : current, ); }; const dropRole = (index: number) => { setDraft((current) => current ? { ...current, roles: (current.roles ?? []).filter((_, i) => i !== index) } : current, ); }; return ( Bring your history in Paste a resume or a LinkedIn export and this reads what it can. Check it before it lands — nothing is saved until you press the button. Connected to Claude, you can skip this entirely: paste the document there and ask it to import, and it reads the page properly rather than guessing at headings. {!draft ? (