feat: complete phase 2 UX improvements
This commit is contained in:
@@ -1,11 +1,10 @@
|
||||
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 { Accordion, AccordionDetails, AccordionSummary, Alert, Avatar, Box, Button, Checkbox, Chip, Divider, FormControlLabel, LinearProgress, Paper, 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";
|
||||
@@ -37,15 +36,8 @@ import {
|
||||
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;
|
||||
@@ -60,63 +52,28 @@ type ExtractionRun = {
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
type CvImportDiff = {
|
||||
totalAdded: number;
|
||||
totalUpdated: number;
|
||||
totalLowConfidence: number;
|
||||
hasChanges: boolean;
|
||||
categories: Array<{
|
||||
category: string;
|
||||
added: Array<{ id: string; label: string; confidence: string }>;
|
||||
updated: Array<{ id: string; label: string; confidence: string }>;
|
||||
unchangedCount: number;
|
||||
lowConfidenceCount: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
type CvRunDiffResponse = { runId: number; status: string; diff: CvImportDiff };
|
||||
|
||||
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;
|
||||
@@ -138,69 +95,6 @@ type MeResponse = {
|
||||
|
||||
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<string | undefined>) {
|
||||
const joined = values.map((x) => (x ?? "").trim()).filter(Boolean);
|
||||
if (joined.length === 0) return "?";
|
||||
@@ -242,31 +136,12 @@ export default function CareerProfilePage() {
|
||||
|
||||
const [headline, setHeadline] = useState("");
|
||||
const [profileCvText, setProfileCvText] = useState("");
|
||||
const [rewritingSection, setRewritingSection] = useState(false);
|
||||
const [cvSection, setCvSection] = useState<CvSectionOption>("");
|
||||
const [cvSectionStyle, setCvSectionStyle] = useState<CvSectionStyle>("ats-minimal");
|
||||
const [cvSectionTargetRole, setCvSectionTargetRole] = useState("");
|
||||
const [cvPromptBackground, setCvPromptBackground] = useState("");
|
||||
const [cvTone, setCvTone] = useState<CvBuilderTone>("Concise and direct");
|
||||
const [cvLanguage, setCvLanguage] = useState<CvBuilderLanguage>("English");
|
||||
const [selectedRewriteJobId, setSelectedRewriteJobId] = useState<string>("");
|
||||
const [rewritePreview, setRewritePreview] = useState<CvBuilderPreview | null>(null);
|
||||
const [rewritePreviewTemplate, setRewritePreviewTemplate] = useState<RewriteTemplateOption | null>(null);
|
||||
const [pdfCarousel, setPdfCarousel] = useState<PdfCarouselItem[]>([]);
|
||||
const [activePdfIndex, setActivePdfIndex] = useState(0);
|
||||
const [buildingPdfDeck, setBuildingPdfDeck] = useState(false);
|
||||
const [downloadingPdf, setDownloadingPdf] = useState(false);
|
||||
const [savedJobs, setSavedJobs] = useState<JobApplication[]>([]);
|
||||
const [parsingCvSections, setParsingCvSections] = useState(false);
|
||||
const [reprocessingCv, setReprocessingCv] = useState(false);
|
||||
const [structuredCv, setStructuredCv] = useState<StructuredCvProfile>(emptyStructuredCv());
|
||||
const [completeness, setCompleteness] = useState<CareerCompleteness | null>(null);
|
||||
const [versions, setVersions] = useState<CareerVersion[]>([]);
|
||||
// Phase 1 increment 2: "Profile sections" (CV structure overview) and the "Template-driven CV
|
||||
// builder" are duplicate/implementation concepts — the real CV Builder lives at /career/builder.
|
||||
// Hidden from the default Career Profile workflow behind this toggle; the underlying functionality
|
||||
// (and its tests) stay intact and reachable. Removal is planned for a later phase — see
|
||||
// docs/career-workspace-ux-refactor.md.
|
||||
// The raw import/section parser remains available as an advanced recovery tool.
|
||||
const [showAdvancedCvTools, setShowAdvancedCvTools] = useState(false);
|
||||
|
||||
const loadVersions = useCallback(async () => {
|
||||
@@ -293,53 +168,31 @@ export default function CareerProfilePage() {
|
||||
}
|
||||
}, [loadVersions, t, toast]);
|
||||
const [extractionRuns, setExtractionRuns] = useState<ExtractionRun[]>([]);
|
||||
const [runDiffs, setRunDiffs] = useState<Record<number, CvImportDiff>>({});
|
||||
const [reviewingRunId, setReviewingRunId] = useState<number | null>(null);
|
||||
const [acceptedLowConfidenceIds, setAcceptedLowConfidenceIds] = useState<Record<number, string[]>>({});
|
||||
const runStatusRef = useRef<Record<number, string>>({});
|
||||
|
||||
// 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<PdfCarouselItem[]>([]);
|
||||
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 {
|
||||
// /career reads the structured profile from the relational source of truth (/career/profile);
|
||||
// /auth/me still provides the account row (avatar, provider chips) shown in the header.
|
||||
const [careerResponse, meResponse, runsResponse, jobsResponse] = await Promise.all([
|
||||
const [careerResponse, meResponse, runsResponse] = await Promise.all([
|
||||
api.get<CareerProfileResponse>("/career/profile"),
|
||||
api.get<MeResponse>("/auth/me"),
|
||||
api.get<ExtractionRun[]>("/profile-cv/runs").catch(() => ({ data: [] as ExtractionRun[] } as any)),
|
||||
api.get<JobListResponse>("/jobapplications", { params: { page: 1, pageSize: 100, sortBy: "dateApplied", sortDir: "desc" } }).catch(() => ({ data: { items: [], total: 0, page: 1, pageSize: 100 } } as any)),
|
||||
]);
|
||||
setMe(meResponse.data);
|
||||
setProfileCvText(careerResponse.data?.cvText ?? "");
|
||||
setStructuredCv(normalizeStructuredCv(careerResponse.data?.profile ?? emptyStructuredCv()));
|
||||
setCompleteness(careerResponse.data?.completeness ?? null);
|
||||
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);
|
||||
@@ -362,12 +215,23 @@ export default function CareerProfilePage() {
|
||||
return () => window.clearInterval(timer);
|
||||
}, [extractionRuns, loadProfile]);
|
||||
|
||||
useEffect(() => {
|
||||
const pending = extractionRuns.filter((run) => run.status === "pending_review" && !runDiffs[run.id]);
|
||||
if (pending.length === 0) return;
|
||||
void Promise.all(pending.map((run) => api.get<CvRunDiffResponse>(`/profile-cv/runs/${run.id}/diff`)))
|
||||
.then((responses) => setRunDiffs((current) => Object.fromEntries([
|
||||
...Object.entries(current),
|
||||
...responses.map((response) => [response.data.runId, response.data.diff]),
|
||||
])))
|
||||
.catch(() => undefined);
|
||||
}, [extractionRuns, runDiffs]);
|
||||
|
||||
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 === "pending_review") {
|
||||
toast(`CV ${run.trigger} is ready to review.`, "info");
|
||||
}
|
||||
if ((prior === "queued" || prior === "running") && run.status === "failed") {
|
||||
toast(run.errorMessage || `CV ${run.trigger} failed.`, "error");
|
||||
@@ -393,107 +257,6 @@ export default function CareerProfilePage() {
|
||||
: 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 (
|
||||
<Paper sx={{ mt: 0, p: 2.5, borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
|
||||
<ProfileCompleteness
|
||||
@@ -595,6 +358,7 @@ export default function CareerProfilePage() {
|
||||
<Chip label={providerLabel} color={me?.provider === "local" ? "primary" : "default"} />
|
||||
<Chip label={googleLabel} color={me?.googleLink?.linked ? "success" : "default"} variant={me?.googleLink?.linked ? "filled" : "outlined"} />
|
||||
<Chip label={cvLabel} color={profileCvText.trim() ? "success" : "warning"} variant={profileCvText.trim() ? "filled" : "outlined"} />
|
||||
<Button size="small" variant="contained" href="/career/builder">Open CV Builder</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -626,7 +390,7 @@ export default function CareerProfilePage() {
|
||||
try {
|
||||
await api.post<QueuedCvRunResponse>("/profile-cv/upload", formData, { headers: { "Content-Type": "multipart/form-data" } });
|
||||
await loadProfile();
|
||||
toast(t("profileCvUploaded"), "success");
|
||||
toast("CV extracted. Review the changes before applying them.", "info");
|
||||
} catch (e: any) {
|
||||
toast(String(e?.response?.data || e?.message || t("profileCvUploadFailed")), "error");
|
||||
} finally {
|
||||
@@ -753,6 +517,61 @@ export default function CareerProfilePage() {
|
||||
{run.errorMessage}
|
||||
</Typography>
|
||||
) : null}
|
||||
{run.status === "pending_review" ? (
|
||||
<Box sx={{ mt: 1.25 }}>
|
||||
{runDiffs[run.id] ? (
|
||||
<>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{runDiffs[run.id].totalAdded} additions | {runDiffs[run.id].totalUpdated} updates
|
||||
{runDiffs[run.id].totalLowConfidence ? ` | ${runDiffs[run.id].totalLowConfidence} need attention` : ""}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 0.5 }}>
|
||||
{runDiffs[run.id].categories
|
||||
.filter((category) => category.added.length || category.updated.length)
|
||||
.map((category) => `${category.category}: +${category.added.length} / ~${category.updated.length}`)
|
||||
.join(" | ") || "No profile changes found"}
|
||||
</Typography>
|
||||
</>
|
||||
) : <LinearProgress sx={{ my: 1 }} />}
|
||||
{runDiffs[run.id]?.categories.flatMap((category) => [...category.added, ...category.updated].map((change) => ({ ...change, category: category.category }))).filter((change) => change.confidence === "Low").map((change) => (
|
||||
<FormControlLabel
|
||||
key={change.id}
|
||||
sx={{ display: "flex", mt: 0.5 }}
|
||||
control={<Checkbox size="small" checked={(acceptedLowConfidenceIds[run.id] ?? []).includes(change.id)} onChange={(event) => setAcceptedLowConfidenceIds((current) => ({
|
||||
...current,
|
||||
[run.id]: event.target.checked
|
||||
? [...(current[run.id] ?? []), change.id]
|
||||
: (current[run.id] ?? []).filter((id) => id !== change.id),
|
||||
}))} />}
|
||||
label={`Include low-confidence ${change.category}: ${change.label}`}
|
||||
/>
|
||||
))}
|
||||
<Box sx={{ display: "flex", gap: 1, mt: 1, flexWrap: "wrap" }}>
|
||||
<Button size="small" variant="contained" disabled={!runDiffs[run.id] || reviewingRunId !== null} onClick={async () => {
|
||||
setReviewingRunId(run.id);
|
||||
try {
|
||||
await api.post(`/profile-cv/runs/${run.id}/accept`, { acceptedLowConfidenceIds: acceptedLowConfidenceIds[run.id] ?? [] });
|
||||
setRunDiffs((current) => { const next = { ...current }; delete next[run.id]; return next; });
|
||||
await Promise.all([loadProfile(), loadVersions()]);
|
||||
toast("CV changes merged into your career profile.", "success");
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, "Could not apply CV changes."), "error");
|
||||
} finally { setReviewingRunId(null); }
|
||||
}}>Apply changes</Button>
|
||||
<Button size="small" color="inherit" disabled={reviewingRunId !== null} onClick={async () => {
|
||||
setReviewingRunId(run.id);
|
||||
try {
|
||||
await api.post(`/profile-cv/runs/${run.id}/discard`);
|
||||
setRunDiffs((current) => { const next = { ...current }; delete next[run.id]; return next; });
|
||||
await loadProfile();
|
||||
toast("CV extraction discarded.", "info");
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, "Could not discard CV extraction."), "error");
|
||||
} finally { setReviewingRunId(null); }
|
||||
}}>Discard</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
@@ -836,335 +655,6 @@ export default function CareerProfilePage() {
|
||||
|
||||
<OtherSectionsSection value={structuredCv.otherSections} onChange={(next) => setStructuredCv((prev) => ({ ...prev, otherSections: next }))} />
|
||||
</Box>
|
||||
<Box sx={{ mt: 2, p: 2.25, borderRadius: 4, border: "1px solid", borderColor: "divider", background: "linear-gradient(180deg, rgba(15,23,42,0.04) 0%, rgba(15,23,42,0) 100%)", display: showAdvancedCvTools ? "block" : "none" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.75 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 900 }}>Template-driven CV builder</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", maxWidth: 720 }}>
|
||||
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.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<Chip size="small" variant="outlined" label={cvSection || "Whole CV rewrite"} />
|
||||
{selectedRewriteJob ? <Chip size="small" color="primary" variant="outlined" label={`Saved job · ${selectedRewriteJob.jobTitle}`} /> : null}
|
||||
{rewriteReady ? <Chip size="small" color="success" label="Preview ready" /> : null}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Paper sx={{ p: { xs: 1.5, md: 2 }, borderRadius: 4, border: "1px solid", borderColor: "divider", background: `linear-gradient(180deg, ${selectedRewriteTemplate.accent}14 0%, rgba(255,255,255,0.96) 100%)`, boxShadow: "0 18px 40px rgba(15,23,42,0.08)" }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1.15fr 0.85fr" }, gap: 2, alignItems: "stretch" }}>
|
||||
<Box sx={{ p: { xs: 1.25, md: 2 }, borderRadius: 3.5, background: "rgba(255,255,255,0.82)", border: "1px solid", borderColor: "rgba(15,23,42,0.08)" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 1.5, mb: 1.5 }}>
|
||||
<Box>
|
||||
<Typography variant="overline" sx={{ color: selectedRewriteTemplate.accent, fontWeight: 900, letterSpacing: '0.16em' }}>{selectedRewriteTemplate.eyebrow}</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 900 }}>{selectedRewriteTemplate.title}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25, maxWidth: 560 }}>{selectedRewriteTemplate.blurb}</Typography>
|
||||
</Box>
|
||||
<IconButton size="small" onClick={() => setRewritePreviewTemplate(selectedRewriteTemplate)}>
|
||||
<ZoomInOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ borderRadius: 3.5, overflow: "hidden", border: "1px solid", borderColor: "rgba(15,23,42,0.1)", background: "white", minHeight: { xs: 280, md: 340 }, boxShadow: "inset 0 1px 0 rgba(255,255,255,0.7)" }}>
|
||||
<Box sx={{ px: { xs: 2, md: 3 }, py: { xs: 2, md: 2.5 }, borderBottom: "1px solid", borderColor: "rgba(15,23,42,0.08)", background: `linear-gradient(135deg, ${selectedRewriteTemplate.accent}14 0%, rgba(255,255,255,0.96) 72%)` }}>
|
||||
<Typography variant="caption" sx={{ display: "block", color: selectedRewriteTemplate.accent, fontWeight: 900, letterSpacing: '0.14em', mb: 0.5 }}>{selectedRewriteTemplate.eyebrow}</Typography>
|
||||
<Typography sx={{ fontSize: { xs: '1.1rem', md: '1.35rem' }, fontWeight: 900, lineHeight: 1.1 }}>{selectedRewriteTemplate.sampleHeading}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.5 }}>{selectedRewriteTemplate.sampleMeta}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ px: { xs: 2, md: 3 }, py: { xs: 2, md: 2.5 } }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>Preview of the generated PDF style</Typography>
|
||||
{selectedRewriteTemplate.sampleBullets.map((bullet) => (
|
||||
<Typography key={bullet} variant="body2" sx={{ display: "block", color: "text.primary", mb: 0.85, lineHeight: 1.55 }}>• {bullet}</Typography>
|
||||
))}
|
||||
<Box sx={{ mt: 2, pt: 1.5, borderTop: "1px dashed", borderColor: "divider", display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(3, minmax(0, 1fr))" }, gap: 1 }}>
|
||||
<Chip size="small" variant="outlined" label="Readable hierarchy" />
|
||||
<Chip size="small" variant="outlined" label="PDF-first spacing" />
|
||||
<Chip size="small" variant="outlined" label="ATS-safe structure" />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 900 }}>Choose a visual direction before generating</Typography>
|
||||
<Box sx={{ display: "grid", gap: 1.1 }}>
|
||||
{REWRITE_TEMPLATES.map((option) => {
|
||||
const selected = option.id === cvSectionStyle;
|
||||
return (
|
||||
<Paper
|
||||
key={option.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${option.title} template preview`}
|
||||
onClick={() => 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)' },
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "92px minmax(0, 1fr)", gap: 1.1, alignItems: "stretch" }}>
|
||||
<Box sx={{ borderRadius: 2.5, border: "1px solid", borderColor: "rgba(15,23,42,0.08)", background: `linear-gradient(180deg, ${option.accent}1e 0%, rgba(255,255,255,0.98) 100%)`, p: 1, minHeight: 102, display: "flex", flexDirection: "column", justifyContent: "space-between" }}>
|
||||
<Typography variant="caption" sx={{ color: option.accent, fontWeight: 900, letterSpacing: '0.08em' }}>{option.eyebrow}</Typography>
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ display: "block", fontWeight: 800, lineHeight: 1.25 }}>{option.sampleHeading}</Typography>
|
||||
<Typography variant="caption" sx={{ display: "block", color: "text.secondary", mt: 0.5, lineHeight: 1.25 }}>{option.sampleMeta}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 1 }}>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 900 }}>{option.title}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25, lineHeight: 1.4 }}>{option.blurb}</Typography>
|
||||
</Box>
|
||||
{selected ? <Chip size="small" color="primary" label="Selected" /> : null}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5, mb: 1.75 }}>
|
||||
<TextField
|
||||
label="Prompt-based CV brief"
|
||||
value={cvPromptBackground}
|
||||
onChange={(e) => 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" } }}
|
||||
/>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>{t("profileCvSectionLabel")}</InputLabel>
|
||||
<Select value={cvSection} label={t("profileCvSectionLabel")} onChange={(e) => setCvSection(e.target.value as CvSectionOption)}>
|
||||
<MenuItem value="">Whole CV</MenuItem>
|
||||
<MenuItem value="Professional Summary">{t("profileCvSectionSummary")}</MenuItem>
|
||||
<MenuItem value="Core Skills">{t("profileCvSectionSkills")}</MenuItem>
|
||||
<MenuItem value="Experience Highlights">{t("profileCvSectionExperience")}</MenuItem>
|
||||
<MenuItem value="Selected Achievements">{t("profileCvSectionAchievements")}</MenuItem>
|
||||
<MenuItem value="Projects">{t("profileCvSectionProjects")}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
label={t("profileCvSectionTargetRole")}
|
||||
value={cvSectionTargetRole}
|
||||
onChange={(e) => setCvSectionTargetRole(e.target.value)}
|
||||
fullWidth
|
||||
helperText={selectedRewriteJob ? `Using saved job context: ${selectedRewriteJob.jobTitle}` : "Leave empty to let the selected job drive tailoring."}
|
||||
/>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>Language</InputLabel>
|
||||
<Select value={cvLanguage} label="Language" onChange={(e) => setCvLanguage(e.target.value as CvBuilderLanguage)}>
|
||||
<MenuItem value="English">English</MenuItem>
|
||||
<MenuItem value="Norwegian">Norwegian</MenuItem>
|
||||
<MenuItem value="Spanish">Spanish</MenuItem>
|
||||
<MenuItem value="French">French</MenuItem>
|
||||
<MenuItem value="German">German</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>Tone</InputLabel>
|
||||
<Select value={cvTone} label="Tone" onChange={(e) => setCvTone(e.target.value as CvBuilderTone)}>
|
||||
<MenuItem value="Concise and direct">Concise and direct</MenuItem>
|
||||
<MenuItem value="Executive and polished">Executive and polished</MenuItem>
|
||||
<MenuItem value="Technical and detailed">Technical and detailed</MenuItem>
|
||||
<MenuItem value="Warm and people-focused">Warm and people-focused</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl fullWidth size="small" sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }}>
|
||||
<InputLabel>Saved job context</InputLabel>
|
||||
<Select value={selectedRewriteJobId} label="Saved job context" onChange={(e) => setSelectedRewriteJobId(String(e.target.value))}>
|
||||
<MenuItem value="">None</MenuItem>
|
||||
{savedJobs.map((job) => (
|
||||
<MenuItem key={job.id} value={String(job.id)}>{job.jobTitle} · {job.company?.name ?? "Unknown company"}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.5 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 900 }}>Builder output</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
{selectedRewriteTemplate.title} · {rewritePreview?.targetRole || selectedRewriteJob?.jobTitle || cvSectionTargetRole || "General reuse"}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!isLocal || (!profileCvText.trim() && structuredCv.sections.length === 0) || rewritingSection || uploadingCv || improvingCv || rebuildingCv}
|
||||
onClick={async () => {
|
||||
setRewritingSection(true);
|
||||
resetPdfCarousel();
|
||||
try {
|
||||
const res = await api.post<CvBuilderPreview>("/profile-cv/rewrite-preview", buildRewritePayload(cvSectionStyle));
|
||||
setRewritePreview(res.data);
|
||||
toast(t("profileCvSectionRewritten"), "success");
|
||||
} catch (e: any) {
|
||||
toast(getApiErrorMessage(e, t("profileCvSectionRewriteFailed")), "error");
|
||||
} finally {
|
||||
setRewritingSection(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{rewritingSection ? t("profileCvSectionRewriting") : rewriteReady ? "Refresh preview" : "Build preview"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
disabled={!rewriteReady || downloadingPdf}
|
||||
onClick={async () => {
|
||||
setDownloadingPdf(true);
|
||||
try {
|
||||
await savePdfToCarousel(cvSectionStyle, true);
|
||||
toast("CV PDF downloaded and added to the carousel.", "success");
|
||||
} catch (e: any) {
|
||||
toast(getApiErrorMessage(e, "Failed to export the CV PDF."), "error");
|
||||
} finally {
|
||||
setDownloadingPdf(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{downloadingPdf ? "Generating PDF…" : "Download PDF"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="text"
|
||||
disabled={!rewriteReady || buildingPdfDeck}
|
||||
onClick={buildPdfCarousel}
|
||||
>
|
||||
{buildingPdfDeck ? "Building PDF carousel…" : "Build PDF carousel"}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "0.9fr 1.1fr" }, gap: 1.5 }}>
|
||||
<Paper sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{rewritePreview?.sectionName || "Full rewritten CV text"}</Typography>
|
||||
{rewriteReady ? <Chip size="small" color="success" label={`${(rewritePreview?.fullText || "").trim().split(/\s+/).filter(Boolean).length} words`} /> : null}
|
||||
</Box>
|
||||
<Box sx={{ minHeight: 220, maxHeight: 520, overflow: "auto", borderRadius: 2.5, backgroundColor: "background.default", border: "1px dashed", borderColor: "divider", p: 1.5 }}>
|
||||
{rewriteReady ? (
|
||||
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{rewritePreview?.sectionName ? rewritePreview?.rewrittenText : rewritePreview?.fullText}</Typography>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>Choose a template and generate a live preview. The builder will show rewritten content here and render the PDF layout beside it.</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ mt: 1.25, display: "flex", justifyContent: "flex-end", gap: 1, flexWrap: "wrap" }}>
|
||||
<Button variant="text" disabled={!rewriteReady} onClick={() => navigator.clipboard.writeText(rewritePreview?.fullText ?? "")}>{t("profileCopyCvText")}</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!rewriteReady}
|
||||
onClick={() => {
|
||||
setProfileCvText(rewritePreview?.fullText ?? "");
|
||||
if (rewritePreview?.structuredCv) setStructuredCv(normalizeStructuredCv(rewritePreview.structuredCv));
|
||||
}}
|
||||
>
|
||||
Replace master CV
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>PDF carousel</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
{activePdfItem?.title ? `${activePdfItem.title} · generated PDF` : `${selectedRewriteTemplate.title} · print-ready layout`}
|
||||
</Typography>
|
||||
</Box>
|
||||
{activePdfItem?.fileName ? <Chip size="small" variant="outlined" label={activePdfItem.fileName} /> : rewriteReady ? <Chip size="small" variant="outlined" label={rewritePreview?.suggestedFileName || "preview.pdf"} /> : null}
|
||||
</Box>
|
||||
|
||||
{pdfCarousel.length > 0 ? (
|
||||
<>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 1.25 }}>
|
||||
{pdfCarousel.map((item, index) => (
|
||||
<Button
|
||||
key={item.templateId}
|
||||
size="small"
|
||||
variant={index === activePdfIndex ? "contained" : "outlined"}
|
||||
color={item.status === "error" ? "error" : item.status === "ready" ? "primary" : "inherit"}
|
||||
onClick={() => setActivePdfIndex(index)}
|
||||
>
|
||||
{item.title}
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
<Box sx={{ borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default", overflow: "hidden", minHeight: 520 }}>
|
||||
{activePdfItem?.status === "ready" && activePdfItem.pdfUrl ? (
|
||||
<iframe title={`${activePdfItem.title} PDF preview`} src={activePdfItem.pdfUrl} style={{ width: "100%", minHeight: 520, border: 0, background: "white" }} />
|
||||
) : activePdfItem?.status === "error" ? (
|
||||
<Box sx={{ minHeight: 520, display: "grid", placeItems: "center", p: 3 }}>
|
||||
<Box sx={{ maxWidth: 420, textAlign: "center" }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 900, mb: 1 }}>{activePdfItem.title} PDF unavailable</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{activePdfItem.error || "This template could not be rendered as a PDF right now."}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ minHeight: 520, display: "grid", placeItems: "center", p: 3 }}>
|
||||
<Box sx={{ maxWidth: 420, textAlign: "center" }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 900, mb: 1 }}>{activePdfItem?.title || "Preparing PDF preview"}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
{buildingPdfDeck ? "The carousel is generating PDFs across the current template set." : "Generate the PDF carousel to inspect rendered export files without leaving the page."}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<Box sx={{ borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default", overflow: "hidden", minHeight: 520 }}>
|
||||
{rewriteReady ? (
|
||||
<iframe title="Profile CV preview" srcDoc={rewritePreview?.html} style={{ width: "100%", minHeight: 520, border: 0, background: "white" }} />
|
||||
) : (
|
||||
<Box sx={{ minHeight: 520, display: "grid", placeItems: "center", p: 3 }}>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", textAlign: "center", maxWidth: 360 }}>
|
||||
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.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
<Dialog open={Boolean(rewritePreviewTemplate)} onClose={() => setRewritePreviewTemplate(null)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{rewritePreviewTemplate?.title ?? "Template preview"}</DialogTitle>
|
||||
<DialogContent>
|
||||
{rewritePreviewTemplate ? (
|
||||
<Box sx={{ p: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", background: `linear-gradient(180deg, ${rewritePreviewTemplate.accent}12 0%, rgba(255,255,255,0) 100%)` }}>
|
||||
<Typography variant="overline" sx={{ color: rewritePreviewTemplate.accent, fontWeight: 800 }}>{rewritePreviewTemplate.eyebrow}</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 900, mb: 0.5 }}>{rewritePreviewTemplate.sampleHeading}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{rewritePreviewTemplate.sampleMeta}</Typography>
|
||||
{rewritePreviewTemplate.sampleBullets.map((bullet) => (
|
||||
<Typography key={bullet} variant="body2" sx={{ mb: 0.75 }}>• {bullet}</Typography>
|
||||
))}
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 1.5 }}>{rewritePreviewTemplate.blurb}</Typography>
|
||||
</Box>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Box>
|
||||
<Box sx={{ mt: 1, display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{cvWordCount} words
|
||||
|
||||
Reference in New Issue
Block a user