feat: Introduce pre-generated template snapshots

This commit is contained in:
JOYCEQL
2026-03-15 15:44:38 +08:00
parent 0ec010ea21
commit fd76ba110e
23 changed files with 526 additions and 288 deletions
+5 -1
View File
@@ -9,7 +9,9 @@
"build": "vite build",
"start": "node server.mjs",
"preview": "vite preview",
"release": "bumpp"
"release": "bumpp",
"generate:template-snapshots": "tsx scripts/generate-template-snapshots.ts",
"install:playwright": "playwright install chromium"
},
"dependencies": {
"@google/generative-ai": "^0.24.1",
@@ -94,10 +96,12 @@
"bumpp": "^10.4.1",
"changelogen": "^0.6.2",
"eslint": "^8",
"playwright": "^1.58.2",
"postcss": "^8",
"postcss-normalize": "^13.0.1",
"sass": "^1.77.4",
"tailwindcss": "^3.4.1",
"tsx": "^4.21.0",
"typescript": "^5",
"vite": "^7.3.1",
"vite-tsconfig-paths": "^6.1.1",
+32
View File
@@ -249,6 +249,9 @@ importers:
eslint:
specifier: ^8
version: 8.57.1
playwright:
specifier: ^1.58.2
version: 1.58.2
postcss:
specifier: ^8
version: 8.5.1
@@ -261,6 +264,9 @@ importers:
tailwindcss:
specifier: ^3.4.1
version: 3.4.17(ts-node@10.9.1(@types/node@20.17.17)(typescript@5.7.3))
tsx:
specifier: ^4.21.0
version: 4.21.0
typescript:
specifier: ^5
version: 5.7.3
@@ -4301,6 +4307,11 @@ packages:
fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -5065,6 +5076,16 @@ packages:
pkg-types@2.3.0:
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
playwright-core@1.58.2:
resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==}
engines: {node: '>=18'}
hasBin: true
playwright@1.58.2:
resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==}
engines: {node: '>=18'}
hasBin: true
postcss-browser-comments@6.0.1:
resolution: {integrity: sha512-VE5mVLOW+L31a+Eyi7i5j7PmzOydObKLA9VwGBpTZy2OYB3XY1E7/xHxv4tURtEI/qb5h2TyyGHPhZ31sXOEXg==}
engines: {node: '>=18'}
@@ -10850,6 +10871,9 @@ snapshots:
fs.realpath@1.0.0: {}
fsevents@2.3.2:
optional: true
fsevents@2.3.3:
optional: true
@@ -11879,6 +11903,14 @@ snapshots:
exsolve: 1.0.8
pathe: 2.0.3
playwright-core@1.58.2: {}
playwright@1.58.2:
dependencies:
playwright-core: 1.58.2
optionalDependencies:
fsevents: 2.3.2
postcss-browser-comments@6.0.1(browserslist@4.24.4)(postcss@8.5.1):
dependencies:
browserslist: 4.24.4
Binary file not shown.

After

Width:  |  Height:  |  Size: 480 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 446 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 393 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 387 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 597 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 654 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 588 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 535 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 620 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 498 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 734 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 666 KiB

