feat: Introduce pre-generated template snapshots
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
After Width: | Height: | Size: 480 KiB |
|
After Width: | Height: | Size: 446 KiB |
|
After Width: | Height: | Size: 393 KiB |
|
After Width: | Height: | Size: 462 KiB |
|
After Width: | Height: | Size: 387 KiB |
|
After Width: | Height: | Size: 597 KiB |
|
After Width: | Height: | Size: 490 KiB |
|
After Width: | Height: | Size: 654 KiB |
|
After Width: | Height: | Size: 588 KiB |
|
After Width: | Height: | Size: 535 KiB |
|
After Width: | Height: | Size: 620 KiB |
|
After Width: | Height: | Size: 498 KiB |
|
After Width: | Height: | Size: 734 KiB |
|
After Width: | Height: | Size: 666 KiB |
@@ -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>
|
||||
|
||||
@@ -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`,
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||