feat(builder): show live PDF previews in template gallery, remove hover card

This commit is contained in:
Amruth Pillai
2026-07-04 18:05:45 +02:00
parent bc09430fdf
commit 27df724d2a
2 changed files with 50 additions and 84 deletions
@@ -3,14 +3,12 @@ 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 { useLingui } from "@lingui/react";
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";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@reactive-resume/ui/components/hover-card";
import { ScrollArea } from "@reactive-resume/ui/components/scroll-area";
import { cn } from "@reactive-resume/utils/style";
import { CometCard } from "@/components/animation/comet-card";
@@ -18,9 +16,8 @@ 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 a preview card actually opens — mirrors the
// sidebar template card. The hover card mounts its content only when open, so at most one tile renders
// live at a time; `TemplateLivePreview` caches per (data, template) and cancels on close.
// 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,
@@ -34,7 +31,6 @@ export function TemplateGalleryDialog(_: DialogProps<"resume.template.gallery">)
const updateResumeData = useUpdateResumeData();
function onSelectTemplate(template: Template) {
// Snapshot the only field this switch mutates so the undo action fully reverts it.
const previousTemplate = resume.data.metadata.template;
if (template === previousTemplate) {
closeDialog();
@@ -60,7 +56,7 @@ export function TemplateGalleryDialog(_: DialogProps<"resume.template.gallery">)
}
return (
<DialogContent className="lg:max-w-5xl">
<DialogContent className="lg:max-w-6xl xl:max-w-7xl">
<DialogHeader className="gap-2">
<DialogTitle className="flex items-center gap-3 text-xl">
<SlideshowIcon size={20} />
@@ -75,8 +71,8 @@ export function TemplateGalleryDialog(_: DialogProps<"resume.template.gallery">)
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[80svh] pb-8">
<div className="grid grid-cols-2 gap-6 p-4 md:grid-cols-3 lg:grid-cols-4">
<ScrollArea className="max-h-[85svh] pb-8">
<div className="grid grid-cols-2 gap-6 p-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{Object.entries(templates).map(([template, metadata]) => (
<TemplateCard
key={template}
@@ -102,73 +98,41 @@ type TemplateCardProps = {
};
function TemplateCard({ id, data, metadata, isActive, onSelect }: TemplateCardProps) {
const { i18n } = useLingui();
return (
<HoverCard>
<CometCard translateDepth={3} rotateDepth={6} glareOpacity={0}>
<HoverCardTrigger
delay={300}
render={
<button
type="button"
onClick={() => onSelect(id)}
className={cn(
"relative block aspect-page size-full cursor-pointer overflow-hidden rounded-md bg-popover outline-none",
isActive && "ring-2 ring-ring ring-offset-4 ring-offset-background",
)}
>
<img src={metadata.imageUrl} alt={metadata.name} className="size-full object-cover" />
</button>
}
/>
<CometCard translateDepth={3} rotateDepth={6} glareOpacity={0}>
<button
type="button"
onClick={() => onSelect(id)}
className={cn(
"relative block aspect-page size-full cursor-pointer overflow-hidden rounded-md bg-popover outline-none",
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={t`Live preview of your resume in the ${metadata.name} template`}
/>
</Suspense>
</button>
<div className="flex items-center justify-center">
<span className="font-bold leading-loose tracking-tight">{metadata.name}</span>
<div className="mt-1 flex items-center justify-center">
<span className="font-bold leading-loose tracking-tight">{metadata.name}</span>
</div>
{metadata.tags.length > 0 && (
<div className="flex flex-wrap justify-center gap-1 px-1 pb-1">
{metadata.tags
.sort((a, b) => a.localeCompare(b))
.map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
<HoverCardContent
side="right"
sideOffset={-32}
align="start"
alignOffset={32}
className="pointer-events-none! flex w-80 flex-col justify-between gap-y-6 rounded-md bg-background/80 p-4 pb-6"
>
{/* Live peek of the user's own data through this template. Static JPG tile stays the cheap default;
this renders on demand only while the card is open, one tile at a time. */}
<Suspense
fallback={
<div className="aspect-page w-full overflow-hidden rounded-md bg-white">
<img src={metadata.imageUrl} alt={metadata.name} className="size-full object-contain" />
</div>
}
>
<TemplateLivePreview
data={data}
template={id}
fallbackSrc={metadata.imageUrl}
alt={t`Live preview of your resume in the ${metadata.name} template`}
/>
</Suspense>
<div className="space-y-1">
<h3 className="font-semibold text-lg">{metadata.name}</h3>
<p className="text-muted-foreground">{i18n.t(metadata.description)}</p>
</div>
{metadata.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{metadata.tags
.sort((a, b) => a.localeCompare(b))
.map((tag) => (
<Badge key={tag} variant="default">
{tag}
</Badge>
))}
</div>
)}
</HoverCardContent>
</CometCard>
</HoverCard>
)}
</CometCard>
);
}
@@ -1,6 +1,6 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { Template } from "@reactive-resume/schema/templates";
import { useEffect, useState } from "react";
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";
@@ -8,8 +8,8 @@ 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-8 linear scan — trivial for a hover preview; only one card renders at a time.
const PREVIEW_CACHE_LIMIT = 8;
// 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[] = [];
@@ -24,10 +24,10 @@ const setCachedPreview = (data: ResumeData, template: Template, url: string) =>
}
};
// Single-flight render pipeline shared across every mounted instance: renders run one-at-a-time on the
// main thread (serialized through `renderQueue`), and only the latest requested render commits its result
// (`latestRenderRequestId`), so rapid hovers between templates discard superseded work instead of stacking.
let latestRenderRequestId = 0;
// 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 = {
@@ -47,6 +47,8 @@ type TemplateLivePreviewProps = {
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);
@@ -56,10 +58,10 @@ export function TemplateLivePreview({ alt, className, data, fallbackSrc, templat
}
let cancelled = false;
const requestId = ++latestRenderRequestId;
const requestId = ++latestRequestId.current;
renderQueue = renderQueue.then(async () => {
if (cancelled || requestId !== latestRenderRequestId) return;
if (cancelled || requestId !== latestRequestId.current) return;
// Another instance may have cached this exact preview while we were queued.
const existing = getCachedPreview(data, template);
@@ -72,7 +74,7 @@ export function TemplateLivePreview({ alt, className, data, fallbackSrc, templat
const blob = await createResumePdfBlob(data, template);
const url = await createPdfFirstPageImageUrl(blob);
if (cancelled || requestId !== latestRenderRequestId) {
if (cancelled || requestId !== latestRequestId.current) {
URL.revokeObjectURL(url);
return;
}