feat(career): CV Builder UI — 3-tab builder, live preview, variants, public CV
CI and Deploy / test (push) Failing after 1m57s
CI and Deploy / deploy (push) Has been skipped

Frontend for Phase 4. /career/builder lists CV variants; the editor has the
three spec tabs (Content / Customize / AI Tools, plus History) beside an
always-on live preview that re-renders through the server theme engine on a
debounce. Content: reorder/hide/rename sections, headline override, custom
sections. Customize: 8-theme picker, accent colour, fonts, density, page size,
photo/icons/page-number toggles. AI Tools: suggestion-only assistance (never
auto-applied). Autosave with version history + restore, public on/off with a
copyable /cv/{slug} link, PDF export. Public read-only page at /cv/:slug.

- cvBuilder.ts (types + API), CvBuilderPage, CvBuilderEditor, PublicCvPage
- routes + nav wired in App.tsx; "Open CV Builder" entry on Career Workspace
- 2 component tests; tsc + production build clean

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-18 10:05:21 +02:00
parent a3e18e4b44
commit 158dd02b00
7 changed files with 883 additions and 6 deletions
+55
View File
@@ -0,0 +1,55 @@
import React, { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { api, getApiErrorMessage } from "../api";
// Anonymous read-only public CV at /cv/:slug. Renders the server-produced HTML in a sandboxed
// iframe. noindex is enforced server-side (X-Robots-Tag) and reinforced with a meta tag here.
export default function PublicCvPage() {
const { slug } = useParams();
const [html, setHtml] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const meta = document.createElement("meta");
meta.name = "robots";
meta.content = "noindex, nofollow";
document.head.appendChild(meta);
let alive = true;
api.get<{ html: string; name: string }>(`/public-cv/${slug}`)
.then((r) => { if (alive) setHtml(r.data.html); })
.catch((err) => { if (alive) setError(getApiErrorMessage(err, "This CV is not available.")); });
return () => {
alive = false;
document.head.removeChild(meta);
};
}, [slug]);
if (error) {
return (
<div style={{ display: "flex", height: "100vh", alignItems: "center", justifyContent: "center", fontFamily: "system-ui", color: "#374151" }}>
{error}
</div>
);
}
if (html === null) {
return (
<div style={{ display: "flex", height: "100vh", alignItems: "center", justifyContent: "center", fontFamily: "system-ui", color: "#9ca3af" }}>
Loading…
</div>
);
}
return (
<div style={{ minHeight: "100vh", background: "#e9edf2", padding: "24px 0", display: "flex", justifyContent: "center" }}>
<iframe
title="Public CV"
srcDoc={html}
sandbox="allow-same-origin"
style={{ width: "210mm", height: "297mm", border: "none", background: "#fff", boxShadow: "0 8px 30px rgba(0,0,0,0.18)" }}
/>
</div>
);
}