+205
View File
@@ -0,0 +1,205 @@
import { spawn, type ChildProcess } from "node:child_process";
import { mkdir, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { chromium } from "playwright";
import { DEFAULT_TEMPLATES } from "../src/config";
import {
TEMPLATE_PREVIEW_HEIGHT_PX,
TEMPLATE_PREVIEW_LOCALES,
TEMPLATE_PREVIEW_WIDTH_PX,
TEMPLATE_SNAPSHOT_ROOT_SELECTOR,
TEMPLATE_SNAPSHOT_VERSION,
createEmptyTemplateSnapshotManifest,
getTemplateSnapshotPath,
type TemplatePreviewLocale,
} from "../src/lib/templatePreview";
const SNAPSHOT_SERVER_HOST = "127.0.0.1";
const SNAPSHOT_SERVER_PORT = 4173;
const SNAPSHOT_SERVER_URL = `http://${SNAPSHOT_SERVER_HOST}:${SNAPSHOT_SERVER_PORT}`;
const SNAPSHOT_PUBLIC_DIR = path.resolve(
process.cwd(),
"public",
"template-snapshots"
);
const SNAPSHOT_MANIFEST_FILE = path.resolve(
process.cwd(),
"src",
"generated",
"templateSnapshotManifest.ts"
);
const VITE_CLI_FILE = path.resolve(
process.cwd(),
"node_modules",
"vite",
"bin",
"vite.js"
);
const MIN_NODE_MAJOR = 20;
const MIN_NODE_MINOR = 19;
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const buildTemplateSnapshotUrl = (
locale: TemplatePreviewLocale,
templateId: string
) =>
`${SNAPSHOT_SERVER_URL}/app/preview-template/${templateId}?locale=${locale}&snapshot=1`;
const waitForServer = async (timeoutMs = 30_000) => {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
try {
const response = await fetch(SNAPSHOT_SERVER_URL);
if (response.ok) {
return;
}
} catch {}
await sleep(500);
}
throw new Error(`Timed out waiting for ${SNAPSHOT_SERVER_URL}`);
};
const assertSupportedNodeVersion = () => {
const [major, minor] = process.versions.node
.split(".")
.map((segment) => Number(segment));
if (
Number.isNaN(major) ||
Number.isNaN(minor) ||
major < MIN_NODE_MAJOR ||
(major === MIN_NODE_MAJOR && minor < MIN_NODE_MINOR)
) {
throw new Error(
`Node.js ${MIN_NODE_MAJOR}.${MIN_NODE_MINOR}+ is required. Current runtime is ${process.versions.node}.`
);
}
};
const startDevServer = (): ChildProcess => {
const child = spawn(
process.execPath,
[
VITE_CLI_FILE,
"dev",
"--host",
SNAPSHOT_SERVER_HOST,
"--port",
String(SNAPSHOT_SERVER_PORT),
"--strictPort",
],
{
cwd: process.cwd(),
stdio: "inherit",
env: process.env,
}
);
return child;
};
const stopProcess = (child: ChildProcess | null) => {
if (!child || child.exitCode !== null) return;
child.kill("SIGTERM");
};
const writeManifest = async (
manifest: ReturnType<typeof createEmptyTemplateSnapshotManifest>
) => {
const fileContent = `export const TEMPLATE_SNAPSHOT_MANIFEST = ${JSON.stringify(
manifest,
null,
2
)} as const;\n`;
await writeFile(SNAPSHOT_MANIFEST_FILE, fileContent, "utf8");
};
const ensurePlaywrightBrowser = async () => {
try {
const browser = await chromium.launch();
await browser.close();
} catch (error) {
throw new Error(
"Playwright Chromium is not installed. Run `pnpm exec playwright install chromium` first.",
{ cause: error }
);
}
};
const main = async () => {
assertSupportedNodeVersion();
await ensurePlaywrightBrowser();
await rm(SNAPSHOT_PUBLIC_DIR, { recursive: true, force: true });
await mkdir(SNAPSHOT_PUBLIC_DIR, { recursive: true });
const manifest = createEmptyTemplateSnapshotManifest();
manifest.version = TEMPLATE_SNAPSHOT_VERSION;
manifest.generatedAt = new Date().toISOString();
const devServer = startDevServer();
try {
console.log("Starting preview server for template snapshots...");
await waitForServer();
console.log("Preview server is ready.");
const browser = await chromium.launch();
const page = await browser.newPage({
viewport: {
width: TEMPLATE_PREVIEW_WIDTH_PX,
height: TEMPLATE_PREVIEW_HEIGHT_PX,
},
deviceScaleFactor: 2,
colorScheme: "light",
});
for (const locale of TEMPLATE_PREVIEW_LOCALES) {
const localeOutputDir = path.join(SNAPSHOT_PUBLIC_DIR, locale);
await mkdir(localeOutputDir, { recursive: true });
for (const template of DEFAULT_TEMPLATES) {
console.log(`Capturing ${locale}/${template.id}...`);
const screenshotUrl = buildTemplateSnapshotUrl(locale, template.id);
const outputFilePath = path.join(localeOutputDir, `${template.id}.png`);
await page.goto(screenshotUrl, {
waitUntil: "networkidle",
});
await page.waitForSelector(TEMPLATE_SNAPSHOT_ROOT_SELECTOR);
await page.evaluate(async () => {
if (document.fonts?.ready) {
await document.fonts.ready;
}
});
await page.locator(TEMPLATE_SNAPSHOT_ROOT_SELECTOR).screenshot({
path: outputFilePath,
type: "png",
});
manifest.locales[locale][template.id] = `${getTemplateSnapshotPath(
locale,
template.id
)}?v=${encodeURIComponent(manifest.generatedAt)}`;
}
}
await browser.close();
await writeManifest(manifest);
console.log("Template snapshots generated successfully.");
} finally {
stopProcess(devServer);
}
};
void main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useTranslations } from "@/i18n/compat/client";
import { useLocale, useTranslations } from "@/i18n/compat/client";
import {
Dialog,
DialogContent,
@@ -11,6 +11,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { DEFAULT_TEMPLATES } from "@/config";
import { initialResumeState } from "@/config/initialResumeData";
import ResumeTemplateComponent from "@/components/templates";
import { useTemplateSnapshots } from "@/hooks/useTemplateSnapshots";
import type { Translator } from "@/i18n/compat/utils";
import type { ResumeData } from "@/types/resume";
import type { ResumeTemplate } from "@/types/template";
@@ -45,6 +46,54 @@ const NORMAL_TEMPLATES: NormalTemplate[] = DEFAULT_TEMPLATES.map((template) => (
nameKey: toTemplateNameKey(template.id),
}));
const BlankTemplateThumbnail = ({ t }: { t: Translator }) => (
<div className="w-full h-full flex flex-col items-center justify-center bg-gray-50 dark:bg-gray-800/50">
<div className="w-24 h-24 rounded-full bg-white dark:bg-gray-800 shadow-sm flex items-center justify-center mb-6 text-gray-400 group-hover:text-primary transition-colors">
<FilePlus className="w-12 h-12" />
</div>
<span className="text-2xl font-bold text-gray-700 dark:text-gray-200 group-hover:text-primary transition-colors">
{t("dashboard.resumes.createDialog.blankTitle")}
</span>
<p className="text-gray-500 mt-4 text-base px-8 text-center leading-relaxed">
{t("dashboard.resumes.createDialog.blankThumbnailDescription")}
</p>
</div>
);
const TemplateCardThumbnail = ({
template,
t,
snapshotSrc,
}: {
template: TemplateOption,
t: Translator,
snapshotSrc?: string | null,
}) => {
if (template.isBlank) {
return <BlankTemplateThumbnail t={t} />;
}
if (snapshotSrc) {
return (
<img
src={snapshotSrc}
alt={t(`dashboard.templates.${template.nameKey}.name`)}
className="h-full w-full object-cover object-top"
loading="eager"
draggable={false}
/>
);
}
return (
<div className="w-full h-full flex flex-col items-center justify-center bg-gray-50 dark:bg-gray-800/50">
<span className="text-lg font-semibold text-gray-700 dark:text-gray-200">
{t(`dashboard.templates.${template.nameKey}.name`)}
</span>
</div>
);
};
const TemplateThumbnail = ({
template,
t,
@@ -72,19 +121,7 @@ const TemplateThumbnail = ({
}, [template.isBlank, scaleModifier]);
if (template.isBlank) {
return (
<div className="w-full h-full flex flex-col items-center justify-center bg-gray-50 dark:bg-gray-800/50">
<div className="w-24 h-24 rounded-full bg-white dark:bg-gray-800 shadow-sm flex items-center justify-center mb-6 text-gray-400 group-hover:text-primary transition-colors">
<FilePlus className="w-12 h-12" />
</div>
<span className="text-2xl font-bold text-gray-700 dark:text-gray-200 group-hover:text-primary transition-colors">
{t("dashboard.resumes.createDialog.blankTitle")}
</span>
<p className="text-gray-500 mt-4 text-base px-8 text-center leading-relaxed">
{t("dashboard.resumes.createDialog.blankThumbnailDescription")}
</p>
</div>
);
return <BlankTemplateThumbnail t={t} />;
}
const sampleExperience = quality === "high"
@@ -157,6 +194,8 @@ export const CreateResumeModal = ({
onCreate,
}: CreateResumeModalProps) => {
const t = useTranslations();
const locale = useLocale();
const { snapshotMap } = useTemplateSnapshots(locale);
const [previewTarget, setPreviewTarget] = useState<TemplateOption | null>(null);
const handleCreate = (template: TemplateOption) => {
@@ -263,7 +302,11 @@ export const CreateResumeModal = ({
layoutId={`card-image-${template.id}`}
className="aspect-[210/297] rounded-2xl overflow-hidden border border-gray-200/60 dark:border-gray-800/60 shadow-sm transition-all duration-300 group-hover:shadow-xl group-hover:border-primary/50 dark:group-hover:border-primary/50 bg-white dark:bg-gray-900 relative"
>
<TemplateThumbnail template={template} t={t} quality="low" />
<TemplateCardThumbnail
template={template}
t={t}
snapshotSrc={snapshotMap[template.id]}
/>
<div className="absolute inset-0 ring-1 ring-inset ring-black/5 dark:ring-white/5 rounded-2xl pointer-events-none" />
<div className="absolute inset-0 bg-gradient-to-t from-gray-900/40 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
</motion.div>
+40 -33
View File
@@ -1,64 +1,71 @@
import React, { useMemo } from "react";
import { useParams } from "@tanstack/react-router";
import { DEFAULT_TEMPLATES } from "../../config";
import { initialResumeState, initialResumeStateEn } from "../../config/initialResumeData";
import ResumeTemplateComponent from "../templates";
import { cn } from "../../lib/utils";
import { ResumeData } from "../../types/resume";
import { ResumeTemplate } from "../../types/template";
import { normalizeFontFamily } from "@/utils/fonts";
import {
TEMPLATE_PREVIEW_HEIGHT_PX,
TEMPLATE_PREVIEW_WIDTH_PX,
TEMPLATE_SNAPSHOT_ROOT_ATTRIBUTE,
createTemplatePreviewData,
getTemplateById,
isTemplatePreviewLocale,
} from "@/lib/templatePreview";
const IframeTemplateViewer = () => {
const { id } = useParams({ from: "/app/preview-template/$id" });
// Use cookie to determine locale
const locale =
const searchParams =
typeof window !== "undefined"
? new URLSearchParams(window.location.search)
: null;
const localeParam = searchParams?.get("locale");
const cookieLocale =
typeof document !== "undefined"
? document.cookie
.split("; ")
.find((row) => row.startsWith("NEXT_LOCALE="))
?.split("=")[1] || "zh"
?.split("=")[1]
: null;
const locale = isTemplatePreviewLocale(localeParam)
? localeParam
: isTemplatePreviewLocale(cookieLocale)
? cookieLocale
: "zh";
const isSnapshotMode = searchParams?.get("snapshot") === "1";
const template = useMemo(() => {
return DEFAULT_TEMPLATES.find((t: ResumeTemplate) => t.id === id) || DEFAULT_TEMPLATES[0];
return getTemplateById(id);
}, [id]);
const mockData: ResumeData = useMemo(() => {
const baseData = locale === "en" ? initialResumeStateEn : initialResumeState;
return {
...baseData,
id: "preview-mock-id",
templateId: template.id,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
globalSettings: {
...baseData.globalSettings,
themeColor: template.colorScheme.primary,
sectionSpacing: template.spacing.sectionGap,
paragraphSpacing: template.spacing.itemGap,
pagePadding: template.spacing.contentPadding,
},
basic: {
...baseData.basic,
layout: template.basic.layout,
},
} as ResumeData;
const mockData = useMemo(() => {
return createTemplatePreviewData(template, locale);
}, [locale, template]);
const selectedFontFamily = normalizeFontFamily(
mockData.globalSettings?.fontFamily
);
return (
<div className="w-full h-full min-h-screen bg-white flex justify-center items-start overflow-hidden">
<div
className={cn(
"w-full min-h-screen overflow-hidden bg-white",
isSnapshotMode ? "flex items-start justify-start p-0" : "flex items-start justify-center"
)}
>
<div
{...{ [TEMPLATE_SNAPSHOT_ROOT_ATTRIBUTE]: "" }}
className={cn(
"w-[210mm] min-w-[210mm] min-h-[297mm]",
"bg-white",
"relative mx-auto origin-top-left"
"bg-white relative origin-top-left",
isSnapshotMode ? "" : "mx-auto"
)}
style={{
width: `${TEMPLATE_PREVIEW_WIDTH_PX}px`,
minWidth: `${TEMPLATE_PREVIEW_WIDTH_PX}px`,
height: isSnapshotMode
? `${TEMPLATE_PREVIEW_HEIGHT_PX}px`
: undefined,
minHeight: `${TEMPLATE_PREVIEW_HEIGHT_PX}px`,
overflow: "hidden",
fontFamily: selectedFontFamily,
padding: `${template.spacing.contentPadding}px`,
}}
+32 -239
View File
@@ -1,4 +1,3 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { ImageIcon, Layout, PanelsLeftBottom } from "lucide-react";
import { motion } from "framer-motion";
import { useTranslations, useLocale } from "@/i18n/compat/client";
@@ -14,84 +13,21 @@ import { cn } from "@/lib/utils";
import { DEFAULT_TEMPLATES } from "@/config";
import { useResumeStore } from "@/store/useResumeStore";
import { ScrollArea } from "@/components/ui/scroll-area";
import ResumeTemplateComponent from "@/components/templates";
import {
initialResumeState,
initialResumeStateEn,
} from "@/config/initialResumeData";
import { normalizeFontFamily } from "@/utils/fonts";
import type { ResumeData } from "@/types/resume";
const A4_WIDTH_PX = 794;
const A4_HEIGHT_PX = 1123;
const SNAPSHOT_CAPTURE_SCALE = 0.6;
import { useTemplateSnapshots } from "@/hooks/useTemplateSnapshots";
type TemplateItem = (typeof DEFAULT_TEMPLATES)[number];
type SnapshotState = Record<string, string | null>;
interface TemplatePreviewProps {
template: TemplateItem;
isActive: boolean;
snapshotUrl?: string | null;
snapshotSrc: string | null;
onSelect: (templateId: string) => void;
}
const createPreviewData = (
template: TemplateItem,
baseData: typeof initialResumeState
): ResumeData =>
({
...baseData,
id: `preview-mock-sheet-${template.id}`,
templateId: template.id,
createdAt: new Date(0).toISOString(),
updatedAt: new Date(0).toISOString(),
globalSettings: {
...baseData.globalSettings,
themeColor: template.colorScheme.primary,
sectionSpacing: template.spacing.sectionGap,
paragraphSpacing: template.spacing.itemGap,
pagePadding: template.spacing.contentPadding,
},
basic: {
...baseData.basic,
layout: template.basic.layout,
},
}) as ResumeData;
const waitForStableRender = async () => {
if (document.fonts?.ready) {
await document.fonts.ready;
}
await new Promise<void>((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => resolve());
});
});
};
const scheduleIdle = (callback: () => void) => {
if ("requestIdleCallback" in window) {
return window.requestIdleCallback(callback, { timeout: 1200 });
}
return window.setTimeout(callback, 180);
};
const cancelIdle = (handle: number) => {
if ("cancelIdleCallback" in window) {
window.cancelIdleCallback(handle);
return;
}
window.clearTimeout(handle);
};
const TemplatePreview = ({
template,
isActive,
snapshotUrl,
snapshotSrc,
onSelect,
}: TemplatePreviewProps) => {
return (
@@ -105,12 +41,12 @@ const TemplatePreview = ({
)}
>
<div className="relative aspect-[210/297] w-full overflow-hidden bg-gray-50 dark:bg-gray-900">
{snapshotUrl ? (
{snapshotSrc ? (
<img
src={snapshotUrl}
src={snapshotSrc}
alt={template.name}
className="h-full w-full object-cover object-top"
loading="lazy"
loading="eager"
draggable={false}
/>
) : (
@@ -138,183 +74,40 @@ const TemplateSheet = () => {
const t = useTranslations("templates");
const locale = useLocale();
const { activeResume, setTemplate } = useResumeStore();
const [snapshotUrls, setSnapshotUrls] = useState<SnapshotState>({});
const [capturingTemplateId, setCapturingTemplateId] = useState<string | null>(
null
);
const captureRef = useRef<HTMLDivElement>(null);
const { snapshotMap } = useTemplateSnapshots(locale);
const currentTemplate =
DEFAULT_TEMPLATES.find((template) => template.id === activeResume?.templateId) ||
DEFAULT_TEMPLATES[0];
const baseData = useMemo(
() => (locale === "en" ? initialResumeStateEn : initialResumeState),
[locale]
);
const previewDataMap = useMemo(
() =>
Object.fromEntries(
DEFAULT_TEMPLATES.map((template) => [
template.id,
createPreviewData(template, baseData),
])
) as Record<string, ResumeData>,
[baseData]
);
const capturingTemplate = useMemo(
() =>
DEFAULT_TEMPLATES.find((template) => template.id === capturingTemplateId) ??
null,
[capturingTemplateId]
);
useEffect(() => {
setSnapshotUrls({});
setCapturingTemplateId(null);
}, [locale]);
useEffect(() => {
if (capturingTemplateId) return;
const nextTemplate = DEFAULT_TEMPLATES.find(
(template) => !(template.id in snapshotUrls)
);
if (!nextTemplate) return;
let cancelled = false;
const idleHandle = scheduleIdle(() => {
if (!cancelled) {
setCapturingTemplateId(nextTemplate.id);
}
});
return () => {
cancelled = true;
cancelIdle(idleHandle);
};
}, [capturingTemplateId, snapshotUrls]);
useEffect(() => {
if (!capturingTemplateId || !capturingTemplate || !captureRef.current) return;
let cancelled = false;
const captureSnapshot = async () => {
try {
const { default: html2canvas } = await import("html2canvas");
await waitForStableRender();
if (cancelled || !captureRef.current) return;
const canvas = await html2canvas(captureRef.current, {
backgroundColor: "#ffffff",
scale: SNAPSHOT_CAPTURE_SCALE,
useCORS: true,
logging: false,
width: A4_WIDTH_PX,
height: A4_HEIGHT_PX,
cacheBust: true,
windowWidth: A4_WIDTH_PX,
windowHeight: A4_HEIGHT_PX,
});
if (cancelled) return;
const snapshotUrl = canvas.toDataURL("image/png");
setSnapshotUrls((prev) => ({
...prev,
[capturingTemplateId]: snapshotUrl,
}));
} catch (error) {
console.error("Template snapshot capture failed:", error);
if (!cancelled) {
setSnapshotUrls((prev) => ({
...prev,
[capturingTemplateId]: null,
}));
}
} finally {
if (!cancelled) {
setCapturingTemplateId(null);
}
}
};
void captureSnapshot();
return () => {
cancelled = true;
};
}, [capturingTemplate, capturingTemplateId]);
return (
<>
<Sheet>
<SheetTrigger asChild>
<PanelsLeftBottom size={20} />
</SheetTrigger>
<SheetContent side="left" className="w-1/2 sm:max-w-1/2">
<SheetHeader>
<SheetTitle>{t("switchTemplate")}</SheetTitle>
</SheetHeader>
<SheetDescription />
<Sheet>
<SheetTrigger asChild>
<PanelsLeftBottom size={20} />
</SheetTrigger>
<SheetContent side="left" forceMount className="w-1/2 sm:max-w-1/2">
<SheetHeader>
<SheetTitle>{t("switchTemplate")}</SheetTitle>
</SheetHeader>
<SheetDescription />
<div className="mt-4 h-[calc(100vh-8rem)]">
<ScrollArea className="h-full w-full pr-4">
<div className="grid grid-cols-4 gap-4 pb-8">
{DEFAULT_TEMPLATES.map((template) => (
<TemplatePreview
key={template.id}
template={template}
isActive={template.id === currentTemplate.id}
snapshotUrl={snapshotUrls[template.id]}
onSelect={setTemplate}
/>
))}
</div>
</ScrollArea>
</div>
</SheetContent>
</Sheet>
{capturingTemplate && (
<div
aria-hidden="true"
className="fixed top-0 left-0 pointer-events-none"
style={{
width: `${A4_WIDTH_PX}px`,
height: `${A4_HEIGHT_PX}px`,
transform: "translate(-200vw, 0)",
overflow: "hidden",
background: "#ffffff",
}}
>
<div
ref={captureRef}
style={{
width: `${A4_WIDTH_PX}px`,
height: `${A4_HEIGHT_PX}px`,
boxSizing: "border-box",
padding: `${capturingTemplate.spacing.contentPadding}px`,
background: "#ffffff",
fontFamily: normalizeFontFamily(
previewDataMap[capturingTemplate.id].globalSettings?.fontFamily
),
}}
>
<ResumeTemplateComponent
data={previewDataMap[capturingTemplate.id]}
template={capturingTemplate}
/>
</div>
<div className="mt-4 h-[calc(100vh-8rem)]">
<ScrollArea className="h-full w-full pr-4">
<div className="grid grid-cols-4 gap-4 pb-8">
{DEFAULT_TEMPLATES.map((template) => (
<TemplatePreview
key={template.id}
template={template}
isActive={template.id === currentTemplate.id}
snapshotSrc={snapshotMap[template.id]}
onSelect={setTemplate}
/>
))}
</div>
</ScrollArea>
</div>
)}
</>
</SheetContent>
</Sheet>
);
};
+24
View File
@@ -0,0 +1,24 @@
export const TEMPLATE_SNAPSHOT_MANIFEST = {
"version": 1,
"generatedAt": "2026-03-15T07:27:11.337Z",
"locales": {
"zh": {
"classic": "/template-snapshots/zh/classic.png?v=2026-03-15T07%3A27%3A11.337Z",
"modern": "/template-snapshots/zh/modern.png?v=2026-03-15T07%3A27%3A11.337Z",
"left-right": "/template-snapshots/zh/left-right.png?v=2026-03-15T07%3A27%3A11.337Z",
"timeline": "/template-snapshots/zh/timeline.png?v=2026-03-15T07%3A27%3A11.337Z",
"minimalist": "/template-snapshots/zh/minimalist.png?v=2026-03-15T07%3A27%3A11.337Z",
"elegant": "/template-snapshots/zh/elegant.png?v=2026-03-15T07%3A27%3A11.337Z",
"creative": "/template-snapshots/zh/creative.png?v=2026-03-15T07%3A27%3A11.337Z"
},
"en": {
"classic": "/template-snapshots/en/classic.png?v=2026-03-15T07%3A27%3A11.337Z",
"modern": "/template-snapshots/en/modern.png?v=2026-03-15T07%3A27%3A11.337Z",
"left-right": "/template-snapshots/en/left-right.png?v=2026-03-15T07%3A27%3A11.337Z",
"timeline": "/template-snapshots/en/timeline.png?v=2026-03-15T07%3A27%3A11.337Z",
"minimalist": "/template-snapshots/en/minimalist.png?v=2026-03-15T07%3A27%3A11.337Z",
"elegant": "/template-snapshots/en/elegant.png?v=2026-03-15T07%3A27%3A11.337Z",
"creative": "/template-snapshots/en/creative.png?v=2026-03-15T07%3A27%3A11.337Z"
}
}
} as const;
+48
View File
@@ -0,0 +1,48 @@
import { useEffect, useMemo } from "react";
import { DEFAULT_TEMPLATES } from "@/config";
import { TEMPLATE_SNAPSHOT_MANIFEST } from "@/generated/templateSnapshotManifest";
import {
getTemplateSnapshotSrc,
isTemplatePreviewLocale,
} from "@/lib/templatePreview";
export const useTemplateSnapshots = (locale: string | null | undefined) => {
const resolvedLocale = isTemplatePreviewLocale(locale) ? locale : "zh";
const snapshotMap = useMemo(
() =>
Object.fromEntries(
DEFAULT_TEMPLATES.map((template) => [
template.id,
getTemplateSnapshotSrc(
TEMPLATE_SNAPSHOT_MANIFEST,
resolvedLocale,
template.id
),
])
) as Record<string, string | null>,
[resolvedLocale]
);
useEffect(() => {
const preloaders = Object.values(snapshotMap)
.filter((src): src is string => Boolean(src))
.map((src) => {
const image = new window.Image();
image.decoding = "async";
image.src = src;
return image;
});
return () => {
preloaders.forEach((image) => {
image.src = "";
});
};
}, [snapshotMap]);
return {
resolvedLocale,
snapshotMap,
};
};
+82
View File
@@ -0,0 +1,82 @@
import { DEFAULT_TEMPLATES } from "@/config";
import {
initialResumeState,
initialResumeStateEn,
} from "@/config/initialResumeData";
import type { ResumeData } from "@/types/resume";
import type { ResumeTemplate } from "@/types/template";
export const TEMPLATE_PREVIEW_WIDTH_PX = 794;
export const TEMPLATE_PREVIEW_HEIGHT_PX = 1123;
export const TEMPLATE_SNAPSHOT_VERSION = 1;
export const TEMPLATE_SNAPSHOT_ROOT_ATTRIBUTE = "data-template-snapshot-root";
export const TEMPLATE_SNAPSHOT_ROOT_SELECTOR = `[${TEMPLATE_SNAPSHOT_ROOT_ATTRIBUTE}]`;
export const TEMPLATE_SNAPSHOT_PUBLIC_DIR = "template-snapshots";
export const TEMPLATE_PREVIEW_LOCALES = ["zh", "en"] as const;
export type TemplatePreviewLocale = (typeof TEMPLATE_PREVIEW_LOCALES)[number];
export interface TemplateSnapshotManifest {
version: number;
generatedAt: string | null;
locales: Record<TemplatePreviewLocale, Record<string, string>>;
}
export const createEmptyTemplateSnapshotManifest =
(): TemplateSnapshotManifest => ({
version: TEMPLATE_SNAPSHOT_VERSION,
generatedAt: null,
locales: {
zh: {},
en: {},
},
});
export const isTemplatePreviewLocale = (
value: string | null | undefined
): value is TemplatePreviewLocale =>
value === "zh" || value === "en";
export const getTemplateById = (templateId: string | undefined): ResumeTemplate =>
DEFAULT_TEMPLATES.find((template) => template.id === templateId) ??
DEFAULT_TEMPLATES[0];
export const getTemplatePreviewBaseData = (locale: TemplatePreviewLocale) =>
locale === "en" ? initialResumeStateEn : initialResumeState;
export const createTemplatePreviewData = (
template: ResumeTemplate,
locale: TemplatePreviewLocale
): ResumeData => {
const baseData = getTemplatePreviewBaseData(locale);
return {
...baseData,
id: `preview-mock-${locale}-${template.id}`,
templateId: template.id,
createdAt: new Date(0).toISOString(),
updatedAt: new Date(0).toISOString(),
globalSettings: {
...baseData.globalSettings,
themeColor: template.colorScheme.primary,
sectionSpacing: template.spacing.sectionGap,
paragraphSpacing: template.spacing.itemGap,
pagePadding: template.spacing.contentPadding,
},
basic: {
...baseData.basic,
layout: template.basic.layout,
},
} as ResumeData;
};
export const getTemplateSnapshotPath = (
locale: TemplatePreviewLocale,
templateId: string
) => `/${TEMPLATE_SNAPSHOT_PUBLIC_DIR}/${locale}/${templateId}.png`;
export const getTemplateSnapshotSrc = (
manifest: TemplateSnapshotManifest,
locale: TemplatePreviewLocale,
templateId: string
) => manifest.locales[locale][templateId] ?? null;