fix(web): show static template previews in gallery (#3302)

This commit is contained in:
Amruth Pillai
2026-08-10 12:18:47 +02:00
committed by GitHub
parent efd950bd93
commit 035d94183b
9 changed files with 141 additions and 255 deletions
+2 -2
View File
@@ -55,7 +55,7 @@
"@tanstack/react-form": "^1.33.4",
"@tanstack/react-hotkeys": "^0.10.0",
"@tanstack/react-query": "^5.101.4",
"@tanstack/react-router": "^1.170.24",
"@tanstack/react-router": "^1.170.25",
"@tiptap/extension-color": "^3.29.2",
"@tiptap/extension-highlight": "^3.29.2",
"@tiptap/extension-text-align": "^3.29.2",
@@ -106,7 +106,7 @@
"@tanstack/react-devtools": "^0.10.9",
"@tanstack/react-query-devtools": "^5.101.4",
"@tanstack/react-router-devtools": "^1.167.1",
"@tanstack/router-plugin": "^1.168.28",
"@tanstack/router-plugin": "^1.168.29",
"@types/babel__core": "^7.20.5",
"@types/pg": "^8.21.0",
"@types/react": "^19.2.18",
@@ -1,11 +1,9 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { Template } from "@reactive-resume/schema/templates";
import type { DialogProps } from "@/dialogs/store";
import type { TemplateMetadata } from "./data";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { SlideshowIcon } from "@phosphor-icons/react";
import { lazy, Suspense } from "react";
import { toast } from "sonner";
import { Badge } from "@reactive-resume/ui/components/badge";
import { DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@reactive-resume/ui/components/dialog";
@@ -16,14 +14,6 @@ import { useDialogStore } from "@/dialogs/store";
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
import { templates } from "./data";
// Lazy so the browser PDF pipeline (pdf.js) loads only when the gallery opens.
// All visible tiles share the same lazy chunk; only one PDF renders at a time via the shared serial queue.
const TemplateLivePreview = lazy(() =>
import("@/features/resume/preview/template-live-preview").then((module) => ({
default: module.TemplateLivePreview,
})),
);
export function TemplateGalleryDialog(_: DialogProps<"resume.template.gallery">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const resume = useCurrentResume();
@@ -76,7 +66,6 @@ export function TemplateGalleryDialog(_: DialogProps<"resume.template.gallery">)
{Object.entries(templates).map(([template, metadata]) => (
<TemplateCard
key={template}
data={resume.data}
metadata={metadata}
id={template as Template}
isActive={template === selectedTemplate}
@@ -91,13 +80,12 @@ export function TemplateGalleryDialog(_: DialogProps<"resume.template.gallery">)
type TemplateCardProps = {
id: Template;
data: ResumeData;
isActive?: boolean;
metadata: TemplateMetadata;
onSelect: (template: Template) => void;
};
function TemplateCard({ id, data, metadata, isActive, onSelect }: TemplateCardProps) {
function TemplateCard({ id, metadata, isActive, onSelect }: TemplateCardProps) {
return (
<CometCard translateDepth={3} rotateDepth={6} glareOpacity={0}>
<button
@@ -109,9 +97,7 @@ function TemplateCard({ id, data, metadata, isActive, onSelect }: TemplateCardPr
isActive && "ring-2 ring-ring ring-offset-4 ring-offset-background",
)}
>
<Suspense fallback={<img src={metadata.imageUrl} alt={metadata.name} className="size-full object-cover" />}>
<TemplateLivePreview data={data} template={id} fallbackSrc={metadata.imageUrl} alt={metadata.name} />
</Suspense>
<img src={metadata.imageUrl} alt={metadata.name} className="size-full object-cover" />
</button>
<div className="mt-1 flex items-center justify-center">
@@ -1,106 +0,0 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { Template } from "@reactive-resume/schema/templates";
import { useEffect, useRef, useState } from "react";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { cn } from "@reactive-resume/utils/style";
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
import { createPdfFirstPageImageUrl } from "./pdf-thumbnail";
// Bounded FIFO cache of generated object URLs keyed by (data, template) reference. Evicted entries are
// revoked so blobs don't leak; stale-data entries (a new `data` object after an edit) age out via the cap.
// ponytail: cap-24 linear scan — gallery can show ~20 templates at once.
const PREVIEW_CACHE_LIMIT = 24;
type PreviewCacheEntry = { data: ResumeData; template: Template; url: string };
const previewCache: PreviewCacheEntry[] = [];
const getCachedPreview = (data: ResumeData, template: Template) =>
previewCache.find((entry) => entry.data === data && entry.template === template)?.url;
const setCachedPreview = (data: ResumeData, template: Template, url: string) => {
previewCache.push({ data, template, url });
while (previewCache.length > PREVIEW_CACHE_LIMIT) {
const evicted = previewCache.shift();
if (evicted) URL.revokeObjectURL(evicted.url);
}
};
// Serial render pipeline shared across all instances: PDFs generate one-at-a-time on the main thread
// (serialized through `renderQueue`) so the gallery doesn't spike CPU/memory when all tiles mount at once.
// Per-instance `latestRenderRequestId` ref (not global) ensures that if a component re-renders with new
// props it discards its own superseded work without cancelling renders for other tiles.
let renderQueue: Promise<void> = Promise.resolve();
type TemplateLivePreviewProps = {
alt: string;
className?: string;
data: ResumeData;
fallbackSrc: string;
template: Template;
};
/**
* Renders the first page of the user's actual resume data through a given template, lazily.
* Reuses the browser PDF pipeline (`createResumePdfBlob` + pdf.js first-page render). Falls back to the
* static template image while generating or if generation fails. Intended to be mounted on demand (e.g.
* inside a hover/preview card) so the render stays off the hover critical path.
*/
export function TemplateLivePreview({ alt, className, data, fallbackSrc, template }: TemplateLivePreviewProps) {
const [imageUrl, setImageUrl] = useState<string | null>(() => getCachedPreview(data, template) ?? null);
const [hasError, setHasError] = useState(false);
// Per-instance counter: only the latest render request for THIS tile commits its result.
const latestRequestId = useRef(0);
useEffect(() => {
const cached = getCachedPreview(data, template);
if (cached) {
setImageUrl(cached);
return;
}
let cancelled = false;
const requestId = ++latestRequestId.current;
renderQueue = renderQueue.then(async () => {
if (cancelled || requestId !== latestRequestId.current) return;
// Another instance may have cached this exact preview while we were queued.
const existing = getCachedPreview(data, template);
if (existing) {
setImageUrl(existing);
return;
}
try {
const blob = await createResumePdfBlob(data, template);
const url = await createPdfFirstPageImageUrl(blob);
if (cancelled || requestId !== latestRequestId.current) {
URL.revokeObjectURL(url);
return;
}
setCachedPreview(data, template, url);
setImageUrl(url);
} catch {
if (!cancelled) setHasError(true);
}
});
return () => {
cancelled = true;
};
}, [data, template]);
const isLoading = !imageUrl && !hasError;
return (
<div className={cn("relative aspect-page w-full overflow-hidden rounded-md bg-white", className)}>
<img src={imageUrl ?? fallbackSrc} alt={alt} className="size-full object-contain" />
{isLoading ? (
<div className="absolute inset-0 flex items-center justify-center bg-white/40">
<Spinner className="size-8" />
</div>
) : null}
</div>
);
}