From e428274e395ada4d68c955d40f24da16a7a05eee Mon Sep 17 00:00:00 2001 From: cesnimda Date: Fri, 17 Jul 2026 23:01:53 +0200 Subject: [PATCH] refactor(profile): split account and career into dedicated components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2.2 — stop backing /profile and /career from one component behind a boolean. /career now renders a dedicated CareerProfilePage; /profile keeps ProfilePage. Each hardcodes its mode and saves only its own concern (identity vs master profile) via the partial-update endpoint. This commit is the behaviour-preserving checkpoint: the two components still share the full implementation (each carries all state, only its own JSX renders). The per-component pruning that removes the other concern's state/JSX follows in subsequent commits, verified by tsc at each step. Co-Authored-By: Claude Opus 4.8 --- job-tracker-ui/src/profile-page.test.tsx | 17 +- .../src/views/CareerProfilePage.tsx | 1371 +++++++++++++++++ .../src/views/CareerWorkspacePage.tsx | 10 +- job-tracker-ui/src/views/ProfilePage.tsx | 18 +- 4 files changed, 1394 insertions(+), 22 deletions(-) create mode 100644 job-tracker-ui/src/views/CareerProfilePage.tsx diff --git a/job-tracker-ui/src/profile-page.test.tsx b/job-tracker-ui/src/profile-page.test.tsx index da79e1b..2b9ace6 100644 --- a/job-tracker-ui/src/profile-page.test.tsx +++ b/job-tracker-ui/src/profile-page.test.tsx @@ -4,6 +4,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { ToastProvider } from './toast'; import { I18nProvider } from './i18n/I18nProvider'; import ProfilePage from './views/ProfilePage'; +import CareerProfilePage from './views/CareerProfilePage'; import { api } from './api'; const createObjectURLMock = jest.fn(() => 'blob:mock-pdf'); @@ -76,16 +77,22 @@ const structuredCv = { ], }; -function renderPage(props: { careerOnly?: boolean } = {}) { +function renderWith(Component: React.ComponentType) { return render( - + , ); } +// /profile (account identity) — the CV-editing surface still renders here (display:none) because +// the two components share the same underlying implementation; these tests exercise that surface. +function renderPage() { + return renderWith(ProfilePage); +} + beforeEach(() => { mockedApi.get.mockImplementation((url: string) => { if (url === '/auth/me') { @@ -282,9 +289,9 @@ test('profile page rewrite tools use selected template and saved job context', a }); test('saving the master profile (career) persists structured cv json', async () => { - // Phase 2: the master-profile save lives on /career (careerOnly). It sends only the CV fields — - // identity is saved separately on /profile — so the payload carries profileCvStructureJson. - renderPage({ careerOnly: true }); + // Phase 2: the master-profile save lives on /career (CareerProfilePage). It sends only the CV + // fields — identity is saved separately on /profile — so the payload carries profileCvStructureJson. + renderWith(CareerProfilePage); expect(await screen.findByText(/cv ready/i)).toBeInTheDocument(); const fullNameInput = screen.getByLabelText(/full name/i); diff --git a/job-tracker-ui/src/views/CareerProfilePage.tsx b/job-tracker-ui/src/views/CareerProfilePage.tsx new file mode 100644 index 0000000..3bc563c --- /dev/null +++ b/job-tracker-ui/src/views/CareerProfilePage.tsx @@ -0,0 +1,1371 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { Accordion, AccordionDetails, AccordionSummary, Alert, Avatar, Box, Button, Chip, Dialog, DialogContent, DialogTitle, Divider, FormControl, IconButton, InputLabel, LinearProgress, MenuItem, Paper, Select, TextField, Typography } from "@mui/material"; + +import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import PhotoCameraOutlinedIcon from "@mui/icons-material/PhotoCameraOutlined"; +import ZoomInOutlinedIcon from "@mui/icons-material/ZoomInOutlined"; + +import { api, getApiErrorMessage } from "../api"; +import GoogleAuthCard from "../components/GoogleAuthCard"; +import MicrosoftAuthCard from "../components/MicrosoftAuthCard"; +import AuthStatusCard from "../components/AuthStatusCard"; +import TwoFactorSettingsCard from "../components/TwoFactorSettingsCard"; +import SessionsSettingsCard from "../components/SessionsSettingsCard"; +import CropImageDialog from "../components/CropImageDialog"; +import { useToast } from "../toast"; +import { useI18n } from "../i18n/I18nProvider"; +import { + emptyStructuredCv, + getStructuredCvFieldMetadata, + joinLines, + normalizeStructuredCv, + parseStructuredCvJson, + splitLines, + StructuredCvFieldMetadata, + StructuredCvProfile, +} from "../profileCv"; +import { JobApplication } from "../types"; + + +type CvSectionOption = "" | "Professional Summary" | "Core Skills" | "Experience Highlights" | "Selected Achievements" | "Projects"; +type CvSectionStyle = "ats-minimal" | "harvard" | "auckland" | "edinburgh" | "monarch" | "fjord"; + +type CvBuilderTone = "Concise and direct" | "Executive and polished" | "Technical and detailed" | "Warm and people-focused"; +type CvBuilderLanguage = "English" | "Norwegian" | "Spanish" | "French" | "German"; + +type ExtractionRun = { + id: number; + trigger: string; + status: string; + artifactFileName?: string; + startedAtUtc: string; + completedAtUtc?: string; + appliedAtUtc?: string; + parserVersion: string; + normalizerVersion: string; + llmPromptVersion: string; + errorMessage?: string; +}; + +type QueuedCvRunResponse = { + queued: boolean; + extractionRunId: number; + status: string; +}; + +type JobListResponse = { + items: JobApplication[]; + total: number; + page: number; + pageSize: number; +}; + +type RewriteTemplateOption = { + id: CvSectionStyle; + title: string; + eyebrow: string; + accent: string; + blurb: string; + sampleHeading: string; + sampleMeta: string; + sampleBullets: string[]; +}; + +type CvBuilderPreview = { + templateId: CvSectionStyle; + html: string; + suggestedFileName: string; + fullText: string; + rewrittenText: string; + structuredCv: StructuredCvProfile; + sectionName?: string | null; + targetRole?: string | null; + jobApplicationId?: number | null; +}; + +type PdfCarouselItem = { + templateId: CvSectionStyle; + title: string; + fileName: string; + pdfUrl?: string; + status: "loading" | "ready" | "error"; + error?: string; +}; + +type RewriteRequestPayload = { + sectionName: string | null; + style: CvSectionStyle; + templateId: CvSectionStyle; + targetRole: string | null; + jobApplicationId: number | null; + sourceText: string | null; + promptBackground: string | null; + tone: string | null; + language: string | null; +}; + +type MeResponse = { + provider?: "local" | "google" | "external"; + id?: string; + email?: string; + userName?: string; + firstName?: string; + lastName?: string; + displayName?: string; + profileCvText?: string; + profileCvStructureJson?: string; + avatarImageDataUrl?: string; + roles?: string[]; + googleLink?: { + linked: boolean; + email?: string | null; + linkedAt?: string | null; + } | null; +}; + +const CV_UPLOAD_ACCEPT = ".pdf,.docx,.txt,.md,image/png,image/jpeg,image/webp,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,text/plain,text/markdown"; +const AVATAR_UPLOAD_ACCEPT = "image/png,image/jpeg,image/webp"; +const REWRITE_TEMPLATES: RewriteTemplateOption[] = [ + { + id: "ats-minimal", + title: "ATS Minimal", + eyebrow: "Scanner-friendly", + accent: "#0f172a", + blurb: "Compact, direct, and easy for screening systems to parse.", + sampleHeading: "Senior Backend Engineer", + sampleMeta: "Acme Systems · Oslo · 2021 - Present", + sampleBullets: ["Built API workflows with measurable delivery outcomes.", "Kept skills and achievements easy to scan."] + }, + { + id: "harvard", + title: "Harvard", + eyebrow: "Traditional", + accent: "#7f1d1d", + blurb: "Formal hierarchy and restrained tone for conservative hiring flows.", + sampleHeading: "Professional Summary", + sampleMeta: "Clear structure · precise dates · credible language", + sampleBullets: ["Emphasizes polished summaries.", "Works well for broad professional roles."] + }, + { + id: "auckland", + title: "Auckland", + eyebrow: "Modern sidebar", + accent: "#0f766e", + blurb: "Sharper highlights with a more contemporary, design-forward rhythm.", + sampleHeading: "Selected Impact", + sampleMeta: "Focused strengths · compact highlights", + sampleBullets: ["Pulls skills into stronger highlight clusters.", "Good when you want a fresher feel."] + }, + { + id: "edinburgh", + title: "Edinburgh", + eyebrow: "Editorial", + accent: "#5b21b6", + blurb: "More personality and stronger section contrast without losing clarity.", + sampleHeading: "Experience Highlights", + sampleMeta: "Premium spacing · stronger visual voice", + sampleBullets: ["Useful when the CV should feel more distinctive.", "Still keeps wording grounded and factual."] + }, + { + id: "monarch", + title: "Monarch", + eyebrow: "Executive", + accent: "#7c2d12", + blurb: "High-contrast premium presentation for leadership-heavy applications.", + sampleHeading: "Executive Profile", + sampleMeta: "Leadership clarity · premium hierarchy", + sampleBullets: ["Adds more top-level summary emphasis.", "Well suited to senior strategic roles."] + }, + { + id: "fjord", + title: "Fjord", + eyebrow: "Technical", + accent: "#0f4c5c", + blurb: "Calm, high-density layout for engineering resumes and project-heavy CVs.", + sampleHeading: "Projects & Systems", + sampleMeta: "Technical depth · practical readability", + sampleBullets: ["Gives projects and skills more weight.", "Better for technical detail without chaos."] + }, +]; + +function initialsFrom(values: Array) { + const joined = values.map((x) => (x ?? "").trim()).filter(Boolean); + if (joined.length === 0) return "?"; + if (joined.length === 1) { + const parts = joined[0].split(/[\s@._-]+/).filter(Boolean); + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + return (parts[0][0] + parts[1][0]).toUpperCase(); + } + return (joined[0][0] + joined[1][0]).toUpperCase(); +} + +function confidenceTone(confidence?: number) { + if (typeof confidence !== "number") return { label: "Review", color: "default" as const }; + if (confidence >= 0.8) return { label: `High ${Math.round(confidence * 100)}%`, color: "success" as const }; + if (confidence >= 0.65) return { label: `Medium ${Math.round(confidence * 100)}%`, color: "warning" as const }; + return { label: `Low ${Math.round(confidence * 100)}%`, color: "error" as const }; +} + +function FieldReviewNote({ metadata }: { metadata?: StructuredCvFieldMetadata }) { + if (!metadata) return null; + const tone = confidenceTone(metadata.confidence); + return ( + + + {metadata.method ? : null} + {metadata.sourceBlockId ? : null} + {metadata.reviewState ? : null} + {metadata.sourceSnippet ? ( + + {metadata.sourceSnippet} + + ) : null} + + ); +} + +// CareerProfilePage backs /career: the master career profile — the single editable source of +// truth for all future generated documents. Split out from ProfilePage in Phase 2.2. It saves +// only the master-profile fields (partial update), never identity/security. +export default function CareerProfilePage() { + // Retained so the shared JSX (copied from ProfilePage) reads identically; hardcoded for /career. + const careerOnly = true; + const { toast } = useToast(); + const { t } = useI18n(); + const cvInputRef = useRef(null); + const avatarInputRef = useRef(null); + const [me, setMe] = useState(null); + const [loading, setLoading] = useState(false); + const [loadError, setLoadError] = useState(null); + const [uploadingCv, setUploadingCv] = useState(false); + const [improvingCv, setImprovingCv] = useState(false); + const [rebuildingCv, setRebuildingCv] = useState(false); + const [uploadingAvatar, setUploadingAvatar] = useState(false); + const [avatarFile, setAvatarFile] = useState(null); + const [cropOpen, setCropOpen] = useState(false); + + const [email, setEmail] = useState(""); + const [userName, setUserName] = useState(""); + const [firstName, setFirstName] = useState(""); + const [lastName, setLastName] = useState(""); + const [displayName, setDisplayName] = useState(""); + const [headline, setHeadline] = useState(""); + const [profileCvText, setProfileCvText] = useState(""); + const [rewritingSection, setRewritingSection] = useState(false); + const [cvSection, setCvSection] = useState(""); + const [cvSectionStyle, setCvSectionStyle] = useState("ats-minimal"); + const [cvSectionTargetRole, setCvSectionTargetRole] = useState(""); + const [cvPromptBackground, setCvPromptBackground] = useState(""); + const [cvTone, setCvTone] = useState("Concise and direct"); + const [cvLanguage, setCvLanguage] = useState("English"); + const [selectedRewriteJobId, setSelectedRewriteJobId] = useState(""); + const [rewritePreview, setRewritePreview] = useState(null); + const [rewritePreviewTemplate, setRewritePreviewTemplate] = useState(null); + const [pdfCarousel, setPdfCarousel] = useState([]); + const [activePdfIndex, setActivePdfIndex] = useState(0); + const [buildingPdfDeck, setBuildingPdfDeck] = useState(false); + const [downloadingPdf, setDownloadingPdf] = useState(false); + const [savedJobs, setSavedJobs] = useState([]); + const [parsingCvSections, setParsingCvSections] = useState(false); + const [reprocessingCv, setReprocessingCv] = useState(false); + const [structuredCv, setStructuredCv] = useState(emptyStructuredCv()); + const [extractionRuns, setExtractionRuns] = useState([]); + const runStatusRef = useRef>({}); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + + // Keep a ref to the latest carousel so the unmount cleanup can revoke the + // outstanding preview object URLs without re-running on every change. + const pdfCarouselRef = useRef([]); + useEffect(() => { + pdfCarouselRef.current = pdfCarousel; + }, [pdfCarousel]); + + useEffect(() => { + // Revoke any remaining preview object URLs only on unmount. Per-change + // revocation is already handled explicitly in savePdfToCarousel (replace) and + // resetPdfCarousel (clear); doing it here on every pdfCarousel change revoked + // URLs that were still referenced by other items in the deck, breaking their + // previews. + return () => { + pdfCarouselRef.current.forEach((item) => { + if (item.pdfUrl) { + window.URL.revokeObjectURL(item.pdfUrl); + } + }); + }; + }, []); + + const loadProfile = useCallback(async () => { + setLoading(true); + try { + const [profileResponse, runsResponse, jobsResponse] = await Promise.all([ + api.get("/auth/me"), + api.get("/profile-cv/runs").catch(() => ({ data: [] as ExtractionRun[] } as any)), + api.get("/jobapplications", { params: { page: 1, pageSize: 100, sortBy: "dateApplied", sortDir: "desc" } }).catch(() => ({ data: { items: [], total: 0, page: 1, pageSize: 100 } } as any)), + ]); + const r = profileResponse; + setMe(r.data); + setEmail(r.data?.email ?? ""); + setUserName(r.data?.userName ?? ""); + setFirstName(r.data?.firstName ?? ""); + setLastName(r.data?.lastName ?? ""); + setDisplayName(r.data?.displayName ?? ""); + setProfileCvText(r.data?.profileCvText ?? ""); + setStructuredCv(parseStructuredCvJson(r.data?.profileCvStructureJson)); + setExtractionRuns(runsResponse.data ?? []); + setSavedJobs(jobsResponse.data?.items ?? []); + setHeadline(window.localStorage.getItem("profileHeadline") ?? ""); + setLoadError(null); + } catch (error: any) { + setMe(null); + setExtractionRuns([]); + setSavedJobs([]); + setLoadError(String(error?.response?.data || error?.message || "Unable to load profile right now.")); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void loadProfile(); + }, [loadProfile]); + + useEffect(() => { + const activeRuns = extractionRuns.filter((run) => run.status === "queued" || run.status === "running"); + if (activeRuns.length === 0) return; + + const timer = window.setInterval(() => { + void loadProfile(); + }, 4000); + + return () => window.clearInterval(timer); + }, [extractionRuns, loadProfile]); + + useEffect(() => { + const previous = runStatusRef.current; + for (const run of extractionRuns) { + const prior = previous[run.id]; + if ((prior === "queued" || prior === "running") && run.status === "applied") { + toast(`CV ${run.trigger} completed.`, "success"); + } + if ((prior === "queued" || prior === "running") && run.status === "failed") { + toast(run.errorMessage || `CV ${run.trigger} failed.`, "error"); + } + previous[run.id] = run.status; + } + }, [extractionRuns, toast]); + + const initials = useMemo(() => initialsFrom([me?.displayName, me?.firstName, me?.lastName, me?.userName, me?.email]), [me]); + const isLocal = me?.provider === "local"; + const fullName = [me?.firstName, me?.lastName].filter(Boolean).join(" "); + const cvWordCount = profileCvText.trim() ? profileCvText.trim().split(/\s+/).length : 0; + + const providerLabel = me?.provider === "local" ? t("profileLocalAccount") : me?.provider === "google" ? t("profileGoogleSession") : t("profileExternalSession"); + const googleLabel = me?.googleLink?.linked + ? me.googleLink.email + ? t("profileGoogleLinkedWithEmail", { email: me.googleLink.email }) + : t("profileGoogleLinked") + : t("profileGoogleNotLinked"); + const cvLabel = profileCvText.trim() ? t("profileCvReady", { count: cvWordCount }) : t("profileCvMissing"); + const latestRun = extractionRuns[0]; + const selectedRewriteTemplate = REWRITE_TEMPLATES.find((option) => option.id === cvSectionStyle) ?? REWRITE_TEMPLATES[0]; + const selectedRewriteJob = savedJobs.find((job) => String(job.id) === selectedRewriteJobId) ?? null; + const rewriteReady = Boolean(rewritePreview?.html && rewritePreview.fullText.trim()); + const activePdfItem = pdfCarousel[activePdfIndex] ?? null; + + const releasePdfCarousel = useCallback((items: PdfCarouselItem[]) => { + items.forEach((item) => { + if (item.pdfUrl) { + window.URL.revokeObjectURL(item.pdfUrl); + } + }); + }, []); + + const buildRewritePayload = useCallback((templateId: CvSectionStyle): RewriteRequestPayload => ({ + sectionName: cvSection || null, + style: templateId, + templateId, + targetRole: cvSectionTargetRole.trim() || null, + jobApplicationId: selectedRewriteJob ? selectedRewriteJob.id : null, + sourceText: profileCvText.trim() || null, + promptBackground: cvPromptBackground.trim() || null, + tone: cvTone, + language: cvLanguage, + }), [cvLanguage, cvPromptBackground, cvSection, cvSectionTargetRole, cvTone, profileCvText, selectedRewriteJob]); + + const resetPdfCarousel = useCallback(() => { + setPdfCarousel((current) => { + releasePdfCarousel(current); + return []; + }); + setActivePdfIndex(0); + }, [releasePdfCarousel]); + + const savePdfToCarousel = useCallback(async (templateId: CvSectionStyle, download = false) => { + const template = REWRITE_TEMPLATES.find((option) => option.id === templateId) ?? REWRITE_TEMPLATES[0]; + const payload = buildRewritePayload(templateId); + const response = await api.post("/profile-cv/export-pdf", payload, { responseType: "blob" }); + const blob = new Blob([response.data], { type: "application/pdf" }); + const url = window.URL.createObjectURL(blob); + const item: PdfCarouselItem = { + templateId, + title: template.title, + fileName: rewritePreview?.suggestedFileName || `${templateId}-cv.pdf`, + pdfUrl: url, + status: "ready", + }; + + setPdfCarousel((current) => { + const existing = current.find((entry) => entry.templateId === templateId); + if (existing?.pdfUrl) { + window.URL.revokeObjectURL(existing.pdfUrl); + } + const next = existing + ? current.map((entry) => (entry.templateId === templateId ? item : entry)) + : [...current, item]; + setActivePdfIndex(next.findIndex((entry) => entry.templateId === templateId)); + return next; + }); + + if (download) { + const link = document.createElement("a"); + link.href = url; + link.download = item.fileName; + document.body.appendChild(link); + link.click(); + link.remove(); + } + + return item; + }, [buildRewritePayload, rewritePreview?.suggestedFileName]); + + const buildPdfCarousel = useCallback(async () => { + setBuildingPdfDeck(true); + resetPdfCarousel(); + const orderedTemplates = [selectedRewriteTemplate.id, ...REWRITE_TEMPLATES.map((option) => option.id).filter((id) => id !== selectedRewriteTemplate.id)]; + const seedItems = orderedTemplates.map((templateId) => ({ + templateId, + title: REWRITE_TEMPLATES.find((option) => option.id === templateId)?.title ?? templateId, + fileName: `${templateId}-cv.pdf`, + status: "loading" as const, + })); + setPdfCarousel(seedItems); + setActivePdfIndex(0); + + for (const templateId of orderedTemplates) { + try { + const item = await savePdfToCarousel(templateId, false); + setPdfCarousel((current) => current.map((entry) => entry.templateId === templateId ? item : entry)); + } catch (error: any) { + const message = getApiErrorMessage(error, `Failed to generate the ${templateId} PDF preview.`); + setPdfCarousel((current) => current.map((entry) => entry.templateId === templateId ? { ...entry, status: "error", error: message } : entry)); + } + } + + setBuildingPdfDeck(false); + }, [resetPdfCarousel, savePdfToCarousel, selectedRewriteTemplate.id]); + + useEffect(() => { + resetPdfCarousel(); + }, [rewritePreview?.fullText, rewritePreview?.templateId, rewritePreview?.targetRole, resetPdfCarousel]); + + return ( + + { + setCropOpen(false); + setAvatarFile(null); + }} + onSave={async (blob) => { + const file = new File([blob], "avatar.png", { type: "image/png" }); + const formData = new FormData(); + formData.append("file", file); + setUploadingAvatar(true); + try { + const response = await api.post<{ avatarImageDataUrl?: string }>("/auth/avatar", formData, { + headers: { "Content-Type": "multipart/form-data" }, + }); + setMe((prev) => (prev ? { ...prev, avatarImageDataUrl: response.data?.avatarImageDataUrl ?? prev.avatarImageDataUrl } : prev)); + setCropOpen(false); + setAvatarFile(null); + toast(t("profileImageUpdated"), "success"); + } catch (e: any) { + toast(String(e?.response?.data || e?.message || t("profileImageUploadFailed")), "error"); + } finally { + setUploadingAvatar(false); + } + }} + /> + + {loadError ? ( + void loadProfile()}>Retry}> + Unable to load profile. + {loadError} + + ) : null} + + + + + {initials} + + { + const file = event.target.files?.[0] ?? null; + event.target.value = ""; + if (!file) return; + setAvatarFile(file); + setCropOpen(true); + }} + /> + + {me?.avatarImageDataUrl ? ( + + ) : null} + + + + + {careerOnly ? "Master career profile" : t("profileTitle")} + + {me?.userName || me?.displayName || fullName || me?.email || "-"} + {headline || t("profileHeadlinePlaceholder")} + + + + + + + + + + {!careerOnly ? <> + + + + : null} + + + {!careerOnly ? + {t("profileAccountSection")} + {!isLocal ? ( + + {t("profileReadOnlyInfo")} + + ) : null} + : null} + + {!careerOnly ? <> + setDisplayName(e.target.value)} disabled={!isLocal} fullWidth /> + setUserName(e.target.value)} disabled={!isLocal} fullWidth /> + setFirstName(e.target.value)} disabled={!isLocal} fullWidth /> + setLastName(e.target.value)} disabled={!isLocal} fullWidth /> + setEmail(e.target.value)} disabled={!isLocal} fullWidth /> + setHeadline(e.target.value)} + helperText={t("profileHeadlineHelp")} + fullWidth + /> + : null} + + + + + {t("profileMasterCv")} + + {t("profileMasterCvBody")} + + + + { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) return; + const formData = new FormData(); + formData.append("file", file); + setUploadingCv(true); + try { + await api.post("/profile-cv/upload", formData, { headers: { "Content-Type": "multipart/form-data" } }); + await loadProfile(); + toast(t("profileCvUploaded"), "success"); + } catch (e: any) { + toast(String(e?.response?.data || e?.message || t("profileCvUploadFailed")), "error"); + } finally { + setUploadingCv(false); + } + }} + /> + + + + + + + {uploadingCv ? : null} + + {t("profileCvStructuredDefaultHint")} + + + }> + + + {t("profileCvRawPanelTitle")} + {t("profileCvRawPanelHelp")} + + + + + + setProfileCvText(e.target.value)} + helperText={t("profileCvTextHelp")} + multiline + minRows={12} + disabled={!isLocal} + fullWidth + /> + + + + + + + + + {t("profileCvExtractionHistory")} + {t("profileCvExtractionHistoryHelp")} + + {structuredCv.metadata.profileVersion ? : null} + + {latestRun ? ( + + {extractionRuns.map((run) => ( + + + {run.trigger} + + + {run.id === structuredCv.metadata.appliedExtractionRunId ? : null} + + + {run.artifactFileName || t("profileCvNoStoredArtifact")} + + {run.parserVersion} · {new Date(run.startedAtUtc).toLocaleString()} + + {run.errorMessage ? ( + + {run.errorMessage} + + ) : null} + + ))} + + ) : ( + {t("profileCvExtractionHistoryEmpty")} + )} + + + + + {t("profileCvStructureOverview")} + {t("profileCvStructureOverviewHelp")} + + + + {structuredCv.sections.length > 0 ? ( + + {structuredCv.sections.map((section) => { + const safeContent = typeof section.content === "string" ? section.content : ""; + const safeWordCount = Number.isFinite(Number(section.wordCount)) ? Number(section.wordCount) : (safeContent.trim() ? safeContent.trim().split(/\s+/).length : 0); + return ( + + + {section.name} + + + {safeContent.slice(0, 280)}{safeContent.length > 280 ? "…" : ""} + + ); + })} + + ) : ( + {t("profileCvStructureEmpty")} + )} + + + + {t("profileCvStructuredEditor")} + {t("profileCvStructuredEditorHelp")} + + + + + setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, fullName: e.target.value || undefined } }))} fullWidth /> + + + + setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, headline: e.target.value || undefined } }))} fullWidth /> + + + + setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, email: e.target.value || undefined } }))} fullWidth /> + + + + setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, phone: e.target.value || undefined } }))} fullWidth /> + + + + setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, location: e.target.value || undefined } }))} fullWidth /> + + + setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, website: e.target.value || undefined } }))} fullWidth /> + setStructuredCv((prev) => ({ ...prev, contact: { ...prev.contact, linkedIn: e.target.value || undefined } }))} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} /> + + + + + setStructuredCv((prev) => ({ ...prev, summary: splitLines(e.target.value) }))} + helperText={t("profileCvStructuredListHelp")} + multiline + minRows={5} + fullWidth + /> + + + + setStructuredCv((prev) => ({ ...prev, skills: splitLines(e.target.value) }))} + helperText={t("profileCvStructuredListHelp")} + multiline + minRows={5} + fullWidth + /> + + + + setStructuredCv((prev) => ({ ...prev, interests: splitLines(e.target.value) }))} + helperText={t("profileCvStructuredListHelp")} + multiline + minRows={4} + fullWidth + /> + + + + + + + {t("profileCvStructuredLanguages")} + + + + {structuredCv.languages.length === 0 ? {t("profileCvStructuredEmpty")} : null} + {structuredCv.languages.map((language, index) => ( + + + setStructuredCv((prev) => ({ ...prev, languages: prev.languages.map((entry, entryIndex) => entryIndex === index ? { ...entry, name: e.target.value || undefined } : entry) }))} fullWidth /> + setStructuredCv((prev) => ({ ...prev, languages: prev.languages.map((entry, entryIndex) => entryIndex === index ? { ...entry, level: e.target.value || undefined } : entry) }))} fullWidth /> + setStructuredCv((prev) => ({ ...prev, languages: prev.languages.map((entry, entryIndex) => entryIndex === index ? { ...entry, notes: e.target.value || undefined } : entry) }))} fullWidth /> + + + + ))} + + + + + {t("profileCvStructuredJobs")} + + + {structuredCv.jobs.length === 0 ? {t("profileCvStructuredEmpty")} : null} + {structuredCv.jobs.map((job, index) => ( + + + setStructuredCv((prev) => ({ ...prev, jobs: prev.jobs.map((entry, entryIndex) => entryIndex === index ? { ...entry, title: e.target.value || undefined } : entry) }))} fullWidth /> + setStructuredCv((prev) => ({ ...prev, jobs: prev.jobs.map((entry, entryIndex) => entryIndex === index ? { ...entry, company: e.target.value || undefined } : entry) }))} fullWidth /> + setStructuredCv((prev) => ({ ...prev, jobs: prev.jobs.map((entry, entryIndex) => entryIndex === index ? { ...entry, location: e.target.value || undefined } : entry) }))} fullWidth /> + + setStructuredCv((prev) => ({ ...prev, jobs: prev.jobs.map((entry, entryIndex) => entryIndex === index ? { ...entry, start: e.target.value || undefined } : entry) }))} fullWidth /> + setStructuredCv((prev) => ({ ...prev, jobs: prev.jobs.map((entry, entryIndex) => entryIndex === index ? { ...entry, end: e.target.value || undefined, isCurrent: /present|current/i.test(e.target.value) || entry.isCurrent } : entry) }))} fullWidth /> + + setStructuredCv((prev) => ({ ...prev, jobs: prev.jobs.map((entry, entryIndex) => entryIndex === index ? { ...entry, bullets: splitLines(e.target.value) } : entry) }))} helperText={t("profileCvStructuredListHelp")} multiline minRows={5} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} /> + setStructuredCv((prev) => ({ ...prev, jobs: prev.jobs.map((entry, entryIndex) => entryIndex === index ? { ...entry, skills: splitLines(e.target.value) } : entry) }))} helperText={t("profileCvStructuredListHelp")} multiline minRows={3} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} /> + + + + + + ))} + + + + + {t("profileCvStructuredEducation")} + + + {structuredCv.education.length === 0 ? {t("profileCvStructuredEmpty")} : null} + {structuredCv.education.map((education, index) => ( + + + setStructuredCv((prev) => ({ ...prev, education: prev.education.map((entry, entryIndex) => entryIndex === index ? { ...entry, qualification: e.target.value || undefined } : entry) }))} fullWidth /> + setStructuredCv((prev) => ({ ...prev, education: prev.education.map((entry, entryIndex) => entryIndex === index ? { ...entry, institution: e.target.value || undefined } : entry) }))} fullWidth /> + setStructuredCv((prev) => ({ ...prev, education: prev.education.map((entry, entryIndex) => entryIndex === index ? { ...entry, location: e.target.value || undefined } : entry) }))} fullWidth /> + + setStructuredCv((prev) => ({ ...prev, education: prev.education.map((entry, entryIndex) => entryIndex === index ? { ...entry, start: e.target.value || undefined } : entry) }))} fullWidth /> + setStructuredCv((prev) => ({ ...prev, education: prev.education.map((entry, entryIndex) => entryIndex === index ? { ...entry, end: e.target.value || undefined } : entry) }))} fullWidth /> + + setStructuredCv((prev) => ({ ...prev, education: prev.education.map((entry, entryIndex) => entryIndex === index ? { ...entry, details: splitLines(e.target.value) } : entry) }))} helperText={t("profileCvStructuredListHelp")} multiline minRows={4} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} /> + + + + + + ))} + + + + + {t("profileCvStructuredOtherSections")} + + + {structuredCv.otherSections.length === 0 ? {t("profileCvStructuredEmpty")} : null} + {structuredCv.otherSections.map((section, index) => ( + + + setStructuredCv((prev) => ({ ...prev, otherSections: prev.otherSections.map((entry, entryIndex) => entryIndex === index ? { ...entry, title: e.target.value || undefined } : entry) }))} fullWidth /> + + setStructuredCv((prev) => ({ ...prev, otherSections: prev.otherSections.map((entry, entryIndex) => entryIndex === index ? { ...entry, items: splitLines(e.target.value) } : entry) }))} helperText={t("profileCvStructuredListHelp")} multiline minRows={4} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} /> + + + ))} + + + + + + Template-driven CV builder + + Choose a template, optionally target one section, and tailor the output toward a saved job or free-text role target. The preview below renders the actual PDF layout before you apply it. + + + + + {selectedRewriteJob ? : null} + {rewriteReady ? : null} + + + + + + + + + + {selectedRewriteTemplate.eyebrow} + {selectedRewriteTemplate.title} + {selectedRewriteTemplate.blurb} + + setRewritePreviewTemplate(selectedRewriteTemplate)}> + + + + + + + {selectedRewriteTemplate.eyebrow} + {selectedRewriteTemplate.sampleHeading} + {selectedRewriteTemplate.sampleMeta} + + + Preview of the generated PDF style + {selectedRewriteTemplate.sampleBullets.map((bullet) => ( + • {bullet} + ))} + + + + + + + + + + + Choose a visual direction before generating + + {REWRITE_TEMPLATES.map((option) => { + const selected = option.id === cvSectionStyle; + return ( + setCvSectionStyle(option.id)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setCvSectionStyle(option.id); + } + }} + sx={{ + p: 1.15, + borderRadius: 3, + cursor: "pointer", + border: "1px solid", + borderColor: selected ? "primary.main" : "divider", + background: selected ? `linear-gradient(180deg, ${option.accent}10 0%, rgba(255,255,255,0.98) 100%)` : "rgba(255,255,255,0.84)", + boxShadow: selected ? "0 0 0 1px rgba(25,118,210,0.16), 0 10px 24px rgba(15,23,42,0.08)" : "0 6px 16px rgba(15,23,42,0.04)", + transition: "transform 120ms ease, box-shadow 120ms ease, border-color 120ms ease", + '&:hover': { transform: 'translateY(-1px)' }, + }} + > + + + {option.eyebrow} + + {option.sampleHeading} + {option.sampleMeta} + + + + + + {option.title} + {option.blurb} + + {selected ? : null} + + + + + ); + })} + + + + + + + + setCvPromptBackground(e.target.value)} + fullWidth + multiline + minRows={4} + helperText="Describe your strengths, preferred emphasis, industry background, or the angle you want the AI to lean into." + sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} + /> + + {t("profileCvSectionLabel")} + + + setCvSectionTargetRole(e.target.value)} + fullWidth + helperText={selectedRewriteJob ? `Using saved job context: ${selectedRewriteJob.jobTitle}` : "Leave empty to let the selected job drive tailoring."} + /> + + Language + + + + Tone + + + + Saved job context + + + + + + + Builder output + + {selectedRewriteTemplate.title} · {rewritePreview?.targetRole || selectedRewriteJob?.jobTitle || cvSectionTargetRole || "General reuse"} + + + + + + + + + + + + + {rewritePreview?.sectionName || "Full rewritten CV text"} + {rewriteReady ? : null} + + + {rewriteReady ? ( + {rewritePreview?.sectionName ? rewritePreview?.rewrittenText : rewritePreview?.fullText} + ) : ( + Choose a template and generate a live preview. The builder will show rewritten content here and render the PDF layout beside it. + )} + + + + + + + + + + + PDF carousel + + {activePdfItem?.title ? `${activePdfItem.title} · generated PDF` : `${selectedRewriteTemplate.title} · print-ready layout`} + + + {activePdfItem?.fileName ? : rewriteReady ? : null} + + + {pdfCarousel.length > 0 ? ( + <> + + {pdfCarousel.map((item, index) => ( + + ))} + + + {activePdfItem?.status === "ready" && activePdfItem.pdfUrl ? ( +