feat(cv): rebuild professional resume studio
This commit is contained in:
@@ -310,6 +310,8 @@ test("Career Workspace loads from the authenticated application shell", async ({
|
||||
await page.getByRole("link", { name: "Open CV Builder" }).click();
|
||||
await expect(page).toHaveURL(/\/career\/builder$/);
|
||||
await page.getByRole("button", { name: "New CV" }).first().click();
|
||||
await expect(page.getByRole("dialog", { name: "Create a CV" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Create CV" }).click();
|
||||
await expect(page).toHaveURL(/\/career\/builder\/\d+$/);
|
||||
await expect(page.getByLabel("CV name")).toBeVisible();
|
||||
|
||||
@@ -319,6 +321,11 @@ test("Career Workspace loads from the authenticated application shell", async ({
|
||||
for (const width of [375, 768, 1440]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await expect(page.getByLabel("CV name")).toBeVisible();
|
||||
if (width === 375) {
|
||||
await page.getByRole("button", { name: "Preview" }).click();
|
||||
await expect(page.getByTitle("CV preview page 1")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Edit" }).click();
|
||||
}
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
||||
expect(overflow).toBeLessThanOrEqual(1);
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ test("returning workspace shows missing profile information and recent general a
|
||||
expect(await screen.findByText("Backend CV")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("General CV").length).toBeGreaterThan(1);
|
||||
expect(screen.getByText("Job-specific")).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("link", { name: "Open", exact: true })[0]).toHaveAttribute("href", "/career/builder/8");
|
||||
expect(screen.getAllByRole("link", { name: /^Open$/ })[0]).toHaveAttribute("href", "/career/builder/8");
|
||||
});
|
||||
|
||||
test.each([
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Box } from "@mui/material";
|
||||
|
||||
import { CvTheme, cvBuilderApi } from "../cvBuilder";
|
||||
|
||||
const previewCache = new Map<string, string>();
|
||||
|
||||
export default function CvTemplateThumbnail({ theme, height = 150 }: { theme?: CvTheme; height?: number }) {
|
||||
const [previewHtml, setPreviewHtml] = useState(() => theme ? previewCache.get(theme.id) ?? "" : "");
|
||||
const accent = theme?.swatches?.[0] || "#3157d5";
|
||||
const sidebar = theme?.swatches?.[1] || "#eef1f4";
|
||||
const paper = theme?.swatches?.[2] || "#ffffff";
|
||||
const layout = theme?.layout || "header-band";
|
||||
const hasSidebar = layout === "sidebar-left" || layout === "sidebar-right";
|
||||
const sidebarRight = layout === "sidebar-right";
|
||||
const lines = [92, 72, 84, 61, 88, 76, 95, 67];
|
||||
|
||||
useEffect(() => {
|
||||
if (!theme) return;
|
||||
const cached = previewCache.get(theme.id);
|
||||
if (cached) {
|
||||
setPreviewHtml(cached);
|
||||
return;
|
||||
}
|
||||
setPreviewHtml("");
|
||||
let active = true;
|
||||
void cvBuilderApi.themePreview(theme.id).then((render) => {
|
||||
previewCache.set(theme.id, render.html);
|
||||
if (active) setPreviewHtml(render.html);
|
||||
}).catch(() => undefined);
|
||||
return () => { active = false; };
|
||||
}, [theme]);
|
||||
|
||||
if (previewHtml) {
|
||||
const scale = height / (297 * 96 / 25.4);
|
||||
return (
|
||||
<Box aria-hidden sx={{ height, bgcolor: "#dfe4ea", border: "1px solid", borderColor: "divider", borderRadius: 1.5, overflow: "hidden", display: "flex", justifyContent: "center" }}>
|
||||
<Box sx={{ width: height * 210 / 297, height, bgcolor: "#fff", boxShadow: "0 5px 14px rgba(15,23,42,.15)", overflow: "hidden" }}>
|
||||
<iframe title={`${theme?.name ?? "CV"} template preview`} srcDoc={previewHtml} sandbox="allow-same-origin" tabIndex={-1}
|
||||
style={{ width: "210mm", height: "297mm", border: 0, display: "block", transform: `scale(${scale})`, transformOrigin: "top left", pointerEvents: "none" }} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const body = <Box sx={{ p: "7%", minWidth: 0 }}>
|
||||
<Box sx={{ width: "48%", height: 5, borderRadius: 2, bgcolor: accent, mb: 1 }} />
|
||||
{lines.map((width, index) => <Box key={index} sx={{ width: `${width}%`, height: index === 4 ? 4 : 2.5, borderRadius: 2, bgcolor: index === 4 ? accent : "rgba(51,65,85,.24)", mt: index === 4 ? 1.25 : 0, mb: 0.7 }} />)}
|
||||
</Box>;
|
||||
const side = <Box sx={{ bgcolor: sidebar, p: "13% 12%", minWidth: 0 }}>
|
||||
<Box sx={{ width: 24, height: 24, borderRadius: theme?.photoShape === "circle" ? "50%" : 1, bgcolor: "rgba(255,255,255,.78)", border: "1px solid rgba(15,23,42,.1)", mb: 1.2 }} />
|
||||
{[66, 86, 54, 74, 62, 79].map((width) => <Box key={width} sx={{ width: `${width}%`, height: 2.5, borderRadius: 2, bgcolor: "rgba(30,41,59,.3)", mb: 0.8 }} />)}
|
||||
</Box>;
|
||||
|
||||
return (
|
||||
<Box aria-hidden sx={{ height, bgcolor: paper, border: "1px solid", borderColor: "divider", borderRadius: 1.5, overflow: "hidden", boxShadow: "0 8px 20px rgba(15,23,42,.08)", display: "flex", flexDirection: "column" }}>
|
||||
{!hasSidebar && <Box sx={{ height: layout === "header-band" ? "25%" : 8, bgcolor: layout === "header-band" ? accent : paper, borderBottom: layout === "header-band" ? 0 : `2px solid ${accent}`, p: layout === "header-band" ? "6%" : 0 }}>
|
||||
{layout === "header-band" && <><Box sx={{ width: "42%", height: 5, bgcolor: "rgba(255,255,255,.9)", mb: 0.75 }} /><Box sx={{ width: "30%", height: 2.5, bgcolor: "rgba(255,255,255,.65)" }} /></>}
|
||||
</Box>}
|
||||
<Box sx={{ flex: 1, minHeight: 0, display: "grid", gridTemplateColumns: hasSidebar ? (sidebarRight ? "1fr 32%" : "32% 1fr") : "1fr" }}>
|
||||
{hasSidebar && !sidebarRight ? side : null}{body}{hasSidebar && sidebarRight ? side : null}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -128,6 +128,36 @@ test('failed autosave is visible and can be retried with the latest data', async
|
||||
}));
|
||||
});
|
||||
|
||||
test('session undo and redo restore content edits before autosave', async () => {
|
||||
routeGet(() => Promise.resolve({ data: variant } as any));
|
||||
mockedApi.put.mockResolvedValue({ data: variant } as any);
|
||||
renderAt(3);
|
||||
|
||||
const headline = await screen.findByLabelText('Headline override');
|
||||
fireEvent.change(headline, { target: { value: 'Platform Engineer' } });
|
||||
expect(screen.getByRole('button', { name: 'Undo' })).toBeEnabled();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Undo' }));
|
||||
expect(headline).toHaveValue('');
|
||||
expect(screen.getByRole('button', { name: 'Redo' })).toBeEnabled();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Redo' }));
|
||||
expect(headline).toHaveValue('Platform Engineer');
|
||||
});
|
||||
|
||||
test('professional editor separates template, design and layout controls', async () => {
|
||||
routeGet(() => Promise.resolve({ data: variant } as any));
|
||||
renderAt(3);
|
||||
|
||||
await screen.findByLabelText('Headline override');
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Design' }));
|
||||
expect(screen.getByText('Typography')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Body size')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Skills presentation')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Layout' }));
|
||||
expect(screen.getByLabelText('Page size')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Columns')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Language')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('internal navigation warns and can be cancelled before discarding a pending edit', async () => {
|
||||
routeGet(() => Promise.resolve({ data: variant } as any));
|
||||
renderAt(3);
|
||||
@@ -137,7 +167,7 @@ test('internal navigation warns and can be cancelled before discarding a pending
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Discard unsaved CV changes?' });
|
||||
expect(within(dialog).getByText('This CV has unsaved changes. Leave and discard them?')).toBeInTheDocument();
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' }));
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument(), { timeout: 5000 });
|
||||
expect(screen.getByDisplayValue('Backend CV')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<MemoryRouter>
|
||||
<ToastProvider>
|
||||
<I18nProvider>
|
||||
<ConfirmProvider>
|
||||
@@ -53,7 +53,7 @@ beforeEach(() => {
|
||||
test('lists existing CVs from the variants API', async () => {
|
||||
mockedApi.get.mockResolvedValueOnce({
|
||||
data: [
|
||||
{ id: 1, name: 'Frontend CV', themeId: 'modern', publicSlug: 'abc', isPublic: true, version: 2, jobApplicationId: null, updatedAtUtc: new Date().toISOString() },
|
||||
{ id: 1, name: 'Frontend CV', themeId: 'modern', language: 'en', publicSlug: 'abc', isPublic: true, version: 2, jobApplicationId: 17, jobTitle: 'Frontend Engineer', companyName: 'Northstar', updatedAtUtc: new Date().toISOString() },
|
||||
],
|
||||
} as any);
|
||||
|
||||
@@ -61,6 +61,7 @@ test('lists existing CVs from the variants API', async () => {
|
||||
|
||||
expect(await screen.findByText('Frontend CV')).toBeInTheDocument();
|
||||
expect(screen.getByText('Public')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Frontend Engineer at Northstar/)).toBeInTheDocument();
|
||||
const cvCard = screen.getByRole('link', { name: 'Open Frontend CV' });
|
||||
cvCard.focus();
|
||||
expect(cvCard).toHaveFocus();
|
||||
@@ -79,6 +80,9 @@ test('shows the empty state and creates a CV then navigates to the editor', asyn
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: /New CV/i })[0]);
|
||||
|
||||
expect(await screen.findByRole('dialog', { name: 'Create a CV' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create CV' }));
|
||||
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants', expect.objectContaining({ name: 'Untitled CV' })));
|
||||
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/career/builder/42'));
|
||||
});
|
||||
|
||||
@@ -15,6 +15,22 @@ export type CvVariantSettings = {
|
||||
dateFormat?: string | null;
|
||||
language?: string | null;
|
||||
headline?: string | null;
|
||||
textColor?: string | null;
|
||||
mutedColor?: string | null;
|
||||
headingColor?: string | null;
|
||||
backgroundColor?: string | null;
|
||||
baseFontSizePt?: number | null;
|
||||
headingSizePt?: number | null;
|
||||
lineHeight?: number | null;
|
||||
pageMarginMm?: number | null;
|
||||
sectionGapMm?: number | null;
|
||||
entryGapMm?: number | null;
|
||||
headingStyle?: "caps-rule" | "underline" | "plain" | "bar" | null;
|
||||
headerStyle?: "plain" | "band" | "centered" | "kicker" | null;
|
||||
skillsStyle?: "tags" | "text" | null;
|
||||
layout?: "single" | "sidebar-left" | "sidebar-right" | "header-band" | null;
|
||||
sidebarWidthMm?: number | null;
|
||||
sidebarSections?: string[] | null;
|
||||
showPhoto: boolean;
|
||||
showPageNumbers: boolean;
|
||||
showIcons: boolean;
|
||||
@@ -47,10 +63,13 @@ export type CvVariantSummary = {
|
||||
id: number;
|
||||
name: string;
|
||||
themeId: string;
|
||||
language?: string | null;
|
||||
publicSlug: string;
|
||||
isPublic: boolean;
|
||||
version: number;
|
||||
jobApplicationId: number | null;
|
||||
jobTitle?: string | null;
|
||||
companyName?: string | null;
|
||||
updatedAtUtc: string;
|
||||
};
|
||||
|
||||
@@ -74,6 +93,7 @@ export const AI_ACTIONS: { key: string; label: string }[] = [
|
||||
{ key: "shorten", label: "Shorten" },
|
||||
{ key: "expand", label: "Expand" },
|
||||
{ key: "grammar", label: "Fix grammar" },
|
||||
{ key: "impact", label: "Add measurable impact" },
|
||||
{ key: "ats", label: "ATS optimise" },
|
||||
{ key: "bullets", label: "Generate bullets" },
|
||||
{ key: "summary", label: "Generate summary" },
|
||||
@@ -82,7 +102,8 @@ export const AI_ACTIONS: { key: string; label: string }[] = [
|
||||
];
|
||||
|
||||
export const DEFAULT_SECTION_ORDER = [
|
||||
"summary", "experience", "education", "projects", "skills", "certifications", "languages", "interests",
|
||||
"summary", "experience", "education", "projects", "skills", "certifications", "languages", "awards",
|
||||
"publications", "organisations", "interests", "references",
|
||||
];
|
||||
|
||||
export const SECTION_LABELS: Record<string, string> = {
|
||||
@@ -94,6 +115,10 @@ export const SECTION_LABELS: Record<string, string> = {
|
||||
certifications: "Certifications",
|
||||
languages: "Languages",
|
||||
interests: "Interests",
|
||||
awards: "Awards",
|
||||
publications: "Publications",
|
||||
organisations: "Organisations",
|
||||
references: "References",
|
||||
};
|
||||
|
||||
const CSS_PIXELS_PER_MM = 96 / 25.4;
|
||||
@@ -166,6 +191,7 @@ export function wrapSelection(
|
||||
|
||||
export const cvBuilderApi = {
|
||||
themes: () => api.get<CvTheme[]>("/cv/themes").then((r) => r.data),
|
||||
themePreview: (themeId: string) => api.get<CvRender>(`/cv/themes/${encodeURIComponent(themeId)}/preview`).then((r) => r.data),
|
||||
outline: () => api.get<CvOutline>("/cv/outline").then((r) => r.data),
|
||||
list: () => api.get<CvVariantSummary[]>("/cv/variants").then((r) => r.data),
|
||||
create: (body: { name?: string; jobApplicationId?: number | null; settings?: CvVariantSettings }) =>
|
||||
|
||||
@@ -23,6 +23,7 @@ export const translations = {
|
||||
kanbanPageSubtitle: "Drag a card between stages to update its status.",
|
||||
remindersPageSubtitle: "Everything due for follow-up, soonest first.",
|
||||
correspondencePageSubtitle: "Every message linked to a job, in one inbox.",
|
||||
correspondenceInbox: "Job email",
|
||||
gmailReviewPageSubtitle: "Review Gmail threads before linking them to a job.",
|
||||
account: "Account",
|
||||
profile: "Profile",
|
||||
@@ -1192,6 +1193,7 @@ export const translations = {
|
||||
kanbanPageSubtitle: "Dra et kort mellom stadier for å oppdatere status.",
|
||||
remindersPageSubtitle: "Alt som trenger oppfølging, snarest først.",
|
||||
correspondencePageSubtitle: "Alle meldinger koblet til en jobb, i én innboks.",
|
||||
correspondenceInbox: "Jobb-e-post",
|
||||
gmailReviewPageSubtitle: "Se gjennom Gmail-tråder før du kobler dem til en jobb.",
|
||||
account: "Konto",
|
||||
profile: "Profil",
|
||||
|
||||
@@ -29,6 +29,8 @@ export type StructuredCvContact = {
|
||||
location?: string;
|
||||
website?: string;
|
||||
linkedIn?: string;
|
||||
gitHub?: string;
|
||||
links?: { label?: string; url?: string }[];
|
||||
};
|
||||
|
||||
export type StructuredCvJob = {
|
||||
@@ -116,7 +118,7 @@ export function emptyStructuredCv(): StructuredCvProfile {
|
||||
return {
|
||||
version: "1",
|
||||
metadata: { fields: {} },
|
||||
contact: {},
|
||||
contact: { links: [] },
|
||||
summary: [],
|
||||
jobs: [],
|
||||
education: [],
|
||||
@@ -232,6 +234,8 @@ export function normalizeStructuredCv(value: unknown): StructuredCvProfile {
|
||||
location: normalizeString(source.contact?.location),
|
||||
website: normalizeString(source.contact?.website),
|
||||
linkedIn: normalizeString(source.contact?.linkedIn),
|
||||
gitHub: normalizeString(source.contact?.gitHub),
|
||||
links: Array.isArray(source.contact?.links) ? source.contact.links.map((link: any) => ({ label: normalizeString(link?.label), url: normalizeString(link?.url) })).filter((link: any) => link.url) : [],
|
||||
},
|
||||
summary: normalizeList(source.summary),
|
||||
jobs: Array.isArray(source.jobs)
|
||||
|
||||
@@ -21,7 +21,7 @@ test('public CV exposes the rendered CV and PDF download', async () => {
|
||||
mockedApi.get.mockResolvedValueOnce({ data: { html: '<p>Public CV</p>', name: 'Ada Lovelace' } } as any);
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/cv/public-slug']} future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<MemoryRouter initialEntries={['/cv/public-slug']}>
|
||||
<Routes><Route path="/cv/:slug" element={<PublicCvPage />} /></Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
@@ -225,10 +225,11 @@ export default function CareerProfilePage() {
|
||||
]);
|
||||
setMe(meResponse.data);
|
||||
setProfileCvText(careerResponse.data?.cvText ?? "");
|
||||
setStructuredCv(normalizeStructuredCv(careerResponse.data?.profile ?? emptyStructuredCv()));
|
||||
const profile = normalizeStructuredCv(careerResponse.data?.profile ?? emptyStructuredCv());
|
||||
setStructuredCv(profile);
|
||||
setCompleteness(careerResponse.data?.completeness ?? null);
|
||||
setProfileDirty(false);
|
||||
setHeadline(window.localStorage.getItem("profileHeadline") ?? "");
|
||||
setHeadline(profile.contact.headline ?? "");
|
||||
setLoadError(null);
|
||||
} catch (error: any) {
|
||||
setMe(null);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useBlocker, useNavigate, useParams } from "react-router-dom";
|
||||
import { Link as RouterLink, useBlocker, useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import {
|
||||
Alert, Box, Button, Chip, Collapse, Divider, FormControl, FormControlLabel, IconButton, InputLabel,
|
||||
MenuItem, Paper, Select, Skeleton, Slider, Stack, Switch, Tab, Tabs, TextField, Tooltip, Typography,
|
||||
} from "@mui/material";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
|
||||
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
|
||||
@@ -20,6 +21,8 @@ import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import ZoomInIcon from "@mui/icons-material/ZoomIn";
|
||||
import ZoomOutIcon from "@mui/icons-material/ZoomOut";
|
||||
import UndoIcon from "@mui/icons-material/Undo";
|
||||
import RedoIcon from "@mui/icons-material/Redo";
|
||||
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { useToast } from "../toast";
|
||||
@@ -46,12 +49,19 @@ const FONT_LABELS = ["Segoe UI", "Arial", "Georgia (serif)", "Helvetica Neue", "
|
||||
const MIN_PREVIEW_ZOOM = 0.32;
|
||||
type SaveState = "idle" | "unsaved" | "saving" | "saved" | "error";
|
||||
|
||||
function previewPageHtml(html: string, pageIndex: number, pageHeightPx: number): string {
|
||||
const offset = pageIndex * pageHeightPx;
|
||||
const previewCss = `<style data-cv-preview-page>html,body{overflow:hidden!important;background:#fff!important;}body{transform:translateY(-${offset}px);transform-origin:top left;}</style>`;
|
||||
return html.includes("</head>") ? html.replace("</head>", `${previewCss}</head>`) : `${previewCss}${html}`;
|
||||
}
|
||||
|
||||
export default function CvBuilderEditor() {
|
||||
const { id } = useParams();
|
||||
const variantId = Number(id);
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const { confirmAction } = useDialogActions();
|
||||
const compactEditor = useMediaQuery("(max-width:899.95px)");
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [settings, setSettings] = useState<CvVariantSettings | null>(null);
|
||||
@@ -65,7 +75,6 @@ export default function CvBuilderEditor() {
|
||||
const [previewing, setPreviewing] = useState(false);
|
||||
const [previewError, setPreviewError] = useState(false);
|
||||
const [previewRevision, setPreviewRevision] = useState(0);
|
||||
const [previewHeight, setPreviewHeight] = useState(() => getCvPageMetrics("a4").heightPx);
|
||||
const [previewOverflow, setPreviewOverflow] = useState(false);
|
||||
const [pages, setPages] = useState(1);
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -74,6 +83,8 @@ export default function CvBuilderEditor() {
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [versions, setVersions] = useState<CvVariantVersionInfo[]>([]);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [mobilePane, setMobilePane] = useState<"edit" | "preview">("edit");
|
||||
const [historyRevision, setHistoryRevision] = useState(0);
|
||||
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const saveRevision = useRef(0);
|
||||
@@ -85,6 +96,8 @@ export default function CvBuilderEditor() {
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const blockerPromptOpen = useRef(false);
|
||||
const undoStack = useRef<CvVariantSettings[]>([]);
|
||||
const redoStack = useRef<CvVariantSettings[]>([]);
|
||||
const pageMetrics = useMemo(() => getCvPageMetrics(settings?.pageSize), [settings?.pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -120,6 +133,9 @@ export default function CvBuilderEditor() {
|
||||
setIsPublic(variant.isPublic);
|
||||
setPublicSlug(variant.publicSlug);
|
||||
setSaveState("saved");
|
||||
undoStack.current = [];
|
||||
redoStack.current = [];
|
||||
setHistoryRevision((value) => value + 1);
|
||||
};
|
||||
|
||||
// Debounced live preview.
|
||||
@@ -189,6 +205,31 @@ export default function CvBuilderEditor() {
|
||||
setSettings((prev) => {
|
||||
if (!prev) return prev;
|
||||
const next = { ...prev, ...patch };
|
||||
undoStack.current = [...undoStack.current.slice(-59), prev];
|
||||
redoStack.current = [];
|
||||
setHistoryRevision((value) => value + 1);
|
||||
scheduleSave(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const undo = () => {
|
||||
setSettings((current) => {
|
||||
const previous = undoStack.current.pop();
|
||||
if (!current || !previous) return current;
|
||||
redoStack.current.push(current);
|
||||
setHistoryRevision((value) => value + 1);
|
||||
scheduleSave(previous);
|
||||
return previous;
|
||||
});
|
||||
};
|
||||
|
||||
const redo = () => {
|
||||
setSettings((current) => {
|
||||
const next = redoStack.current.pop();
|
||||
if (!current || !next) return current;
|
||||
undoStack.current.push(current);
|
||||
setHistoryRevision((value) => value + 1);
|
||||
scheduleSave(next);
|
||||
return next;
|
||||
});
|
||||
@@ -215,7 +256,6 @@ export default function CvBuilderEditor() {
|
||||
confirmLabel: "Discard and leave",
|
||||
destructive: true,
|
||||
}).then((confirmed) => {
|
||||
blockerPromptOpen.current = false;
|
||||
if (confirmed) blocker.proceed();
|
||||
else blocker.reset();
|
||||
});
|
||||
@@ -279,6 +319,20 @@ export default function CvBuilderEditor() {
|
||||
}
|
||||
};
|
||||
|
||||
const duplicateVariant = async () => {
|
||||
if (hasUnsavedChanges && !(await retrySave())) {
|
||||
toast("Save the current CV before duplicating it.", "error");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const copy = await cvBuilderApi.duplicate(variantId, `${name || "Untitled CV"} copy`);
|
||||
navigate(`/career/builder/${copy.id}`);
|
||||
toast("CV duplicated. You are editing the copy.", "success");
|
||||
} catch (err) {
|
||||
toast(getApiErrorMessage(err, "Could not duplicate this CV."), "error");
|
||||
}
|
||||
};
|
||||
|
||||
const loadVersions = async () => {
|
||||
try {
|
||||
setVersions(await cvBuilderApi.versions(variantId));
|
||||
@@ -316,7 +370,6 @@ export default function CvBuilderEditor() {
|
||||
doc?.documentElement?.scrollHeight ?? 0,
|
||||
);
|
||||
const pageCount = getCvPageCount(h, pageMetrics.heightPx);
|
||||
setPreviewHeight(h);
|
||||
setPages(pageCount);
|
||||
setPage((current) => Math.min(current, pageCount));
|
||||
const viewportWidth = doc?.documentElement?.clientWidth ?? pageMetrics.widthPx;
|
||||
@@ -331,7 +384,8 @@ export default function CvBuilderEditor() {
|
||||
const goToPage = (p: number) => {
|
||||
const clamped = Math.min(Math.max(1, p), pages);
|
||||
setPage(clamped);
|
||||
scrollRef.current?.scrollTo({ top: (clamped - 1) * pageMetrics.heightPx * zoom, behavior: "smooth" });
|
||||
const target = scrollRef.current?.querySelector<HTMLElement>(`[data-cv-page="${clamped}"]`);
|
||||
if (target && scrollRef.current) scrollRef.current.scrollTo({ top: Math.max(0, target.offsetTop - 12), behavior: "smooth" });
|
||||
};
|
||||
|
||||
const fitPreview = () => {
|
||||
@@ -349,38 +403,57 @@ export default function CvBuilderEditor() {
|
||||
}
|
||||
if (!settings) return <EditorSkeleton />;
|
||||
|
||||
const canUndo = historyRevision >= 0 && undoStack.current.length > 0;
|
||||
const canRedo = historyRevision >= 0 && redoStack.current.length > 0;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "minmax(360px, 460px) 1fr" }, gap: 2, alignItems: "start" }}>
|
||||
<Paper sx={{ p: 2, borderRadius: 4, position: { md: "sticky" }, top: 12, minWidth: 0, maxHeight: { md: "calc(100vh - 24px)" }, overflowY: { md: "auto" } }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
|
||||
<Stack spacing={1.5} sx={{ minWidth: 0 }}>
|
||||
<Paper component="header" sx={{ px: { xs: 1, sm: 1.5 }, py: 1, borderRadius: 3, border: "1px solid", borderColor: "divider" }}>
|
||||
<Stack direction="row" alignItems="center" gap={1} flexWrap="wrap">
|
||||
<Tooltip title="Back to CVs"><IconButton size="small" aria-label="Back to CVs" onClick={() => navigate("/career/builder")}><ArrowBackIcon fontSize="small" /></IconButton></Tooltip>
|
||||
<TextField variant="standard" fullWidth value={name} onChange={(e) => renameVariant(e.target.value)}
|
||||
<TextField variant="standard" value={name} onChange={(e) => renameVariant(e.target.value)}
|
||||
error={!name.trim()} helperText={!name.trim() ? "Enter a name before saving." : undefined}
|
||||
sx={{ minWidth: { xs: 150, sm: 220 }, flex: "1 1 240px", maxWidth: 420 }}
|
||||
slotProps={{ input: { style: { fontWeight: 800, fontSize: "1.05rem" } }, htmlInput: { "aria-label": "CV name" } }} />
|
||||
<SaveBadge state={saveState} canRetry={!!name.trim()} onRetry={() => void retrySave()} />
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 1, flexWrap: "wrap", gap: 1 }}>
|
||||
<Button size="small" variant="outlined" startIcon={<PictureAsPdfIcon />} disabled={exporting || publishing || !name.trim()} onClick={exportPdf}>{exporting ? "Exporting…" : "Export PDF"}</Button>
|
||||
<Divider orientation="vertical" flexItem sx={{ display: { xs: "none", sm: "block" } }} />
|
||||
<Tooltip title="Undo"><span><IconButton size="small" aria-label="Undo" disabled={!canUndo} onClick={undo}><UndoIcon fontSize="small" /></IconButton></span></Tooltip>
|
||||
<Tooltip title="Redo"><span><IconButton size="small" aria-label="Redo" disabled={!canRedo} onClick={redo}><RedoIcon fontSize="small" /></IconButton></span></Tooltip>
|
||||
{compactEditor && <Stack direction="row" sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2, p: 0.25 }}>
|
||||
<Button size="small" variant={mobilePane === "edit" ? "contained" : "text"} onClick={() => setMobilePane("edit")}>Edit</Button>
|
||||
<Button size="small" variant={mobilePane === "preview" ? "contained" : "text"} onClick={() => setMobilePane("preview")}>Preview</Button>
|
||||
</Stack>}
|
||||
<Box sx={{ flex: { sm: 1 } }} />
|
||||
<Button size="small" variant="text" startIcon={<ContentCopyIcon />} disabled={!name.trim()} onClick={() => void duplicateVariant()}>Duplicate</Button>
|
||||
<Button size="small" variant={isPublic ? "contained" : "outlined"} startIcon={<PublicIcon />} disabled={publishing || exporting || !name.trim()} onClick={togglePublic}>
|
||||
{publishing ? "Updating…" : isPublic ? "Public" : "Private"}
|
||||
</Button>
|
||||
{isPublic && <Button size="small" startIcon={<ContentCopyIcon />} onClick={copyPublicLink}>Copy link</Button>}
|
||||
<Button size="small" variant="contained" startIcon={<PictureAsPdfIcon />} disabled={exporting || publishing || !name.trim()} onClick={exportPdf}>{exporting ? "Exporting…" : "Download PDF"}</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Tabs value={tab} onChange={(_, v) => { setTab(v); if (v === 3) loadVersions(); }} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ mb: 1.5 }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "minmax(360px, 480px) minmax(0, 1fr)" }, gap: 1.5, alignItems: "start" }}>
|
||||
<Paper sx={{ display: compactEditor && mobilePane !== "edit" ? "none" : "block", p: 2, borderRadius: 3, position: { md: "sticky" }, top: 12, minWidth: 0, maxHeight: { md: "calc(100vh - 104px)" }, overflowY: { md: "auto" }, border: "1px solid", borderColor: "divider" }}>
|
||||
|
||||
<Tabs value={tab} onChange={(_, v) => { setTab(v); if (v === 5) loadVersions(); }} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ mb: 1.5, minHeight: 38 }}>
|
||||
<Tab label="Content" />
|
||||
<Tab label="Customize" />
|
||||
<Tab label="AI Tools" />
|
||||
<Tab label="Template" />
|
||||
<Tab label="Design" />
|
||||
<Tab label="Layout" />
|
||||
<Tab label="AI" />
|
||||
<Tab label="History" />
|
||||
</Tabs>
|
||||
|
||||
{tab === 0 && <ContentTab settings={settings} update={update} outline={outline} />}
|
||||
{tab === 1 && <CustomizeTab settings={settings} update={update} themes={themes} />}
|
||||
{tab === 2 && <AiToolsTab />}
|
||||
{tab === 3 && <HistoryTab versions={versions} onRestore={restore} />}
|
||||
{tab === 1 && <CustomizeTab mode="template" settings={settings} update={update} themes={themes} />}
|
||||
{tab === 2 && <CustomizeTab mode="design" settings={settings} update={update} themes={themes} />}
|
||||
{tab === 3 && <CustomizeTab mode="layout" settings={settings} update={update} themes={themes} />}
|
||||
{tab === 4 && <AiToolsTab />}
|
||||
{tab === 5 && <HistoryTab versions={versions} onRestore={restore} />}
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 1.5, borderRadius: 4, bgcolor: "action.hover", border: "1px solid", borderColor: "divider", minWidth: 0 }}>
|
||||
<Paper sx={{ display: compactEditor && mobilePane !== "preview" ? "none" : "block", p: 1.5, borderRadius: 3, bgcolor: "action.hover", border: "1px solid", borderColor: "divider", minWidth: 0 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1, px: 1, flexWrap: "wrap" }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700 }}>Live preview</Typography>
|
||||
{previewing && <Chip size="small" label="updating…" variant="outlined" />}
|
||||
@@ -402,30 +475,27 @@ export default function CvBuilderEditor() {
|
||||
</Stack>
|
||||
{previewOverflow && <Alert severity="warning" sx={{ mb: 1 }}>The preview reported horizontal overflow. Shorten an unbroken value or retry after the latest render.</Alert>}
|
||||
{pages >= 3 && <Alert severity="info" sx={{ mb: 1 }}>This CV is {pages} pages. Content remains readable, but consider hiding less relevant entries for a more focused application.</Alert>}
|
||||
<Box ref={scrollRef} sx={{ overflow: "auto", maxHeight: "82vh", display: "flex", justifyContent: "center", p: 1 }}>
|
||||
<Box sx={{ position: "relative", width: `calc(${pageMetrics.widthMm}mm * ${zoom})`, height: `${previewHeight * zoom}px`, flex: "0 0 auto" }}>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
title="CV preview"
|
||||
srcDoc={html}
|
||||
sandbox="allow-same-origin"
|
||||
onLoad={onIframeLoad}
|
||||
style={{
|
||||
width: `${pageMetrics.widthMm}mm`, height: `${previewHeight}px`, border: "none",
|
||||
transform: `scale(${zoom})`, transformOrigin: "top left",
|
||||
boxShadow: "0 8px 30px rgba(0,0,0,0.24)", background: "#fff", display: "block",
|
||||
}}
|
||||
/>
|
||||
{Array.from({ length: Math.max(0, pages - 1) }).map((_, i) => (
|
||||
<Box key={i} aria-hidden sx={{
|
||||
position: "absolute", left: 0, right: 0, top: `${(i + 1) * pageMetrics.heightPx * zoom}px`,
|
||||
borderTop: "2px dashed", borderColor: "error.main", opacity: 0.72, pointerEvents: "none",
|
||||
}} />
|
||||
<iframe ref={iframeRef} title="CV preview" srcDoc={html} sandbox="allow-same-origin" onLoad={onIframeLoad} aria-hidden tabIndex={-1} style={{ position: "absolute", left: "-10000px", top: 0, width: `${pageMetrics.widthMm}mm`, height: `${pageMetrics.heightMm}mm`, visibility: "hidden", pointerEvents: "none" }} />
|
||||
<Box ref={scrollRef} sx={{ overflow: "auto", maxHeight: "82vh", p: { xs: 1, sm: 2 }, bgcolor: "#dfe4ea", borderRadius: 2 }}>
|
||||
<Stack spacing={3} alignItems="center">
|
||||
{Array.from({ length: pages }).map((_, index) => (
|
||||
<Box key={index} data-cv-page={index + 1} sx={{ flex: "0 0 auto" }}>
|
||||
<Typography variant="caption" sx={{ display: "block", mb: 0.75, color: "#475569", fontWeight: 700 }}>Page {index + 1}</Typography>
|
||||
<Box sx={{ position: "relative", width: `calc(${pageMetrics.widthMm}mm * ${zoom})`, height: `calc(${pageMetrics.heightMm}mm * ${zoom})`, bgcolor: "#fff", boxShadow: "0 10px 28px rgba(15,23,42,.18)", overflow: "hidden" }}>
|
||||
<iframe
|
||||
title={`CV preview page ${index + 1}`}
|
||||
srcDoc={previewPageHtml(html, index, pageMetrics.heightPx)}
|
||||
sandbox="allow-same-origin"
|
||||
style={{ width: `${pageMetrics.widthMm}mm`, height: `${pageMetrics.heightMm}mm`, border: "none", transform: `scale(${zoom})`, transformOrigin: "top left", background: "#fff", display: "block" }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -478,6 +548,10 @@ function ContentTab({ settings, update, outline }: {
|
||||
const base: CvSectionSetting[] = settings.sections.length ? [...settings.sections] : DEFAULT_SECTION_ORDER.map((key) => ({ key }));
|
||||
const have = new Set(base.map((s) => s.key));
|
||||
for (const key of DEFAULT_SECTION_ORDER) if (!have.has(key)) base.push({ key });
|
||||
for (const section of outline?.sections ?? []) if (!have.has(section.key)) {
|
||||
base.push({ key: section.key, title: section.title });
|
||||
have.add(section.key);
|
||||
}
|
||||
for (const custom of settings.customSections) {
|
||||
const key = `custom:${custom.key}`;
|
||||
if (!have.has(key)) {
|
||||
@@ -486,7 +560,7 @@ function ContentTab({ settings, update, outline }: {
|
||||
}
|
||||
}
|
||||
return base;
|
||||
}, [settings.customSections, settings.sections]);
|
||||
}, [outline?.sections, settings.customSections, settings.sections]);
|
||||
|
||||
const writeSections = (rows: CvSectionSetting[]) => update({ sections: rows });
|
||||
const sectionDrag = useDragReorder((from, to) => writeSections(moveItem(sectionRows, from, to)));
|
||||
@@ -547,6 +621,9 @@ function ContentTab({ settings, update, outline }: {
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Alert severity="info" action={<Button component={RouterLink} to="/career/profile" size="small">Edit master profile</Button>}>
|
||||
Contact details and career history are shared from your master profile. CV-specific headings, wording, order and visibility stay in this version.
|
||||
</Alert>
|
||||
<TextField label="Headline override" size="small" fullWidth value={settings.headline ?? ""}
|
||||
onChange={(e) => update({ headline: e.target.value || null })}
|
||||
helperText="Blank uses the headline from your master profile." />
|
||||
@@ -763,15 +840,23 @@ function EntryEditor({ section, row, settings, onPatch, onUpdateSettings }: {
|
||||
|
||||
// ---------- Customize tab ----------
|
||||
|
||||
function CustomizeTab({ settings, update, themes }: {
|
||||
function CustomizeTab({ mode, settings, update, themes }: {
|
||||
mode: "template" | "design" | "layout";
|
||||
settings: CvVariantSettings;
|
||||
update: (p: Partial<CvVariantSettings>) => void;
|
||||
themes: CvTheme[];
|
||||
}) {
|
||||
const sidebarSections = settings.sidebarSections ?? ["contact", "skills", "languages"];
|
||||
const toggleSidebarSection = (key: string) => update({
|
||||
sidebarSections: sidebarSections.includes(key)
|
||||
? sidebarSections.filter((item) => item !== key)
|
||||
: [...sidebarSections, key],
|
||||
});
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Box>
|
||||
{mode === "template" && <Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>Theme</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mb: 1.25 }}>Templates change presentation only. Your content and hidden-section choices stay intact.</Typography>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
|
||||
{themes.map((t) => {
|
||||
const active = t.id === settings.themeId;
|
||||
@@ -781,9 +866,11 @@ function CustomizeTab({ settings, update, themes }: {
|
||||
onClick={() => { if (!locked) update({ themeId: t.id }); }}
|
||||
onKeyDown={(e) => { if (!locked && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); update({ themeId: t.id }); } }}
|
||||
sx={{ p: 1, cursor: locked ? "not-allowed" : "pointer", opacity: locked ? 0.6 : 1, outline: "none", borderColor: active ? "primary.main" : undefined, borderWidth: active ? 2 : 1, "&:focus-visible": { boxShadow: 3 } }}>
|
||||
<Stack direction="row" spacing={0.5} sx={{ mb: 0.5 }}>
|
||||
{t.swatches.map((s, i) => <Box key={i} sx={{ width: 14, height: 14, borderRadius: "3px", bgcolor: s, border: "1px solid rgba(0,0,0,0.1)" }} />)}
|
||||
</Stack>
|
||||
<Box sx={{ height: 86, mb: 1, bgcolor: t.swatches[2] || "#fff", border: "1px solid", borderColor: "divider", borderRadius: 1, overflow: "hidden", display: "grid", gridTemplateColumns: t.layout.startsWith("sidebar") ? (t.layout === "sidebar-right" ? "1fr 30%" : "30% 1fr") : "1fr" }}>
|
||||
{t.layout.startsWith("sidebar") && t.layout !== "sidebar-right" ? <Box sx={{ bgcolor: t.swatches[1], p: 0.75 }}><Box sx={{ width: 18, height: 18, borderRadius: t.photoShape === "circle" ? "50%" : 0.5, bgcolor: "rgba(255,255,255,.75)", mb: 0.75 }} />{[50, 72, 58, 68].map((w) => <Box key={w} sx={{ width: `${w}%`, height: 2, bgcolor: "rgba(255,255,255,.6)", mb: 0.5 }} />)}</Box> : null}
|
||||
<Box sx={{ p: 0.9 }}><Box sx={{ width: "55%", height: 5, bgcolor: t.swatches[0], mb: 0.75 }} />{[92, 74, 84, 64, 88, 78].map((w, index) => <Box key={index} sx={{ width: `${w}%`, height: index % 3 === 0 ? 3 : 2, bgcolor: index % 3 === 0 ? t.swatches[0] : "rgba(71,85,105,.28)", mb: 0.65 }} />)}</Box>
|
||||
{t.layout === "sidebar-right" ? <Box sx={{ bgcolor: t.swatches[1], p: 0.75 }}>{[64, 78, 52, 70, 58].map((w) => <Box key={w} sx={{ width: `${w}%`, height: 2, bgcolor: "rgba(30,41,59,.34)", mb: 0.6 }} />)}</Box> : null}
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{t.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>{t.category}</Typography>
|
||||
<Stack direction="row" spacing={0.5} sx={{ mt: 0.5 }}>
|
||||
@@ -794,52 +881,62 @@ function CustomizeTab({ settings, update, themes }: {
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>}
|
||||
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Typography variant="body2" sx={{ flex: 1 }}>Accent colour</Typography>
|
||||
<input type="color" aria-label="Accent colour" value={settings.accentColor ?? "#2563eb"} onChange={(e) => update({ accentColor: e.target.value })} />
|
||||
{settings.accentColor && <Button size="small" onClick={() => update({ accentColor: null })}>Reset</Button>}
|
||||
</Stack>
|
||||
{mode === "design" && <>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Colour</Typography>
|
||||
<Stack direction="row" spacing={0.75} flexWrap="wrap" useFlexGap>
|
||||
{["#3157d5", "#0f766e", "#9f1239", "#7c3aed", "#b45309", "#334155"].map((color) => (
|
||||
<IconButton key={color} aria-label={`Use accent ${color}`} onClick={() => update({ accentColor: color })} sx={{ width: 34, height: 34, bgcolor: color, border: settings.accentColor === color ? "3px solid" : "1px solid", borderColor: settings.accentColor === color ? "text.primary" : "divider", "&:hover": { bgcolor: color } }} />
|
||||
))}
|
||||
<Box component="label" sx={{ width: 34, height: 34, borderRadius: "50%", border: "1px dashed", borderColor: "text.secondary", display: "grid", placeItems: "center", cursor: "pointer", overflow: "hidden" }}>
|
||||
<input type="color" aria-label="Custom accent colour" value={settings.accentColor ?? "#3157d5"} onChange={(e) => update({ accentColor: e.target.value })} style={{ width: 48, height: 48, border: 0, padding: 0, cursor: "pointer" }} />
|
||||
</Box>
|
||||
{settings.accentColor && <Button size="small" onClick={() => update({ accentColor: null })}>Theme default</Button>}
|
||||
</Stack>
|
||||
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>Heading font</InputLabel>
|
||||
<Select label="Heading font" value={settings.headingFont ?? ""} onChange={(e) => update({ headingFont: e.target.value || null })}>
|
||||
<MenuItem value="">Theme default</MenuItem>
|
||||
{FONTS.map((f, i) => <MenuItem key={f} value={f}>{FONT_LABELS[i]}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>Body font</InputLabel>
|
||||
<Select label="Body font" value={settings.bodyFont ?? ""} onChange={(e) => update({ bodyFont: e.target.value || null })}>
|
||||
<MenuItem value="">Theme default</MenuItem>
|
||||
{FONTS.map((f, i) => <MenuItem key={f} value={f}>{FONT_LABELS[i]}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, mt: 0.5 }}>Typography</Typography>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
|
||||
<FormControl size="small" fullWidth><InputLabel>Heading font</InputLabel><Select label="Heading font" value={settings.headingFont ?? ""} onChange={(e) => update({ headingFont: e.target.value || null })}><MenuItem value="">Theme default</MenuItem>{FONTS.map((f, i) => <MenuItem key={f} value={f}>{FONT_LABELS[i]}</MenuItem>)}</Select></FormControl>
|
||||
<FormControl size="small" fullWidth><InputLabel>Body font</InputLabel><Select label="Body font" value={settings.bodyFont ?? ""} onChange={(e) => update({ bodyFont: e.target.value || null })}><MenuItem value="">Theme default</MenuItem>{FONTS.map((f, i) => <MenuItem key={f} value={f}>{FONT_LABELS[i]}</MenuItem>)}</Select></FormControl>
|
||||
</Box>
|
||||
<ControlSlider label="Body size" value={settings.baseFontSizePt ?? 10} min={7} max={13} step={0.25} suffix="pt" onChange={(value) => update({ baseFontSizePt: value })} />
|
||||
<ControlSlider label="Heading size" value={settings.headingSizePt ?? 12} min={9} max={20} step={0.5} suffix="pt" onChange={(value) => update({ headingSizePt: value })} />
|
||||
<ControlSlider label="Line height" value={settings.lineHeight ?? 1.42} min={1.1} max={1.8} step={0.02} onChange={(value) => update({ lineHeight: value })} />
|
||||
<FormControl size="small" fullWidth><InputLabel>Heading treatment</InputLabel><Select label="Heading treatment" value={settings.headingStyle ?? ""} onChange={(e) => update({ headingStyle: (e.target.value || null) as CvVariantSettings["headingStyle"] })}><MenuItem value="">Template default</MenuItem><MenuItem value="caps-rule">Uppercase divider</MenuItem><MenuItem value="underline">Underline</MenuItem><MenuItem value="plain">Plain</MenuItem><MenuItem value="bar">Accent bar</MenuItem></Select></FormControl>
|
||||
<FormControl size="small" fullWidth><InputLabel>Header treatment</InputLabel><Select label="Header treatment" value={settings.headerStyle ?? ""} onChange={(e) => update({ headerStyle: (e.target.value || null) as CvVariantSettings["headerStyle"] })}><MenuItem value="">Template default</MenuItem><MenuItem value="plain">Plain</MenuItem><MenuItem value="band">Colour band</MenuItem><MenuItem value="centered">Centred</MenuItem><MenuItem value="kicker">Editorial</MenuItem></Select></FormControl>
|
||||
<FormControl size="small" fullWidth><InputLabel>Skills presentation</InputLabel><Select inputProps={{ "aria-label": "Skills presentation" }} label="Skills presentation" value={settings.skillsStyle ?? "tags"} onChange={(e) => update({ skillsStyle: e.target.value as CvVariantSettings["skillsStyle"] })}><MenuItem value="tags">Tags</MenuItem><MenuItem value="text">Simple text</MenuItem></Select></FormControl>
|
||||
</>}
|
||||
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>Density</InputLabel>
|
||||
<Select label="Density" value={settings.density ?? "balanced"} onChange={(e) => update({ density: e.target.value })}>
|
||||
<MenuItem value="compact">Compact</MenuItem>
|
||||
<MenuItem value="balanced">Balanced</MenuItem>
|
||||
<MenuItem value="roomy">Roomy</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>Page size</InputLabel>
|
||||
<Select label="Page size" value={settings.pageSize ?? "a4"} onChange={(e) => update({ pageSize: e.target.value })}>
|
||||
<MenuItem value="a4">A4</MenuItem>
|
||||
<MenuItem value="letter">Letter</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Divider />
|
||||
<FormControlLabel control={<Switch checked={settings.showPhoto} onChange={(e) => update({ showPhoto: e.target.checked })} />} label="Show profile photo" />
|
||||
<FormControlLabel control={<Switch checked={settings.showIcons} onChange={(e) => update({ showIcons: e.target.checked })} />} label="Contact icons (supported themes)" />
|
||||
{mode === "layout" && <>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Document</Typography>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
|
||||
<FormControl size="small" fullWidth><InputLabel>Page size</InputLabel><Select inputProps={{ "aria-label": "Page size" }} label="Page size" value={settings.pageSize ?? "a4"} onChange={(e) => update({ pageSize: e.target.value })}><MenuItem value="a4">A4</MenuItem><MenuItem value="letter">US Letter</MenuItem></Select></FormControl>
|
||||
<FormControl size="small" fullWidth><InputLabel>Density</InputLabel><Select inputProps={{ "aria-label": "Density" }} label="Density" value={settings.density ?? "balanced"} onChange={(e) => update({ density: e.target.value })}><MenuItem value="compact">Compact</MenuItem><MenuItem value="balanced">Balanced</MenuItem><MenuItem value="roomy">Roomy</MenuItem></Select></FormControl>
|
||||
<FormControl size="small" fullWidth><InputLabel>Language</InputLabel><Select inputProps={{ "aria-label": "Language" }} label="Language" value={settings.language ?? "en"} onChange={(e) => update({ language: e.target.value })}><MenuItem value="en">English</MenuItem><MenuItem value="no">Norwegian</MenuItem></Select></FormControl>
|
||||
<FormControl size="small" fullWidth><InputLabel>Date format</InputLabel><Select inputProps={{ "aria-label": "Date format" }} label="Date format" value={settings.dateFormat ?? "short"} onChange={(e) => update({ dateFormat: e.target.value })}><MenuItem value="long">January 2020</MenuItem><MenuItem value="short">Jan 2020</MenuItem><MenuItem value="numeric">01/2020</MenuItem><MenuItem value="year">2020</MenuItem></Select></FormControl>
|
||||
</Box>
|
||||
<FormControl size="small" fullWidth><InputLabel>Columns</InputLabel><Select inputProps={{ "aria-label": "Columns" }} label="Columns" value={settings.layout ?? ""} onChange={(e) => update({ layout: (e.target.value || null) as CvVariantSettings["layout"] })}><MenuItem value="">Template default</MenuItem><MenuItem value="single">One column</MenuItem><MenuItem value="header-band">One column with header band</MenuItem><MenuItem value="sidebar-left">Left sidebar</MenuItem><MenuItem value="sidebar-right">Right sidebar</MenuItem></Select></FormControl>
|
||||
<ControlSlider label="Page margins" value={settings.pageMarginMm ?? 16} min={8} max={28} step={1} suffix="mm" onChange={(value) => update({ pageMarginMm: value })} />
|
||||
<ControlSlider label="Section spacing" value={settings.sectionGapMm ?? 6} min={2} max={14} step={0.5} suffix="mm" onChange={(value) => update({ sectionGapMm: value })} />
|
||||
<ControlSlider label="Entry spacing" value={settings.entryGapMm ?? 4.5} min={1} max={10} step={0.5} suffix="mm" onChange={(value) => update({ entryGapMm: value })} />
|
||||
{(settings.layout === "sidebar-left" || settings.layout === "sidebar-right") && <Paper variant="outlined" sx={{ p: 1.5 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>Sidebar content</Typography>
|
||||
<ControlSlider label="Sidebar width" value={settings.sidebarWidthMm ?? 62} min={45} max={85} step={1} suffix="mm" onChange={(value) => update({ sidebarWidthMm: value })} />
|
||||
<Stack>{["contact", "skills", "languages", "certifications", "projects", "interests"].map((key) => <FormControlLabel key={key} control={<Switch size="small" checked={sidebarSections.includes(key)} onChange={() => toggleSidebarSection(key)} />} label={SECTION_LABELS[key] ?? "Contact details"} />)}</Stack>
|
||||
</Paper>}
|
||||
<Divider />
|
||||
<FormControlLabel control={<Switch checked={settings.showPhoto} onChange={(e) => update({ showPhoto: e.target.checked })} />} label="Show profile photo" />
|
||||
<FormControlLabel control={<Switch checked={settings.showIcons} onChange={(e) => update({ showIcons: e.target.checked })} />} label="Contact icons (supported templates)" />
|
||||
</>}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function ControlSlider({ label, value, min, max, step, suffix = "", onChange }: { label: string; value: number; min: number; max: number; step: number; suffix?: string; onChange: (value: number) => void }) {
|
||||
return <Box><Stack direction="row" justifyContent="space-between" alignItems="baseline"><Typography variant="body2">{label}</Typography><Typography variant="caption" color="text.secondary">{Number(value.toFixed(2))}{suffix}</Typography></Stack><Slider size="small" aria-label={label} value={value} min={min} max={max} step={step} onChange={(_, next) => onChange(next as number)} /></Box>;
|
||||
}
|
||||
|
||||
function AiToolsTab() {
|
||||
const { toast } = useToast();
|
||||
const { canUseAi } = useAccountPlan();
|
||||
@@ -883,13 +980,21 @@ function AiToolsTab() {
|
||||
))}
|
||||
</Box>
|
||||
{result && (
|
||||
<Paper variant="outlined" sx={{ p: 1.5 }}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 0.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Suggestion</Typography>
|
||||
<Button size="small" startIcon={<ContentCopyIcon />} onClick={() => { navigator.clipboard?.writeText(result); toast("Copied.", "success"); }}>Copy</Button>
|
||||
</Stack>
|
||||
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{result}</Typography>
|
||||
</Paper>
|
||||
<Stack spacing={1}>
|
||||
<Paper variant="outlined" sx={{ p: 1.5 }}>
|
||||
<Typography variant="overline" color="text.secondary">Original</Typography>
|
||||
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{text}</Typography>
|
||||
</Paper>
|
||||
<Paper variant="outlined" sx={{ p: 1.5, borderColor: "primary.main" }}>
|
||||
<Typography variant="overline" color="primary.main">Suggested — review before using</Typography>
|
||||
<TextField aria-label="Editable AI suggestion" multiline minRows={4} fullWidth variant="standard" value={result} onChange={(event) => setResult(event.target.value)} />
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1 }} flexWrap="wrap" useFlexGap>
|
||||
<Button size="small" variant="contained" onClick={() => { setText(result); setResult(""); toast("Suggestion accepted into the working text.", "success"); }}>Accept</Button>
|
||||
<Button size="small" onClick={() => setResult("")}>Reject</Button>
|
||||
<Button size="small" startIcon={<ContentCopyIcon />} onClick={() => { navigator.clipboard?.writeText(result); toast("Copied.", "success"); }}>Copy</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -2,30 +2,37 @@ import React, { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import {
|
||||
Alert, Box, Button, Chip, IconButton, Menu, MenuItem, Paper, Stack, Typography,
|
||||
Alert, Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Menu, MenuItem, Paper, Stack, TextField, Typography,
|
||||
} from "@mui/material";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import DescriptionOutlinedIcon from "@mui/icons-material/DescriptionOutlined";
|
||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||
import PublicIcon from "@mui/icons-material/Public";
|
||||
|
||||
import { getApiErrorMessage } from "../api";
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { useToast } from "../toast";
|
||||
import { CvVariantSummary, cvBuilderApi, emptyCvVariantSettings } from "../cvBuilder";
|
||||
import { CvTheme, CvVariantSummary, cvBuilderApi, emptyCvVariantSettings } from "../cvBuilder";
|
||||
import { useDialogActions } from "../dialogs";
|
||||
import CvTemplateThumbnail from "../components/CvTemplateThumbnail";
|
||||
|
||||
export default function CvBuilderPage() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const { confirmAction } = useDialogActions();
|
||||
const { confirmAction, promptForValue } = useDialogActions();
|
||||
const [variants, setVariants] = useState<CvVariantSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [menu, setMenu] = useState<{ anchor: HTMLElement; id: number } | null>(null);
|
||||
const [themes, setThemes] = useState<CvTheme[]>([]);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [newName, setNewName] = useState("Untitled CV");
|
||||
const [newTheme, setNewTheme] = useState("modern");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
setVariants(await cvBuilderApi.list());
|
||||
try { setThemes(await cvBuilderApi.themes()); } catch { setThemes([]); }
|
||||
} catch (err) {
|
||||
setError(getApiErrorMessage(err, "Could not load your CVs."));
|
||||
} finally {
|
||||
@@ -37,12 +44,19 @@ export default function CvBuilderPage() {
|
||||
}, []);
|
||||
|
||||
const createNew = async () => {
|
||||
setCreateOpen(true);
|
||||
};
|
||||
|
||||
const confirmCreate = async () => {
|
||||
if (!newName.trim()) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
const variant = await cvBuilderApi.create({ name: "Untitled CV", settings: emptyCvVariantSettings() });
|
||||
const variant = await cvBuilderApi.create({ name: newName.trim(), settings: emptyCvVariantSettings(newTheme) });
|
||||
setCreateOpen(false);
|
||||
navigate(`/career/builder/${variant.id}`);
|
||||
} catch (err) {
|
||||
toast(getApiErrorMessage(err, "Could not create a CV."), "error");
|
||||
}
|
||||
} finally { setCreating(false); }
|
||||
};
|
||||
|
||||
const duplicate = async (id: number) => {
|
||||
@@ -76,12 +90,38 @@ export default function CvBuilderPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const rename = async (id: number) => {
|
||||
const current = variants.find((item) => item.id === id);
|
||||
setMenu(null);
|
||||
const nextName = await promptForValue("Give this CV a clear name.", current?.name ?? "", { title: "Rename CV", confirmLabel: "Rename" });
|
||||
if (!nextName?.trim() || nextName.trim() === current?.name) return;
|
||||
try {
|
||||
const variant = await cvBuilderApi.get(id);
|
||||
const updated = await cvBuilderApi.save(id, { name: nextName.trim(), settings: variant.settings, source: "manual" });
|
||||
setVariants((items) => items.map((item) => item.id === id ? { ...item, name: updated.name, updatedAtUtc: updated.updatedAtUtc, version: updated.version } : item));
|
||||
toast("CV renamed.", "success");
|
||||
} catch (err) { toast(getApiErrorMessage(err, "Rename failed."), "error"); }
|
||||
};
|
||||
|
||||
const download = async (id: number, cvName: string) => {
|
||||
setMenu(null);
|
||||
try {
|
||||
const response = await api.post(cvBuilderApi.exportPdfUrl(id), {}, { responseType: "blob" });
|
||||
const url = URL.createObjectURL(response.data as Blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `${cvName || "cv"}.pdf`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) { toast(getApiErrorMessage(err, "PDF download failed."), "error"); }
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "grid", gap: 2 }}>
|
||||
<Paper sx={{ p: 2.5, borderRadius: 4, display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: 1 }}>
|
||||
<Paper sx={{ p: { xs: 2, sm: 3 }, borderRadius: 3, display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: 2, background: "linear-gradient(120deg, rgba(49,87,213,.09), transparent 58%)", border: "1px solid", borderColor: "divider" }}>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 900 }}>CV Builder</Typography>
|
||||
<Typography color="text.secondary">Build tailored CVs from your master profile. Content stays in your profile — each CV is a theme + a selection.</Typography>
|
||||
<Typography color="text.secondary" sx={{ maxWidth: 720 }}>Build polished, job-specific resumes from one trusted career profile. Every version keeps its own template, content choices and history.</Typography>
|
||||
</Box>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={createNew}>New CV</Button>
|
||||
</Paper>
|
||||
@@ -97,14 +137,14 @@ export default function CvBuilderPage() {
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr", md: "1fr 1fr 1fr" }, gap: 2 }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))", lg: "repeat(3, minmax(0, 1fr))" }, gap: 2 }}>
|
||||
{variants.map((v) => (
|
||||
<Paper
|
||||
key={v.id}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
aria-label={`Open ${v.name}`}
|
||||
sx={{ p: 2, borderRadius: 4, cursor: "pointer", "&:hover": { boxShadow: 4 }, "&:focus-visible": { outline: "3px solid", outlineColor: "primary.main", outlineOffset: 2 } }}
|
||||
sx={{ p: 1.5, borderRadius: 3, cursor: "pointer", border: "1px solid", borderColor: "divider", transition: "transform 150ms ease, box-shadow 150ms ease", "&:hover": { boxShadow: 5, transform: "translateY(-2px)" }, "&:focus-visible": { outline: "3px solid", outlineColor: "primary.main", outlineOffset: 2 } }}
|
||||
onClick={() => navigate(`/career/builder/${v.id}`)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.target === event.currentTarget && (event.key === "Enter" || event.key === " ")) {
|
||||
@@ -112,19 +152,22 @@ export default function CvBuilderPage() {
|
||||
navigate(`/career/builder/${v.id}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
>
|
||||
<CvTemplateThumbnail theme={themes.find((theme) => theme.id === v.themeId)} />
|
||||
<Stack direction="row" alignItems="flex-start" justifyContent="space-between">
|
||||
<Typography sx={{ fontWeight: 800 }}>{v.name}</Typography>
|
||||
<Typography sx={{ fontWeight: 800, mt: 1 }}>{v.name}</Typography>
|
||||
<IconButton size="small" aria-label={`Actions for ${v.name}`} onClick={(e) => { e.stopPropagation(); setMenu({ anchor: e.currentTarget, id: v.id }); }}>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
|
||||
<Chip size="small" label={v.themeId} />
|
||||
<Chip size="small" variant="outlined" label={(v.language || "en").toUpperCase()} />
|
||||
{v.isPublic && <Chip size="small" color="primary" icon={<PublicIcon />} label="Public" />}
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 1 }}>
|
||||
Updated {new Date(v.updatedAtUtc).toLocaleDateString()}
|
||||
Updated {new Date(v.updatedAtUtc).toLocaleDateString()} · version {v.version}
|
||||
{v.jobApplicationId ? ` · ${[v.jobTitle, v.companyName].filter(Boolean).join(" at ") || `job #${v.jobApplicationId}`}` : ""}
|
||||
</Typography>
|
||||
</Paper>
|
||||
))}
|
||||
@@ -133,8 +176,23 @@ export default function CvBuilderPage() {
|
||||
<Menu anchorEl={menu?.anchor} open={!!menu} onClose={() => setMenu(null)}>
|
||||
<MenuItem onClick={() => menu && navigate(`/career/builder/${menu.id}`)}>Open</MenuItem>
|
||||
<MenuItem onClick={() => menu && duplicate(menu.id)}>Duplicate</MenuItem>
|
||||
<MenuItem onClick={() => menu && void rename(menu.id)}>Rename</MenuItem>
|
||||
<MenuItem onClick={() => { const cv = variants.find((item) => item.id === menu?.id); if (cv) void download(cv.id, cv.name); }}>Download PDF</MenuItem>
|
||||
<MenuItem onClick={() => menu && remove(menu.id)} sx={{ color: "error.main" }}>Delete</MenuItem>
|
||||
</Menu>
|
||||
|
||||
<Dialog open={createOpen} onClose={() => { if (!creating) setCreateOpen(false); }} fullWidth maxWidth="md" aria-labelledby="create-cv-title">
|
||||
<DialogTitle id="create-cv-title">Create a CV</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography color="text.secondary" sx={{ mb: 2 }}>Start with your saved career profile, choose a visual direction, then tailor what appears.</Typography>
|
||||
<TextField autoFocus fullWidth label="CV name" value={newName} onChange={(event) => setNewName(event.target.value)} error={!newName.trim()} helperText={!newName.trim() ? "Enter a name." : "For example: Backend Engineer — Acme"} sx={{ mb: 2 }} />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>Choose a template</Typography>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr 1fr", sm: "repeat(4, 1fr)" }, gap: 1 }}>
|
||||
{(themes.length ? themes.filter((theme) => theme.available) : [{ id: "modern", name: "Modern", category: "Professional", layout: "header-band", swatches: ["#3157d5", "#eef1f4", "#fff"] } as CvTheme]).map((theme) => <Paper key={theme.id} role="button" tabIndex={0} aria-pressed={newTheme === theme.id} onClick={() => setNewTheme(theme.id)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setNewTheme(theme.id); } }} variant="outlined" sx={{ p: 0.75, cursor: "pointer", borderWidth: newTheme === theme.id ? 2 : 1, borderColor: newTheme === theme.id ? "primary.main" : "divider", "&:focus-visible": { outline: "3px solid", outlineColor: "primary.main" } }}><CvTemplateThumbnail theme={theme} height={105} /><Typography variant="body2" sx={{ fontWeight: 700, mt: 0.75 }}>{theme.name}</Typography><Typography variant="caption" color="text.secondary">{theme.category}</Typography></Paper>)}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions><Button onClick={() => setCreateOpen(false)} disabled={creating}>Cancel</Button><Button variant="contained" disabled={creating || !newName.trim()} onClick={() => void confirmCreate()}>{creating ? "Creating…" : "Create CV"}</Button></DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from "react";
|
||||
import { Box, Button, Chip, TextField, Typography } from "@mui/material";
|
||||
import { Box, Button, Chip, IconButton, Stack, TextField, Typography } from "@mui/material";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
|
||||
import RichTextField from "../../components/RichTextField";
|
||||
import { useI18n } from "../../i18n/I18nProvider";
|
||||
@@ -87,6 +89,11 @@ export function PersonalInformationSection({
|
||||
</Box>
|
||||
<TextField label={t("profileCvContactWebsite")} value={value.website ?? ""} onChange={(e) => set({ website: e.target.value || undefined })} fullWidth />
|
||||
<TextField label={t("profileCvContactLinkedIn")} value={value.linkedIn ?? ""} onChange={(e) => set({ linkedIn: e.target.value || undefined })} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
|
||||
<TextField label="GitHub" value={value.gitHub ?? ""} onChange={(e) => set({ gitHub: e.target.value || undefined })} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
|
||||
<Box sx={{ gridColumn: "1 / -1" }}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1 }}><Typography variant="subtitle2">Other links</Typography><Button size="small" startIcon={<AddIcon />} onClick={() => set({ links: [...(value.links ?? []), { label: "", url: "" }] })}>Add link</Button></Stack>
|
||||
<Stack spacing={1}>{(value.links ?? []).map((link, index) => <Stack key={index} direction={{ xs: "column", sm: "row" }} spacing={1} alignItems={{ sm: "center" }}><TextField size="small" label="Label" value={link.label ?? ""} onChange={(event) => set({ links: (value.links ?? []).map((item, itemIndex) => itemIndex === index ? { ...item, label: event.target.value || undefined } : item) })} sx={{ flex: "0 1 180px" }} /><TextField size="small" label="URL" value={link.url ?? ""} onChange={(event) => set({ links: (value.links ?? []).map((item, itemIndex) => itemIndex === index ? { ...item, url: event.target.value || undefined } : item) })} fullWidth /><IconButton aria-label={`Delete custom link ${index + 1}`} onClick={() => set({ links: (value.links ?? []).filter((_, itemIndex) => itemIndex !== index) })}><DeleteOutlineIcon fontSize="small" /></IconButton></Stack>)}</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user