feat(cv): qualify templates and reliable local import processing

This commit is contained in:
cesnimda
2026-10-28 14:36:12 +01:00
parent 73ea888375
commit 3f4db8ca81
69 changed files with 2597 additions and 180 deletions
@@ -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>;
}