feat(cv): qualify templates and reliable local import processing
This commit is contained in:
@@ -19,11 +19,13 @@ type Props = { settings: CoverLetterDocumentSettings; disabled: boolean; onChang
|
||||
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" })}>
|
||||
@@ -36,10 +38,52 @@ export function CoverLetterDocumentFields({ settings, disabled, onChange }: Prop
|
||||
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();
|
||||
@@ -75,7 +119,8 @@ function CopyLetterSnapshot({ kind, settings, onClose, onApply }: { kind: "conta
|
||||
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, themeId: theme.id, accentColor: source.accentColor ?? theme.accent, bodyFont: source.bodyFont ?? theme.bodyFont,
|
||||
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 };
|
||||
};
|
||||
|
||||
@@ -210,7 +210,17 @@ export default function CropImageDialog({
|
||||
|
||||
<Box sx={{ px: 1 }}>
|
||||
<Typography variant="body2" sx={{ mb: 1, fontWeight: 700 }}>{t("cropDialogZoom")}</Typography>
|
||||
<Slider min={1} max={3} step={0.01} value={zoom} onChange={(_, value) => setZoom(value as number)} />
|
||||
<Slider aria-label={t("cropDialogZoom")} disabled={saving} min={1} max={3} step={0.01} value={zoom} onChange={(_, value) => setZoom(value as number)} />
|
||||
<Typography variant="body2" color="text.secondary">{t("cropDialogKeyboardHelp")}</Typography>
|
||||
{(["x", "y"] as const).map(axis => {
|
||||
const min = rendered ? Math.min(0, CROPPER_SIZE - (axis === "x" ? rendered.width : rendered.height)) : 0;
|
||||
const label = t(axis === "x" ? "cropDialogHorizontal" : "cropDialogVertical");
|
||||
return <Box key={axis}>
|
||||
<Typography variant="body2">{label}</Typography>
|
||||
<Slider aria-label={label} disabled={saving || min === 0} min={min} max={0} step={1} value={position[axis]}
|
||||
onChange={(_, value) => setPosition(previous => ({ ...previous, [axis]: clamp(value as number, min, 0) }))} />
|
||||
</Box>;
|
||||
})}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
|
||||
@@ -33,7 +33,7 @@ export function getCvTemplateLabels(theme: CvTheme, language: string, t: (key: T
|
||||
return { name: labels ? t(labels[0]) : theme.name, category: labels ? t(labels[1]) : theme.category, description: labels ? t(labels[2]) : theme.description };
|
||||
}
|
||||
|
||||
export default function CvTemplateGallery({ themes, selectedId, onSelect, previewHeight = 170, showDescriptions = true, disabled = false }: Props) {
|
||||
export default function CvTemplateGallery({ themes, selectedId, onSelect, previewHeight = 230, showDescriptions = true, disabled = false }: Props) {
|
||||
const { t, language } = useI18n();
|
||||
const id = useId();
|
||||
const [atsOnly, setAtsOnly] = useState(true);
|
||||
|
||||
@@ -1,34 +1,64 @@
|
||||
import React from "react";
|
||||
import { Box } from "@mui/material";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Box, Skeleton, Typography } from "@mui/material";
|
||||
import { cvBuilderApi, CvTheme, getCvPageMetrics } from "../cvBuilder";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
import { CvTheme } from "../cvBuilder";
|
||||
// Only the server's synthetic template samples are cached, never a user's CV.
|
||||
const samples = new Map<string, Promise<string>>();
|
||||
function sample(themeId: string) {
|
||||
let pending = samples.get(themeId);
|
||||
if (!pending) {
|
||||
pending = cvBuilderApi.themePreview(themeId).then(result => {
|
||||
if (typeof result.html !== "string") throw new Error("Template preview unavailable");
|
||||
return result.html;
|
||||
}).catch(error => { samples.delete(themeId); throw error; });
|
||||
samples.set(themeId, pending);
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
export default function CvTemplateThumbnail({ theme, height = 150 }: { theme?: CvTheme; height?: number }) {
|
||||
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];
|
||||
const { t } = useI18n();
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const [visible, setVisible] = useState(typeof IntersectionObserver === "undefined");
|
||||
const [html, setHtml] = useState<{ id: string; value: string } | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [width, setWidth] = useState(210 * height / 297);
|
||||
const metrics = getCvPageMetrics("a4");
|
||||
const scale = Math.min(width / metrics.widthPx, height / metrics.heightPx);
|
||||
|
||||
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>;
|
||||
useEffect(() => {
|
||||
const element = host.current;
|
||||
if (!element || typeof ResizeObserver === "undefined") return;
|
||||
const resize = new ResizeObserver(() => setWidth(element.clientWidth));
|
||||
resize.observe(element);
|
||||
return () => resize.disconnect();
|
||||
}, []);
|
||||
|
||||
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>
|
||||
);
|
||||
useEffect(() => {
|
||||
if (visible || !host.current || typeof IntersectionObserver === "undefined") return;
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
if (entries.some(entry => entry.isIntersecting)) { setVisible(true); observer.disconnect(); }
|
||||
}, { rootMargin: "200px" });
|
||||
observer.observe(host.current);
|
||||
return () => observer.disconnect();
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !theme?.id) return;
|
||||
let alive = true;
|
||||
setFailed(false);
|
||||
sample(theme.id).then(value => { if (alive) setHtml({ id: theme.id, value }); })
|
||||
.catch(() => { if (alive) setFailed(true); });
|
||||
return () => { alive = false; };
|
||||
}, [theme?.id, visible]);
|
||||
|
||||
const current = html?.id === theme?.id ? html?.value : null;
|
||||
return <Box ref={host} aria-hidden sx={{ height, bgcolor: "grey.100", border: "1px solid", borderColor: "divider", borderRadius: 1, overflow: "hidden", display: "flex", justifyContent: "center", pointerEvents: "none" }}>
|
||||
{current ? <Box sx={{ width: metrics.widthPx * scale, height: metrics.heightPx * scale, overflow: "hidden", bgcolor: "common.white" }}>
|
||||
<iframe title={t("cvEditorTemplateSampleTitle")} srcDoc={current} sandbox="" tabIndex={-1}
|
||||
style={{ border: 0, display: "block", width: metrics.widthPx, height: metrics.heightPx, transform: `scale(${scale})`, transformOrigin: "top left", pointerEvents: "none" }} />
|
||||
</Box> : failed ? <Typography variant="caption" sx={{ p: 1 }}>{t("cvEditorPreviewUnavailable")}</Typography>
|
||||
: <Skeleton variant="rectangular" width="100%" height="100%" />}
|
||||
</Box>;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useId, useState } from "react";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import CheckIcon from "@mui/icons-material/Check";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import {
|
||||
@@ -34,6 +35,7 @@ export default function CvCustomizeTab({ mode, settings, update, themes, section
|
||||
const [candidate, setCandidate] = useState<CvTheme | null>(null);
|
||||
const activeTheme = themes.find(theme => theme.id === settings.themeId);
|
||||
const supports = (setting: string) => !activeTheme?.supportedSettings?.length || activeTheme.supportedSettings.includes(setting);
|
||||
const supportsPhoto = !!activeTheme && activeTheme.photoShape !== "none" && supports("photo");
|
||||
const sidebarSections = settings.sidebarSections ?? ["contact", "skills", "languages"];
|
||||
const layout = settings.layout ?? activeTheme?.layout;
|
||||
const selectTemplate = async (theme: CvTheme) => {
|
||||
@@ -98,6 +100,13 @@ export default function CvCustomizeTab({ mode, settings, update, themes, section
|
||||
</>}
|
||||
</Stack>
|
||||
|
||||
{supportsPhoto && <Stack component="section" aria-labelledby={id + "-photo"} spacing={1}>
|
||||
<Typography id={id + "-photo"} component="h3" variant="subtitle2">{t("cvEditorProfilePhoto")}</Typography>
|
||||
<FormControlLabel control={<Switch checked={settings.showPhoto} onChange={event => update({ showPhoto: event.target.checked })} />} label={t("cvEditorShowPhoto")} />
|
||||
<Typography variant="body2" color="text.secondary">{t(settings.includeProfileContact === false ? "cvEditorPhotoContactHelp" : "cvEditorPhotoHelp")}</Typography>
|
||||
<Button component={RouterLink} to="/career?section=profile" sx={{ alignSelf: "flex-start" }}>{t("cvEditorManagePhoto")}</Button>
|
||||
</Stack>}
|
||||
|
||||
<Accordion disableGutters elevation={0} sx={{ border: "1px solid", borderColor: "divider", "&:before": { display: "none" } }}>
|
||||
<AccordionSummary id={id + "-advanced"} aria-controls={id + "-advanced-content"} expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography component="h3" variant="body2" sx={{ fontWeight: 600 }}>{t("cvEditorAdvancedSettings")}</Typography>
|
||||
@@ -130,7 +139,6 @@ export default function CvCustomizeTab({ mode, settings, update, themes, section
|
||||
</>}
|
||||
</Stack>}
|
||||
<Stack>
|
||||
{supports("photo") && <FormControlLabel control={<Switch checked={settings.showPhoto} onChange={event => update({ showPhoto: event.target.checked })} />} label={t("cvEditorShowPhoto")} />}
|
||||
{supports("icons") && <FormControlLabel control={<Switch checked={settings.showIcons} onChange={event => update({ showIcons: event.target.checked })} />} label={t("cvEditorContactIcons")} />}
|
||||
</Stack>
|
||||
</Stack></AccordionDetails>
|
||||
|
||||
Reference in New Issue
Block a user