Files
jobtrackingapp/job-tracker-ui/src/components/CoverLetterDocument.tsx
T

224 lines
16 KiB
TypeScript

import React, { useEffect, useId, useRef, useState } from "react";
import { Alert, Box, Button, Checkbox, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, MenuItem, Stack, TextField, Typography } from "@mui/material";
import { api } from "../api";
import { applicationAssetsApi, CoverLetterDocumentSettings } from "../applicationWorkspace";
import { cvBuilderApi, CvTheme, CvVariantSummary, getCvPageMetrics, getCvPreviewFitZoom } from "../cvBuilder";
import { previewDocumentHtml } from "../cvPreview";
import { StructuredCvProfile } from "../profileCv";
import { useI18n } from "../i18n/I18nProvider";
import type { TranslationKey } from "../i18n/translations";
const contactFields = ["senderName", "senderTitle", "senderEmail", "senderPhone", "senderLocation"] as const;
const detailFields = [...contactFields, "recipient", "subject", "date"] as const;
const fieldLabels: Record<typeof detailFields[number], TranslationKey> = {
senderName: "letterSenderName", senderTitle: "letterSenderTitle", senderEmail: "letterSenderEmail", senderPhone: "letterSenderPhone", senderLocation: "letterSenderLocation",
recipient: "letterRecipient", subject: "letterSubject", date: "letterDate",
};
type Props = { settings: CoverLetterDocumentSettings; disabled: boolean; onChange: (settings: CoverLetterDocumentSettings) => void };
export function CoverLetterDocumentFields({ settings, disabled, onChange }: Props) {
const { t } = useI18n();
const [copy, setCopy] = useState<"contact" | "design" | null>(null);
const [choosingTemplate, setChoosingTemplate] = useState(false);
return <Stack spacing={1.5} sx={{ mb: 2 }}>
<Typography component="h4" variant="subtitle2">{t("letterDetails")}</Typography>
<Stack direction="row" gap={1} flexWrap="wrap">
<Button disabled={disabled} onClick={() => setCopy("contact")}>{t("letterCopyContact")}</Button>
<Button disabled={disabled} onClick={() => setCopy("design")}>{t("letterMatchDesign")}</Button>
<Button disabled={disabled} onClick={() => setChoosingTemplate(true)}>{t("letterChooseTemplate")}</Button>
</Stack>
<TextField select size="small" label={t("letterDocumentLanguage")} value={settings.language ?? "en"} disabled={disabled}
onChange={event => onChange({ ...settings, language: event.target.value as "en" | "nb-NO" })}>
<MenuItem value="en">{t("languageEnglish")}</MenuItem>
<MenuItem value="nb-NO">{t("languageNorwegianBokmal")}</MenuItem>
</TextField>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", sm: "repeat(2, minmax(0, 1fr))" }, gap: 1.5 }}>
{detailFields.map(key => <TextField key={key} label={t(fieldLabels[key])} size="small" fullWidth multiline={key === "recipient"} value={settings[key]} disabled={disabled}
slotProps={{ htmlInput: { maxLength: key === "recipient" ? 2000 : key === "subject" ? 500 : key === "date" || key === "senderPhone" ? 100 : 300 } }}
onChange={event => onChange({ ...settings, [key]: event.target.value })} />)}
</Box>
{copy && <CopyLetterSnapshot kind={copy} settings={settings} onClose={() => setCopy(null)} onApply={next => { if (!disabled) onChange(next); setCopy(null); }} />}
{choosingTemplate && <LetterTemplatePicker settings={settings} disabled={disabled} onClose={() => setChoosingTemplate(false)} onApply={next => { if (!disabled) onChange(next); setChoosingTemplate(false); }} />}
</Stack>;
}
type ThemeDefaults = CvTheme & { bodyFont: string; baseFontSizePt: number; lineHeight: number };
function LetterTemplatePicker({ settings, disabled, onClose, onApply }: Omit<Props, "onChange"> & { onClose: () => void; onApply: (settings: CoverLetterDocumentSettings) => void }) {
const { t } = useI18n();
const id = useId();
const [themes, setThemes] = useState<CvTheme[]>([]);
const [selected, setSelected] = useState(settings.themeId);
const [loading, setLoading] = useState(true);
const [failed, setFailed] = useState(false);
const [attempt, setAttempt] = useState(0);
useEffect(() => {
let current = true;
setLoading(true); setFailed(false);
cvBuilderApi.themes().then(rows => {
if (current) setThemes(rows);
}).catch(() => { if (current) setFailed(true); }).finally(() => { if (current) setLoading(false); });
return () => { current = false; };
}, [attempt]);
const theme = themes.find(row => row.id === selected);
const ready = theme?.available && !!theme.bodyFont && !!theme.baseFontSizePt && !!theme.lineHeight && !!theme.pageMarginMm;
const apply = () => {
if (!ready || !theme || disabled) return;
onApply({ ...settings, designVersion: 2, themeId: theme.id, accentColor: theme.accent,
bodyFont: theme.bodyFont!, headingFont: theme.headingFont ?? theme.bodyFont!, baseFontSizePt: theme.baseFontSizePt!,
lineHeight: theme.lineHeight!, pageMarginMm: theme.pageMarginMm! });
};
return <Dialog open onClose={onClose} fullWidth maxWidth="sm" aria-labelledby={`${id}-title`} aria-describedby={`${id}-help`}>
<DialogTitle id={`${id}-title`}>{t("letterChooseTemplate")}</DialogTitle>
<DialogContent dividers><Stack spacing={2}>
<Typography id={`${id}-help`} variant="body2">{t("letterTemplateHelp")}</Typography>
{loading && <Typography role="status">{t("loading")}</Typography>}
{failed && <Alert severity="error" action={<Button disabled={disabled} onClick={() => setAttempt(value => value + 1)}>{t("retry")}</Button>}>{t("letterTemplateFailed")}</Alert>}
<TextField select fullWidth size="small" label={t("letterTemplate")} disabled={loading || failed || disabled}
value={themes.some(row => row.id === selected) ? selected : ""} onChange={event => setSelected(event.target.value)}>
{themes.map(row => <MenuItem key={row.id} value={row.id} disabled={!row.available}>{row.name ?? row.id}{row.available ? "" : ` — ${t("cvEditorPro")}`}</MenuItem>)}
</TextField>
{!loading && !failed && !ready && <Typography variant="body2">{t("letterTemplateUnavailable")}</Typography>}
</Stack></DialogContent>
<DialogActions><Button autoFocus onClick={onClose}>{t("cancel")}</Button><Button variant="contained" disabled={disabled || loading || failed || !ready} onClick={apply}>{t("letterTemplateApply")}</Button></DialogActions>
</Dialog>;
}
function CopyLetterSnapshot({ kind, settings, onClose, onApply }: { kind: "contact" | "design"; settings: CoverLetterDocumentSettings; onClose: () => void; onApply: (settings: CoverLetterDocumentSettings) => void }) {
const { t } = useI18n();
const id = useId();
const [variants, setVariants] = useState<CvVariantSummary[]>([]);
const [selected, setSelected] = useState<number | "">("");
const [snapshot, setSnapshot] = useState<CoverLetterDocumentSettings | null>(null);
const [failed, setFailed] = useState(false);
const [attempt, setAttempt] = useState(0);
const [loading, setLoading] = useState(true);
const [selectedContact, setSelectedContact] = useState<readonly typeof contactFields[number][]>(contactFields.filter(key => key !== "senderLocation"));
useEffect(() => {
let current = true;
setLoading(true); setFailed(false);
cvBuilderApi.list().then(rows => { if (current) setVariants(rows); }).catch(() => { if (current) setFailed(true); }).finally(() => { if (current) setLoading(false); });
return () => { current = false; };
}, [attempt]);
useEffect(() => {
let current = true;
setSnapshot(null);
if (!selected) return;
setLoading(true); setFailed(false);
const load = async () => {
const variant = await cvBuilderApi.get(selected);
if (!current) return null;
if (kind === "contact") {
const { data } = await api.get<{ profile: StructuredCvProfile }>("/career/profile");
const contact = data.profile.contact;
return { ...settings, senderName: contact.fullName ?? "", senderTitle: variant.settings.headline?.trim() || contact.headline || "", senderEmail: contact.email ?? "", senderPhone: contact.phone ?? "", senderLocation: contact.location ?? "", sourceCvId: selected };
}
const themes = await cvBuilderApi.themes() as ThemeDefaults[];
const theme = themes.find(row => row.id === variant.settings.themeId && row.available);
if (!theme || !theme.bodyFont || !theme.baseFontSizePt || !theme.lineHeight || !theme.pageMarginMm) throw new Error("Missing effective theme defaults");
const source = variant.settings;
const density = source.density === "compact" ? 0.82 : source.density === "roomy" ? 1.18 : 1;
const margin = Math.min(28, Math.max(8, Math.round((source.pageMarginMm ?? theme.pageMarginMm) * density * 100) / 100));
return { ...settings, designVersion: 2, themeId: theme.id, accentColor: source.accentColor ?? theme.accent, bodyFont: source.bodyFont ?? theme.bodyFont,
headingFont: source.headingFont ?? theme.headingFont ?? theme.bodyFont,
baseFontSizePt: source.baseFontSizePt ?? theme.baseFontSizePt, lineHeight: source.lineHeight ?? theme.lineHeight,
pageMarginMm: margin, pageSize: source.pageSize ?? "a4", sourceCvId: selected };
};
load().then(next => { if (current) setSnapshot(next); }).catch(() => { if (current) setFailed(true); }).finally(() => { if (current) setLoading(false); });
return () => { current = false; };
// The dialog captures the original local draft until the user explicitly applies or cancels.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selected, kind, attempt]);
const apply = () => {
if (!snapshot) return;
if (kind === "design") { onApply(snapshot); return; }
const next = { ...settings };
const chosen = selectedContact.filter(key => snapshot[key].trim());
for (const key of chosen) next[key] = snapshot[key];
if (chosen.length) next.sourceCvId = snapshot.sourceCvId;
onApply(next);
};
return <Dialog open onClose={onClose} fullWidth maxWidth="sm" aria-labelledby={`${id}-title`} aria-describedby={`${id}-help`}>
<DialogTitle id={`${id}-title`}>{t(kind === "contact" ? "letterCopyContact" : "letterMatchDesign")}</DialogTitle>
<DialogContent dividers>
<Stack spacing={2}>
<Typography id={`${id}-help`} variant="body2">{t("letterCopyHelp")}</Typography>
{kind === "contact" && <Typography variant="body2">{t("letterCopyContactHelp")}</Typography>}
<TextField select fullWidth size="small" label={t("letterSourceCv")} value={selected} disabled={loading} onChange={event => setSelected(Number(event.target.value))}>
{variants.map(variant => <MenuItem key={variant.id} value={variant.id}>{variant.name}</MenuItem>)}
</TextField>
{!loading && !failed && variants.length === 0 && <Typography variant="body2">{t("assetsNoCvVariants")}</Typography>}
{failed && <Alert severity="error" action={<Button onClick={() => setAttempt(value => value + 1)}>{t("retry")}</Button>}>{t("letterCopyFailed")}</Alert>}
{snapshot && <Box role="region" aria-label={t(kind === "contact" ? "letterContactSnapshot" : "letterDesignSnapshot")} sx={{ overflowWrap: "anywhere" }}>
{kind === "contact" ? <Stack>{contactFields.map(key => <FormControlLabel key={key} label={`${t(fieldLabels[key])}: ${snapshot[key]}`}
control={<Checkbox size="small" disabled={!snapshot[key].trim()} checked={selectedContact.includes(key) && !!snapshot[key].trim()}
onChange={(_, checked) => setSelectedContact(current => checked ? [...current, key] : current.filter(field => field !== key))} />} />)}</Stack> : <>
<Typography variant="body2">{snapshot.themeId} · {snapshot.accentColor} · {snapshot.bodyFont}</Typography>
<Typography variant="body2">{snapshot.baseFontSizePt} pt · {snapshot.lineHeight} · {snapshot.pageMarginMm} mm · {snapshot.pageSize}</Typography>
</>}
</Box>}
</Stack>
</DialogContent>
<DialogActions><Button autoFocus onClick={onClose}>{t("cancel")}</Button><Button variant="contained" disabled={loading || failed || !snapshot} onClick={apply}>{t("letterCopyApply")}</Button></DialogActions>
</Dialog>;
}
export function CoverLetterDocumentPreview({ jobId, text, settings, active }: { jobId: number; text: string; settings: CoverLetterDocumentSettings; active: boolean }) {
const { t } = useI18n();
const host = useRef<HTMLDivElement>(null);
const frame = useRef<HTMLIFrameElement>(null);
const [html, setHtml] = useState("");
const [pageSize, setPageSize] = useState(settings.pageSize);
const [loading, setLoading] = useState(false);
const [failed, setFailed] = useState(false);
const [attempt, setAttempt] = useState(0);
const [pages, setPages] = useState(1);
const [page, setPage] = useState(0);
const [zoom, setZoom] = useState(0.5);
const metrics = getCvPageMetrics(pageSize);
useEffect(() => {
let current = true;
if (!active) return;
setLoading(true); setFailed(false);
const timer = window.setTimeout(() => {
applicationAssetsApi.previewCoverLetter(jobId, text, settings).then(result => {
if (!current) return;
setHtml(result.html); setPageSize(settings.pageSize); setPage(0); setPages(1);
}).catch(() => { if (current) setFailed(true); }).finally(() => { if (current) setLoading(false); });
}, 300);
return () => { current = false; window.clearTimeout(timer); };
}, [jobId, text, settings, active, attempt]);
useEffect(() => {
const element = host.current;
if (!element || !active) return;
const fit = () => setZoom(getCvPreviewFitZoom(element.clientWidth || metrics.widthPx, metrics.widthPx, 16, 0.2));
fit();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(fit); observer.observe(element);
return () => observer.disconnect();
}, [metrics.widthPx, active]);
const measure = () => {
const element = frame.current;
const doc = element?.contentDocument;
const ready = () => { if (element === frame.current && doc === element?.contentDocument) setPages(Number(doc?.documentElement.dataset.cvPreviewPages) || 1); };
if (doc?.documentElement.dataset.cvPreviewReady === "true" || !doc?.querySelector(".page")) ready();
else element?.contentWindow?.addEventListener("cv-preview-ready", ready, { once: true });
};
return <Stack spacing={1}>
<Typography component="h4" variant="subtitle2">{t("letterDocumentPreview")}</Typography>
{loading && <Typography role="status" variant="caption">{t("letterPreviewLoading")}</Typography>}
{failed && <Alert severity="warning" action={<Button onClick={() => setAttempt(value => value + 1)}>{t("retry")}</Button>}>{t("letterPreviewFailed")}</Alert>}
<Box ref={host} aria-busy={loading} sx={{ minWidth: 0, overflow: "auto", p: 1, bgcolor: "background.default" }}>
{html && <Box sx={{ width: metrics.widthPx * zoom, height: metrics.heightPx * zoom, mx: "auto", bgcolor: "common.white", boxShadow: 1, overflow: "hidden" }}>
<iframe ref={frame} title={t("letterDocumentPreview")} srcDoc={previewDocumentHtml(html, metrics.heightPx, page)} sandbox="allow-same-origin allow-scripts" onLoad={measure}
style={{ display: "block", border: 0, width: metrics.widthPx, height: metrics.heightPx, transform: `scale(${zoom})`, transformOrigin: "top left", background: "white" }} />
</Box>}
</Box>
{pages > 1 && <Stack direction="row" justifyContent="space-between" alignItems="center" gap={1}>
<Button disabled={page === 0} onClick={() => setPage(value => value - 1)}>{t("cvEditorPrevious")}</Button>
<Typography variant="caption">{t("letterPage", { page: page + 1, count: pages })}</Typography>
<Button disabled={page >= pages - 1} onClick={() => setPage(value => value + 1)}>{t("cvEditorNext")}</Button>
</Stack>}
</Stack>;
}