Harden CV rewrite diagnostics and preview PDFs
This commit is contained in:
@@ -7,7 +7,7 @@ import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import PhotoCameraOutlinedIcon from "@mui/icons-material/PhotoCameraOutlined";
|
||||
import ZoomInOutlinedIcon from "@mui/icons-material/ZoomInOutlined";
|
||||
|
||||
import { api } from "../api";
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import GoogleAuthCard from "../components/GoogleAuthCard";
|
||||
import CropImageDialog from "../components/CropImageDialog";
|
||||
import { useToast } from "../toast";
|
||||
@@ -78,6 +78,24 @@ type CvBuilderPreview = {
|
||||
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;
|
||||
};
|
||||
|
||||
type MeResponse = {
|
||||
provider?: "local" | "google" | "external";
|
||||
id?: string;
|
||||
@@ -227,6 +245,10 @@ export default function ProfilePage() {
|
||||
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);
|
||||
@@ -236,6 +258,16 @@ export default function ProfilePage() {
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
pdfCarousel.forEach((item) => {
|
||||
if (item.pdfUrl) {
|
||||
window.URL.revokeObjectURL(item.pdfUrl);
|
||||
}
|
||||
});
|
||||
};
|
||||
}, [pdfCarousel]);
|
||||
|
||||
const loadProfile = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -312,6 +344,100 @@ export default function ProfilePage() {
|
||||
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,
|
||||
}), [cvSection, cvSectionTargetRole, 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 }}>
|
||||
@@ -903,19 +1029,13 @@ export default function ProfilePage() {
|
||||
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", {
|
||||
sectionName: cvSection || null,
|
||||
style: cvSectionStyle,
|
||||
templateId: cvSectionStyle,
|
||||
targetRole: cvSectionTargetRole.trim() || null,
|
||||
jobApplicationId: selectedRewriteJob ? selectedRewriteJob.id : null,
|
||||
sourceText: profileCvText.trim() || null,
|
||||
});
|
||||
const res = await api.post<CvBuilderPreview>("/profile-cv/rewrite-preview", buildRewritePayload(cvSectionStyle));
|
||||
setRewritePreview(res.data);
|
||||
toast(t("profileCvSectionRewritten"), "success");
|
||||
} catch (e: any) {
|
||||
toast(String(e?.response?.data || e?.message || t("profileCvSectionRewriteFailed")), "error");
|
||||
toast(getApiErrorMessage(e, t("profileCvSectionRewriteFailed")), "error");
|
||||
} finally {
|
||||
setRewritingSection(false);
|
||||
}
|
||||
@@ -925,33 +1045,27 @@ export default function ProfilePage() {
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
disabled={!rewriteReady}
|
||||
disabled={!rewriteReady || downloadingPdf}
|
||||
onClick={async () => {
|
||||
setDownloadingPdf(true);
|
||||
try {
|
||||
const response = await api.post("/profile-cv/export-pdf", {
|
||||
sectionName: cvSection || null,
|
||||
style: cvSectionStyle,
|
||||
templateId: cvSectionStyle,
|
||||
targetRole: cvSectionTargetRole.trim() || null,
|
||||
jobApplicationId: selectedRewriteJob ? selectedRewriteJob.id : null,
|
||||
sourceText: profileCvText.trim() || null,
|
||||
}, { responseType: "blob" });
|
||||
const blob = new Blob([response.data], { type: "application/pdf" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = rewritePreview?.suggestedFileName || `${cvSectionStyle}-cv.pdf`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
toast("CV PDF downloaded.", "success");
|
||||
await savePdfToCarousel(cvSectionStyle, true);
|
||||
toast("CV PDF downloaded and added to the carousel.", "success");
|
||||
} catch (e: any) {
|
||||
toast(String(e?.response?.data || e?.message || "Failed to export the CV PDF."), "error");
|
||||
toast(getApiErrorMessage(e, "Failed to export the CV PDF."), "error");
|
||||
} finally {
|
||||
setDownloadingPdf(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Download PDF
|
||||
{downloadingPdf ? "Generating PDF…" : "Download PDF"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="text"
|
||||
disabled={!rewriteReady || buildingPdfDeck}
|
||||
onClick={buildPdfCarousel}
|
||||
>
|
||||
{buildingPdfDeck ? "Building PDF carousel…" : "Build PDF carousel"}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -987,22 +1101,64 @@ export default function ProfilePage() {
|
||||
<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 }}>Styled preview</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{selectedRewriteTemplate.title} · print-ready layout</Typography>
|
||||
<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>
|
||||
{rewriteReady ? <Chip size="small" variant="outlined" label={rewritePreview?.suggestedFileName || "preview.pdf"} /> : null}
|
||||
{activePdfItem?.fileName ? <Chip size="small" variant="outlined" label={activePdfItem.fileName} /> : rewriteReady ? <Chip size="small" variant="outlined" label={rewritePreview?.suggestedFileName || "preview.pdf"} /> : null}
|
||||
</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, spacing, and hierarchy before you apply it.
|
||||
</Typography>
|
||||
|
||||
{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>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -6,6 +6,17 @@ import { I18nProvider } from './i18n/I18nProvider';
|
||||
import ProfilePage from './pages/ProfilePage';
|
||||
import { api } from './api';
|
||||
|
||||
const createObjectURLMock = jest.fn(() => 'blob:mock-pdf');
|
||||
const revokeObjectURLMock = jest.fn();
|
||||
Object.defineProperty(window.URL, 'createObjectURL', {
|
||||
writable: true,
|
||||
value: createObjectURLMock,
|
||||
});
|
||||
Object.defineProperty(window.URL, 'revokeObjectURL', {
|
||||
writable: true,
|
||||
value: revokeObjectURLMock,
|
||||
});
|
||||
|
||||
jest.mock('./api', () => ({
|
||||
api: {
|
||||
get: jest.fn(),
|
||||
@@ -22,6 +33,8 @@ jest.mock('./components/CropImageDialog', () => () => null);
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
const REWRITE_TEMPLATES_COUNT = 6;
|
||||
|
||||
const structuredCv = {
|
||||
version: '1',
|
||||
metadata: {
|
||||
@@ -131,7 +144,7 @@ beforeEach(() => {
|
||||
}
|
||||
return Promise.resolve({ data: {} } as any);
|
||||
});
|
||||
mockedApi.post.mockImplementation((url: string) => {
|
||||
mockedApi.post.mockImplementation((url: string, payload?: any, config?: any) => {
|
||||
if (url === '/profile-cv/parse') {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
@@ -149,6 +162,9 @@ beforeEach(() => {
|
||||
if (url === '/profile-cv/rewrite-preview') {
|
||||
return Promise.resolve({ data: { templateId: 'harvard', html: '<html><body>Preview</body></html>', suggestedFileName: 'harvard-preview.pdf', fullText: 'Professional Summary\nClearer, sharper positioning for backend platform roles.', rewrittenText: 'Professional Summary\nClearer, sharper positioning for backend platform roles.', structuredCv, sectionName: null, jobApplicationId: 42, targetRole: 'Senior Backend Engineer' } } as any);
|
||||
}
|
||||
if (url === '/profile-cv/export-pdf') {
|
||||
return Promise.resolve({ data: new Blob([`pdf-${payload?.templateId ?? 'ats-minimal'}`], { type: 'application/pdf' }), config } as any);
|
||||
}
|
||||
if (url === '/profile-cv/reprocess') {
|
||||
return Promise.resolve({ data: { reprocessed: true } } as any);
|
||||
}
|
||||
@@ -160,6 +176,8 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
createObjectURLMock.mockClear();
|
||||
revokeObjectURLMock.mockClear();
|
||||
});
|
||||
|
||||
test('profile page loads persisted structured cv and can re-parse it', async () => {
|
||||
@@ -247,6 +265,17 @@ test('profile page rewrite tools use selected template and saved job context', a
|
||||
|
||||
expect(await screen.findByText(/preview ready/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/clearer, sharper positioning for backend platform roles/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: /pdf carousel/i })).toBeInTheDocument();
|
||||
|
||||
const buildCarouselButton = screen.getByRole('button', { name: /build pdf carousel/i });
|
||||
fireEvent.click(buildCarouselButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const exportCalls = mockedApi.post.mock.calls.filter(([url]) => url === '/profile-cv/export-pdf');
|
||||
expect(exportCalls.length).toBe(REWRITE_TEMPLATES_COUNT);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(createObjectURLMock).toHaveBeenCalledTimes(REWRITE_TEMPLATES_COUNT));
|
||||
});
|
||||
|
||||
test('saving profile persists structured cv json', async () => {
|
||||
|
||||
Reference in New Issue
Block a user