diff --git a/job-tracker-ui/src/App.tsx b/job-tracker-ui/src/App.tsx index 81ea16c..3019615 100644 --- a/job-tracker-ui/src/App.tsx +++ b/job-tracker-ui/src/App.tsx @@ -49,6 +49,9 @@ const RemindersView = lazy(() => import("./components/RemindersView")); const QuickCommandDialog = lazy(() => import("./components/QuickCommandDialog")); const ProfilePage = lazy(() => import("./views/ProfilePage")); const CareerWorkspacePage = lazy(() => import("./views/CareerWorkspacePage")); +const CvBuilderPage = lazy(() => import("./views/CvBuilderPage")); +const CvBuilderEditor = lazy(() => import("./views/CvBuilderEditor")); +const PublicCvPage = lazy(() => import("./views/PublicCvPage")); const ConnectedAccountsPage = lazy(() => import("./views/ConnectedAccountsPage")); const AdminAuditPage = lazy(() => import("./views/AdminAuditPage")); const AdminUsersPage = lazy(() => import("./views/AdminUsersPage")); @@ -242,6 +245,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo { to: "/correspondence", label: "Correspondence", icon: , section: t("manage") }, { to: "/correspondence/review", label: "Gmail review", icon: , section: t("manage") }, { to: "/career", label: "Career Workspace", icon: , section: t("manage") }, + { to: "/career/builder", label: "CV Builder", icon: , section: t("manage") }, { to: "/trash", label: t("trash"), icon: , section: t("manage") }, ]; @@ -323,6 +327,8 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo } /> } /> } /> + } /> + } /> } /> } /> } /> @@ -378,6 +384,7 @@ export default function App() { { path: "/forgot-password", element: , errorElement: }, { path: "/reset-password", element: , errorElement: }, { path: "/verify-email", element: , errorElement: }, + { path: "/cv/:slug", element: , errorElement: }, { path: "/*", element: , errorElement: }, ], { future: { v7_relativeSplatPath: true } }), [jobColumns, jobPageSize, themeMode]); diff --git a/job-tracker-ui/src/cv-builder-page.test.tsx b/job-tracker-ui/src/cv-builder-page.test.tsx new file mode 100644 index 0000000..84769ac --- /dev/null +++ b/job-tracker-ui/src/cv-builder-page.test.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +import CvBuilderPage from './views/CvBuilderPage'; +import { I18nProvider } from './i18n/I18nProvider'; +import { ToastProvider } from './toast'; +import { api } from './api'; + +const mockNavigate = jest.fn(); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockNavigate, +})); + +jest.mock('./api', () => ({ + api: { + get: jest.fn(), + post: jest.fn(), + put: jest.fn(), + patch: jest.fn(), + delete: jest.fn(), + interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } }, + }, + getApiErrorMessage: (_error: any, fallback?: string) => fallback || 'Request failed.', +})); + +const mockedApi = api as jest.Mocked; + +function renderPage() { + return render( + + + + + + + , + ); +} + +beforeEach(() => { + jest.clearAllMocks(); +}); + +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() }, + ], + } as any); + + renderPage(); + + expect(await screen.findByText('Frontend CV')).toBeInTheDocument(); + expect(screen.getByText('Public')).toBeInTheDocument(); + expect(mockedApi.get).toHaveBeenCalledWith('/cv/variants'); +}); + +test('shows the empty state and creates a CV then navigates to the editor', async () => { + mockedApi.get.mockResolvedValueOnce({ data: [] } as any); + mockedApi.post.mockResolvedValueOnce({ data: { id: 42 } } as any); + + renderPage(); + + expect(await screen.findByText('No CVs yet')).toBeInTheDocument(); + + fireEvent.click(screen.getAllByRole('button', { name: /New CV/i })[0]); + + await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants', expect.objectContaining({ name: 'Untitled CV' }))); + await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/career/builder/42')); +}); diff --git a/job-tracker-ui/src/cvBuilder.ts b/job-tracker-ui/src/cvBuilder.ts new file mode 100644 index 0000000..4040db3 --- /dev/null +++ b/job-tracker-ui/src/cvBuilder.ts @@ -0,0 +1,121 @@ +import { api } from "./api"; + +// Mirrors the backend CvVariantSettings (the lens over the master career profile). +export type CvSectionSetting = { key: string; hidden?: boolean; title?: string }; +export type CvItemOverride = { hidden?: boolean; title?: string; subtitle?: string; bullets?: string[] }; +export type CvCustomSectionSetting = { key: string; title?: string; items: string[]; hidden?: boolean }; + +export type CvVariantSettings = { + themeId: string; + accentColor?: string | null; + headingFont?: string | null; + bodyFont?: string | null; + density?: string | null; + pageSize?: string | null; + dateFormat?: string | null; + language?: string | null; + headline?: string | null; + showPhoto: boolean; + showPageNumbers: boolean; + showIcons: boolean; + sections: CvSectionSetting[]; + overrides: Record; + customSections: CvCustomSectionSetting[]; +}; + +export type CvTheme = { + id: string; + name: string; + category: string; + description: string; + layout: string; + accent: string; + photoShape: string; + supportsIcons: boolean; + swatches: string[]; +}; + +export type CvVariantSummary = { + id: number; + name: string; + themeId: string; + publicSlug: string; + isPublic: boolean; + version: number; + jobApplicationId: number | null; + updatedAtUtc: string; +}; + +export type CvVariant = { + id: number; + name: string; + settings: CvVariantSettings; + isPublic: boolean; + publicSlug: string; + version: number; + jobApplicationId: number | null; + updatedAtUtc: string; +}; + +export type CvVariantVersionInfo = { version: number; source: string; createdAtUtc: string; isCurrent: boolean }; +export type CvRender = { themeId: string; html: string; suggestedFileName: string }; + +export const AI_ACTIONS: { key: string; label: string }[] = [ + { key: "improve", label: "Improve writing" }, + { key: "professional", label: "Professional tone" }, + { key: "shorten", label: "Shorten" }, + { key: "expand", label: "Expand" }, + { key: "grammar", label: "Fix grammar" }, + { key: "ats", label: "ATS optimise" }, + { key: "bullets", label: "Generate bullets" }, + { key: "summary", label: "Generate summary" }, + { key: "rewrite", label: "Rewrite" }, + { key: "tailor", label: "Tailor to role" }, +]; + +export const DEFAULT_SECTION_ORDER = [ + "summary", "experience", "education", "projects", "skills", "certifications", "languages", "interests", +]; + +export const SECTION_LABELS: Record = { + summary: "Professional Summary", + experience: "Experience", + education: "Education", + projects: "Projects", + skills: "Skills", + certifications: "Certifications", + languages: "Languages", + interests: "Interests", +}; + +export function emptyCvVariantSettings(themeId = "modern"): CvVariantSettings { + return { + themeId, + showPhoto: false, + showPageNumbers: false, + showIcons: true, + sections: [], + overrides: {}, + customSections: [], + }; +} + +// --- API --- +export const cvBuilderApi = { + themes: () => api.get("/cv/themes").then((r) => r.data), + list: () => api.get("/cv/variants").then((r) => r.data), + create: (body: { name?: string; jobApplicationId?: number | null; settings?: CvVariantSettings }) => + api.post("/cv/variants", body).then((r) => r.data), + get: (id: number) => api.get(`/cv/variants/${id}`).then((r) => r.data), + save: (id: number, body: { name?: string; settings: CvVariantSettings; source?: string }) => + api.put(`/cv/variants/${id}`, body).then((r) => r.data), + remove: (id: number) => api.delete(`/cv/variants/${id}`), + duplicate: (id: number, name?: string) => api.post(`/cv/variants/${id}/duplicate`, { name }).then((r) => r.data), + setPublic: (id: number, isPublic: boolean) => api.put(`/cv/variants/${id}/public`, { isPublic }).then((r) => r.data), + versions: (id: number) => api.get(`/cv/variants/${id}/versions`).then((r) => r.data), + restore: (id: number, version: number) => api.post(`/cv/variants/${id}/versions/${version}/restore`).then((r) => r.data), + previewSettings: (settings: CvVariantSettings) => api.post("/cv/preview", { settings }).then((r) => r.data), + aiAssist: (body: { action: string; text: string; role?: string; language?: string; context?: string }) => + api.post<{ original: string; result: string }>("/cv/ai/assist", body).then((r) => r.data), + exportPdfUrl: (id: number) => `/cv/variants/${id}/export-pdf`, +}; diff --git a/job-tracker-ui/src/views/CareerWorkspacePage.tsx b/job-tracker-ui/src/views/CareerWorkspacePage.tsx index 4bae354..de69577 100644 --- a/job-tracker-ui/src/views/CareerWorkspacePage.tsx +++ b/job-tracker-ui/src/views/CareerWorkspacePage.tsx @@ -1,6 +1,8 @@ import React from "react"; +import { useNavigate } from "react-router-dom"; -import { Alert, Box, Paper, Typography } from "@mui/material"; +import { Alert, Box, Button, Paper, Typography } from "@mui/material"; +import DescriptionOutlinedIcon from "@mui/icons-material/DescriptionOutlined"; import CareerProfilePage from "./CareerProfilePage"; @@ -8,13 +10,17 @@ import CareerProfilePage from "./CareerProfilePage"; // truth for all generated documents. It is a dedicated component (CareerProfilePage), no longer a // careerOnly fork of ProfilePage. The CV Builder is deliberately NOT here yet (Phase 4). export default function CareerWorkspacePage() { + const navigate = useNavigate(); return ( - - Career Workspace - - Maintain the master career profile that powers your CVs, tailored application material, and future portfolio outputs. - + + + Career Workspace + + Maintain the master career profile that powers your CVs, tailored application material, and future portfolio outputs. + + + Your master profile is the source of truth. Job-specific CV drafts remain separate and never overwrite it. diff --git a/job-tracker-ui/src/views/CvBuilderEditor.tsx b/job-tracker-ui/src/views/CvBuilderEditor.tsx new file mode 100644 index 0000000..a016129 --- /dev/null +++ b/job-tracker-ui/src/views/CvBuilderEditor.tsx @@ -0,0 +1,497 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; + +import { + Alert, Box, Button, Chip, Divider, FormControl, FormControlLabel, IconButton, InputLabel, + MenuItem, Paper, Select, Slider, Stack, Switch, Tab, Tabs, TextField, Tooltip, Typography, +} from "@mui/material"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward"; +import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward"; +import VisibilityIcon from "@mui/icons-material/Visibility"; +import VisibilityOffIcon from "@mui/icons-material/VisibilityOff"; +import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf"; +import PublicIcon from "@mui/icons-material/Public"; +import AddIcon from "@mui/icons-material/Add"; +import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; +import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh"; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; + +import { api, getApiErrorMessage } from "../api"; +import { useToast } from "../toast"; +import { + AI_ACTIONS, CvCustomSectionSetting, CvSectionSetting, CvTheme, CvVariant, CvVariantSettings, + CvVariantVersionInfo, DEFAULT_SECTION_ORDER, SECTION_LABELS, cvBuilderApi, +} from "../cvBuilder"; + +const FONTS = [ + "'Segoe UI', Roboto, Arial, sans-serif", + "Arial, Helvetica, sans-serif", + "Georgia, 'Times New Roman', serif", + "'Helvetica Neue', Arial, sans-serif", + "'Roboto', Arial, sans-serif", + "'Poppins', 'Segoe UI', Arial, sans-serif", +]; +const FONT_LABELS = ["Segoe UI", "Arial", "Georgia (serif)", "Helvetica Neue", "Roboto", "Poppins"]; + +export default function CvBuilderEditor() { + const { id } = useParams(); + const variantId = Number(id); + const navigate = useNavigate(); + const { toast } = useToast(); + + const [name, setName] = useState(""); + const [settings, setSettings] = useState(null); + const [themes, setThemes] = useState([]); + const [isPublic, setIsPublic] = useState(false); + const [publicSlug, setPublicSlug] = useState(""); + const [tab, setTab] = useState(0); + const [html, setHtml] = useState(""); + const [zoom, setZoom] = useState(0.62); + const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "error">("idle"); + const [versions, setVersions] = useState([]); + const [loadError, setLoadError] = useState(null); + + const saveTimer = useRef | null>(null); + const previewTimer = useRef | null>(null); + const dirty = useRef(false); + + // Load variant + theme catalog once. + useEffect(() => { + let alive = true; + (async () => { + try { + const [variant, themeList] = await Promise.all([cvBuilderApi.get(variantId), cvBuilderApi.themes()]); + if (!alive) return; + applyVariant(variant); + setThemes(themeList); + } catch (err) { + if (alive) setLoadError(getApiErrorMessage(err, "Could not open this CV.")); + } + })(); + return () => { + alive = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [variantId]); + + const applyVariant = (variant: CvVariant) => { + setName(variant.name); + setSettings(variant.settings); + setIsPublic(variant.isPublic); + setPublicSlug(variant.publicSlug); + }; + + // Debounced live preview whenever settings change. + useEffect(() => { + if (!settings) return; + if (previewTimer.current) clearTimeout(previewTimer.current); + previewTimer.current = setTimeout(async () => { + try { + const render = await cvBuilderApi.previewSettings(settings); + setHtml(render.html); + } catch { + /* preview is best-effort; keep the last good render */ + } + }, 350); + return () => { + if (previewTimer.current) clearTimeout(previewTimer.current); + }; + }, [settings]); + + // Debounced autosave. + const scheduleSave = useCallback( + (next: CvVariantSettings, nextName?: string) => { + dirty.current = true; + setSaveState("saving"); + if (saveTimer.current) clearTimeout(saveTimer.current); + saveTimer.current = setTimeout(async () => { + try { + await cvBuilderApi.save(variantId, { name: nextName ?? name, settings: next, source: "autosave" }); + dirty.current = false; + setSaveState("saved"); + } catch { + setSaveState("error"); + } + }, 800); + }, + [variantId, name], + ); + + const update = (patch: Partial) => { + setSettings((prev) => { + if (!prev) return prev; + const next = { ...prev, ...patch }; + scheduleSave(next); + return next; + }); + }; + + const renameVariant = (value: string) => { + setName(value); + if (settings) scheduleSave(settings, value); + }; + + // Section rows: settings.sections is authoritative once touched; otherwise the default order, + // always ensuring every known section is present so it can be reordered/hidden. + const sectionRows: CvSectionSetting[] = useMemo(() => { + if (!settings) return []; + const base = 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 }); + return base; + }, [settings]); + + const writeSections = (rows: CvSectionSetting[]) => update({ sections: rows }); + + const moveSection = (index: number, delta: number) => { + const rows = [...sectionRows]; + const target = index + delta; + if (target < 0 || target >= rows.length) return; + [rows[index], rows[target]] = [rows[target], rows[index]]; + writeSections(rows); + }; + + const toggleSection = (index: number) => { + const rows = sectionRows.map((r, i) => (i === index ? { ...r, hidden: !r.hidden } : r)); + writeSections(rows); + }; + + const renameSection = (index: number, title: string) => { + const rows = sectionRows.map((r, i) => (i === index ? { ...r, title: title || undefined } : r)); + writeSections(rows); + }; + + const togglePublic = async () => { + try { + const updated = await cvBuilderApi.setPublic(variantId, !isPublic); + setIsPublic(updated.isPublic); + setPublicSlug(updated.publicSlug); + toast(updated.isPublic ? "CV is now public." : "CV is now private.", "success"); + } catch (err) { + toast(getApiErrorMessage(err, "Could not change visibility."), "error"); + } + }; + + const copyPublicLink = () => { + const url = `${window.location.origin}/cv/${publicSlug}`; + navigator.clipboard?.writeText(url); + toast("Public link copied.", "success"); + }; + + const exportPdf = async () => { + try { + const res = await api.post(cvBuilderApi.exportPdfUrl(variantId), {}, { responseType: "blob" }); + const url = URL.createObjectURL(res.data as Blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${name || "cv"}.pdf`; + a.click(); + URL.revokeObjectURL(url); + } catch (err) { + toast(getApiErrorMessage(err, "PDF export failed."), "error"); + } + }; + + const loadVersions = async () => { + try { + setVersions(await cvBuilderApi.versions(variantId)); + } catch (err) { + toast(getApiErrorMessage(err, "Could not load history."), "error"); + } + }; + + const restore = async (version: number) => { + try { + const updated = await cvBuilderApi.restore(variantId, version); + applyVariant(updated); + await loadVersions(); + toast(`Restored version ${version}.`, "success"); + } catch (err) { + toast(getApiErrorMessage(err, "Restore failed."), "error"); + } + }; + + if (loadError) { + return ( + + + {loadError} + + ); + } + if (!settings) return Loading…; + + return ( + + {/* Left: controls */} + + + navigate("/career/builder")}> + renameVariant(e.target.value)} + slotProps={{ input: { style: { fontWeight: 800, fontSize: "1.05rem" } } }} /> + + + + + + {isPublic && } + + + { setTab(v); if (v === 3) loadVersions(); }} variant="fullWidth" sx={{ mb: 1.5 }}> + + + + + + + {tab === 0 && ( + + )} + {tab === 1 && } + {tab === 2 && } + {tab === 3 && } + + + {/* Right: live preview */} + + + Live preview + + Zoom + setZoom(v as number)} sx={{ width: 120 }} /> + + + +