import React, { useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { 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 MoreVertIcon from "@mui/icons-material/MoreVert"; import PublicIcon from "@mui/icons-material/Public"; import { api, getApiErrorMessage } from "../api"; import { useToast } from "../toast"; import { CvTheme, CvVariantSummary, cvBuilderApi, emptyCvVariantSettings } from "../cvBuilder"; import { useDialogActions } from "../dialogs"; import CvTemplateGallery from "../components/CvTemplateGallery"; import CvTemplateThumbnail from "../components/CvTemplateThumbnail"; import { useI18n } from "../i18n/I18nProvider"; import type { TranslationKey } from "../i18n/translations"; import { PageHeader } from "../layout/PageLayout"; import ViewStateNotice from "../components/ViewStateNotice"; import { useViewResource } from "../hooks/useViewResource"; export default function CvBuilderPage() { const navigate = useNavigate(); const { toast } = useToast(); const { language, t } = useI18n(); const localeRef = useRef(t); localeRef.current = t; const { confirmAction, promptForValue } = useDialogActions(); const resource = useViewResource(async () => ({ variants: await cvBuilderApi.list(), themes: await cvBuilderApi.themes().catch(() => []), }), { initialData: { variants: [] as CvVariantSummary[], themes: [] as CvTheme[] }, errorMessage: "" }); const { variants, themes } = resource.data; const loading = resource.loading || resource.refreshing; const contentRef = useRef(null); const createTitleRef = useRef(null); const actionBusy = useRef(false); const actionAnchor = useRef(null); const [pending, setPending] = useState(false); const [actionError, setActionError] = useState<{ error?: unknown; key: TranslationKey } | null>(null); const [createError, setCreateError] = useState<{ error: unknown } | null>(null); const [menu, setMenu] = useState<{ anchor: HTMLElement; id: number } | null>(null); const [createOpen, setCreateOpen] = useState(false); const [newName, setNewName] = useState(t("cvDashboardUntitled")); const [newTheme, setNewTheme] = useState("modern"); const [creating, setCreating] = useState(false); useEffect(() => { if (pending) contentRef.current?.focus(); else if (document.activeElement === contentRef.current && actionAnchor.current?.isConnected) actionAnchor.current.focus(); }, [pending]); const startAction = (showPending = true) => { if (actionBusy.current) return false; actionBusy.current = true; actionAnchor.current = menu?.anchor ?? null; setMenu(null); setActionError(null); if (showPending) setPending(true); return true; }; const finishAction = () => { actionBusy.current = false; setPending(false); }; const setVariants = (update: (items: CvVariantSummary[]) => CvVariantSummary[]) => { resource.setData((data) => ({ ...data, variants: update(data.variants) })); }; const createNew = () => { if (actionBusy.current) return; setCreateOpen(true); }; const confirmCreate = async () => { if (!newName.trim() || actionBusy.current) return; actionBusy.current = true; createTitleRef.current?.focus(); setCreateError(null); setCreating(true); try { const variant = await cvBuilderApi.create({ name: newName.trim(), settings: emptyCvVariantSettings(newTheme) }); setCreateOpen(false); navigate(`/career/builder/${variant.id}`); } catch (err) { setCreateError({ error: err }); } finally { actionBusy.current = false; setCreating(false); } }; const duplicate = async (id: number) => { if (!startAction()) return; try { await cvBuilderApi.duplicate(id); await resource.reload(); toast(localeRef.current("cvDashboardDuplicated"), "success"); } catch (err) { setActionError({ error: err, key: "cvDashboardDuplicateFailed" }); } finally { finishAction(); } }; const remove = async (id: number) => { if (!startAction(false)) return; const variant = variants.find((item) => item.id === id); try { if (!(await confirmAction(t("cvDashboardDeleteMessage", { name: variant?.name ?? t("cvDashboardThisCv") }), { title: t("cvDashboardDeleteCv"), confirmLabel: t("cvDashboardDeleteCv"), destructive: true, }))) return; setPending(true); await cvBuilderApi.remove(id); setVariants((v) => v.filter((x) => x.id !== id)); toast(localeRef.current("cvDashboardDeleted"), "success"); } catch (err) { setActionError({ error: err, key: "cvDashboardDeleteFailed" }); } finally { finishAction(); } }; const rename = async (id: number) => { if (!startAction(false)) return; const current = variants.find((item) => item.id === id); try { const nextName = await promptForValue(t("cvDashboardRenameMessage"), current?.name ?? "", { title: t("cvDashboardRenameCv"), confirmLabel: t("cvDashboardRename") }); if (!nextName?.trim() || nextName.trim() === current?.name) return; setPending(true); const variant = await cvBuilderApi.get(id); const updated = await cvBuilderApi.save(id, { name: nextName.trim(), settings: variant.settings, source: "manual", expectedVersion: variant.version }); setVariants((items) => items.map((item) => item.id === id ? { ...item, name: updated.name, updatedAtUtc: updated.updatedAtUtc, version: updated.version } : item)); toast(localeRef.current("cvDashboardRenamed"), "success"); } catch (err) { setActionError({ error: err, key: "cvDashboardRenameFailed" }); } finally { finishAction(); } }; const download = async (id: number, cvName: string) => { if (!startAction()) return; 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) { const status = (err as { response?: { status?: number } })?.response?.status; setActionError({ error: status === 503 || status === 504 ? undefined : err, key: status === 503 ? "cvPdfBusy" : status === 504 ? "cvPdfTimedOut" : "cvDashboardDownloadFailed" }); } finally { finishAction(); } }; return ( } onClick={createNew}>{t("cvDashboardNewCv")}} /> { if (actionBusy.current || loading) return; contentRef.current?.focus(); void resource.reload(); }} compact /> {actionError && {getApiErrorMessage(actionError.error, t(actionError.key))}} {pending && {t("loading")}} {!loading && variants.length === 0 && !resource.error && ( {t("cvDashboardEmptyTitle")} {t("cvDashboardEmptyBody")} )} {variants.map((v) => ( { if (!actionBusy.current) navigate(`/career/builder/${v.id}`); }} onKeyDown={(event) => { if (!actionBusy.current && event.target === event.currentTarget && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); navigate(`/career/builder/${v.id}`); } }} > theme.id === v.themeId)} /> {v.name} { e.stopPropagation(); if (!actionBusy.current) setMenu({ anchor: e.currentTarget, id: v.id }); }}> {v.isPublic && } label={t("cvEditorPublic")} />} {t("cvDashboardUpdatedVersion", { date: new Date(v.updatedAtUtc).toLocaleDateString(language === "nb" ? "nb-NO" : "en-GB"), version: v.version })} {v.jobApplicationId ? ` ยท ${[v.jobTitle, v.companyName].filter(Boolean).join(t("cvDashboardAt")) || t("cvDashboardJobNumber", { id: v.jobApplicationId })}` : ""} ))} setMenu(null)}> menu && navigate(`/career/builder/${menu.id}`)}>{t("cvDashboardOpen")} menu && duplicate(menu.id)}>{t("cvEditorDuplicate")} menu && void rename(menu.id)}>{t("cvDashboardRename")} { const cv = variants.find((item) => item.id === menu?.id); if (cv) void download(cv.id, cv.name); }}>{t("cvEditorDownloadPdf")} menu && remove(menu.id)} sx={{ color: "error.main" }}>{t("cvDashboardDelete")} { if (!actionBusy.current) setCreateOpen(false); }} fullWidth maxWidth="md" aria-labelledby="create-cv-title" aria-describedby={`create-cv-help${createError ? " create-cv-error" : ""}`}> {t("cvDashboardCreateTitle")} {t("cvDashboardCreateHelp")} {createError && {getApiErrorMessage(createError.error, t("cvDashboardCreateFailed"))}} setNewName(event.target.value)} error={!newName.trim()} helperText={!newName.trim() ? t("cvDashboardEnterName") : t("cvDashboardNameExample")} sx={{ mb: 2, "& .MuiFormHelperText-root.Mui-disabled": { color: "text.secondary" } }} /> {t("cvDashboardChooseTemplate")} { if (!actionBusy.current) setNewTheme(theme.id); }} previewHeight={125} showDescriptions={false} /> ); }