feat(career): CV Builder UI — 3-tab builder, live preview, variants, public CV
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user