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);
+ }}
+ />
+ } disabled={!isLocal || uploadingAvatar} onClick={() => avatarInputRef.current?.click()}>
+ {uploadingAvatar ? t("profileUploading") : t("profileChangeImage")}
+
+ {me?.avatarImageDataUrl ? (
+ }
+ disabled={!isLocal || uploadingAvatar}
+ onClick={async () => {
+ setUploadingAvatar(true);
+ try {
+ await api.delete("/auth/avatar");
+ setMe((prev) => (prev ? { ...prev, avatarImageDataUrl: undefined } : prev));
+ toast(t("profileImageRemoved"), "success");
+ } catch (e: any) {
+ toast(String(e?.response?.data || e?.message || t("profileImageRemoveFailed")), "error");
+ } finally {
+ setUploadingAvatar(false);
+ }
+ }}
+ >
+ {t("profileRemoveImage")}
+
+ ) : 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 ? (
+
+ ) : activePdfItem?.status === "error" ? (
+
+
+ {activePdfItem.title} PDF unavailable
+ {activePdfItem.error || "This template could not be rendered as a PDF right now."}
+
+
+ ) : (
+
+
+ {activePdfItem?.title || "Preparing PDF preview"}
+
+ {buildingPdfDeck ? "The carousel is generating PDFs across the current template set." : "Generate the PDF carousel to inspect rendered export files without leaving the page."}
+
+
+
+ )}
+
+ >
+ ) : (
+
+ {rewriteReady ? (
+
+ ) : (
+
+
+ The visual preview uses the same server-rendered HTML that the PDF exporter prints. Build a preview to inspect layout, then generate the PDF carousel to compare rendered files template by template.
+
+
+ )}
+
+ )}
+
+
+
+
+
+
+
+ {cvWordCount} words
+
+
+ {t("profileCvPreferredUploads")}
+
+
+
+
+
+
+
+
+ {!careerOnly ?
+
+ {t("profileChangePassword")}
+ {!isLocal ? {t("profilePasswordLocalOnly")} : null}
+ : null}
+
+ {!careerOnly ? <>
+ setCurrentPassword(e.target.value)} disabled={!isLocal} fullWidth />
+ setNewPassword(e.target.value)} disabled={!isLocal} fullWidth />
+
+
+
+
+ > : null}
+
+
+ {!careerOnly && isLocal ? : null}
+ {!careerOnly && isLocal ? : null}
+
+ );
+}
diff --git a/job-tracker-ui/src/views/CareerWorkspacePage.tsx b/job-tracker-ui/src/views/CareerWorkspacePage.tsx
index 433c8ef..4bae354 100644
--- a/job-tracker-ui/src/views/CareerWorkspacePage.tsx
+++ b/job-tracker-ui/src/views/CareerWorkspacePage.tsx
@@ -2,11 +2,11 @@ import React from "react";
import { Alert, Box, Paper, Typography } from "@mui/material";
-import ProfilePage from "./ProfilePage";
+import CareerProfilePage from "./CareerProfilePage";
-// Phase 2: /career is the Career Workspace — the master career profile, which is the single source
-// of truth for all generated documents. The CV Builder is deliberately NOT here yet (Phase 4);
-// Phase 2 only establishes the master profile. The previously-inert "CV Builder" tab was removed.
+// Phase 2 / 2.2: /career is the Career Workspace — the master career profile, the single source of
+// truth for all generated documents. It is a dedicated component (CareerProfilePage), no longer a
+// careerOnly fork of ProfilePage. The CV Builder is deliberately NOT here yet (Phase 4).
export default function CareerWorkspacePage() {
return (
@@ -20,7 +20,7 @@ export default function CareerWorkspacePage() {
Your master profile is the source of truth. Job-specific CV drafts remain separate and never overwrite it.
-
+
);
diff --git a/job-tracker-ui/src/views/ProfilePage.tsx b/job-tracker-ui/src/views/ProfilePage.tsx
index 183fff4..14c61f6 100644
--- a/job-tracker-ui/src/views/ProfilePage.tsx
+++ b/job-tracker-ui/src/views/ProfilePage.tsx
@@ -226,18 +226,12 @@ function FieldReviewNote({ metadata }: { metadata?: StructuredCvFieldMetadata })
);
}
-// careerOnly splits the two Phase 2 surfaces this component still backs:
-// false -> /profile: account identity + security + preferences
-// true -> /career: the master career profile (source of truth)
-// Fully separating this into two components is roadmap 2.2; for now the fork keeps each route's
-// content — and its Save payload — cleanly scoped.
-export default function ProfilePage({
- careerOnly = false,
- onMasterCvAvailabilityChange,
-}: {
- careerOnly?: boolean;
- onMasterCvAvailabilityChange?: (hasMasterCv: boolean) => void;
-}) {
+// ProfilePage backs /profile: account identity + security + preferences. The master career
+// profile lives in CareerProfilePage (/career). Split in Phase 2.2. Saves only identity fields
+// (partial update), never the master profile.
+export default function ProfilePage() {
+ // Retained so the shared JSX reads identically; hardcoded for /profile.
+ const careerOnly = false;
const { toast } = useToast();
const { t } = useI18n();
const cvInputRef = useRef(null);