mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-12 05:55:02 +10:00
feat(builder): show live PDF previews in template gallery, remove hover card
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user