65 lines
3.2 KiB
TypeScript
65 lines
3.2 KiB
TypeScript
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";
|
|
|
|
// 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 { 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);
|
|
|
|
useEffect(() => {
|
|
const element = host.current;
|
|
if (!element || typeof ResizeObserver === "undefined") return;
|
|
const resize = new ResizeObserver(() => setWidth(element.clientWidth));
|
|
resize.observe(element);
|
|
return () => resize.disconnect();
|
|
}, []);
|
|
|
|
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>;
|
|
}
|