feat(templates): replace library with microfrontend app for templates

This commit is contained in:
Amruth Pillai
2023-11-07 16:37:16 +01:00
parent fca61543c5
commit 1aa8aa6900
87 changed files with 1512 additions and 1835 deletions

View File

@ -22,7 +22,7 @@ export const BuilderToolbar = () => {
const setValue = useResumeStore((state) => state.setValue);
const undo = useTemporalResumeStore((state) => state.undo);
const redo = useTemporalResumeStore((state) => state.redo);
const transformRef = useBuilderStore((state) => state.transform.ref);
const frameRef = useBuilderStore((state) => state.frame.ref);
const id = useResumeStore((state) => state.resume.id);
const isPublic = useResumeStore((state) => state.resume.visibility === "public");
@ -41,6 +41,11 @@ export const BuilderToolbar = () => {
openInNewTab(url);
};
const onZoomIn = () => frameRef?.contentWindow?.postMessage({ type: "ZOOM_IN" }, "*");
const onZoomOut = () => frameRef?.contentWindow?.postMessage({ type: "ZOOM_OUT" }, "*");
const onResetView = () => frameRef?.contentWindow?.postMessage({ type: "RESET_VIEW" }, "*");
const onCenterView = () => frameRef?.contentWindow?.postMessage({ type: "CENTER_VIEW" }, "*");
return (
<motion.div
initial={{ opacity: 0, bottom: -64 }}
@ -65,49 +70,26 @@ export const BuilderToolbar = () => {
<Separator orientation="vertical" className="h-9" />
{/* Zoom In */}
<Tooltip content="Zoom In">
<Button
size="icon"
variant="ghost"
className="rounded-none"
onClick={() => transformRef?.zoomIn(0.2)}
>
<Button size="icon" variant="ghost" className="rounded-none" onClick={onZoomIn}>
<MagnifyingGlassPlus />
</Button>
</Tooltip>
{/* Zoom Out */}
<Tooltip content="Zoom Out">
<Button
size="icon"
variant="ghost"
className="rounded-none"
onClick={() => transformRef?.zoomOut(0.2)}
>
<Button size="icon" variant="ghost" className="rounded-none" onClick={onZoomOut}>
<MagnifyingGlassMinus />
</Button>
</Tooltip>
<Tooltip content="Reset Zoom">
<Button
size="icon"
variant="ghost"
className="rounded-none"
onClick={() => transformRef?.resetTransform()}
>
<Button size="icon" variant="ghost" className="rounded-none" onClick={onResetView}>
<ClockClockwise />
</Button>
</Tooltip>
{/* Center Artboard */}
<Tooltip content="Center Artboard">
<Button
size="icon"
variant="ghost"
className="rounded-none"
onClick={() => transformRef?.centerView()}
>
<Button size="icon" variant="ghost" className="rounded-none" onClick={onCenterView}>
<CubeFocus />
</Button>
</Tooltip>

View File

@ -33,7 +33,7 @@ export const BuilderLayout = () => {
if (isDesktop) {
return (
<div className="relative h-full w-full overflow-hidden">
<PanelGroup direction="horizontal" autoSaveId="builder-layout">
<PanelGroup direction="horizontal">
<Panel
collapsible
minSize={20}

View File

@ -1,19 +1,7 @@
import { ResumeDto } from "@reactive-resume/dto";
import { SectionKey } from "@reactive-resume/schema";
import {
Artboard,
PageBreakLine,
PageGrid,
PageNumber,
PageWrapper,
templatesList,
} from "@reactive-resume/templates";
import { pageSizeMap } from "@reactive-resume/utils";
import { AnimatePresence, motion } from "framer-motion";
import { useMemo } from "react";
import { useCallback, useEffect } from "react";
import { Helmet } from "react-helmet-async";
import { LoaderFunction, redirect } from "react-router-dom";
import { ReactZoomPanPinchRef, TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
import { queryClient } from "@/client/libs/query-client";
import { findResumeById } from "@/client/services/resume";
@ -21,28 +9,27 @@ import { useBuilderStore } from "@/client/stores/builder";
import { useResumeStore } from "@/client/stores/resume";
export const BuilderPage = () => {
const frameRef = useBuilderStore((state) => state.frame.ref);
const setFrameRef = useBuilderStore((state) => state.frame.setRef);
const resume = useResumeStore((state) => state.resume);
const title = useResumeStore((state) => state.resume.title);
const resume = useResumeStore((state) => state.resume.data);
const setTransformRef = useBuilderStore((state) => state.transform.setRef);
const { pageHeight, showBreakLine, showPageNumbers } = useMemo(() => {
const { format, options } = resume.metadata.page;
const updateResumeInFrame = useCallback(() => {
if (!frameRef || !frameRef.contentWindow) return;
const message = { type: "SET_RESUME", payload: resume.data };
(() => frameRef.contentWindow.postMessage(message, "*"))();
}, [frameRef, resume.data]);
return {
pageHeight: pageSizeMap[format].height,
showBreakLine: options.breakLine,
showPageNumbers: options.pageNumbers,
};
}, [resume.metadata.page]);
// Send resume data to iframe on initial load
useEffect(() => {
if (!frameRef) return;
frameRef.addEventListener("load", updateResumeInFrame);
return () => frameRef.removeEventListener("load", updateResumeInFrame);
}, [frameRef]);
const Template = useMemo(() => {
const Component = templatesList.find((template) => template.id === resume.metadata.template)
?.Component;
if (!Component) return null;
return Component;
}, [resume.metadata.template]);
// Send resume data to iframe on change of resume data
useEffect(updateResumeInFrame, [resume.data]);
return (
<>
@ -50,45 +37,13 @@ export const BuilderPage = () => {
<title>{title} - Reactive Resume</title>
</Helmet>
<TransformWrapper
centerOnInit
minScale={0.4}
initialScale={1}
limitToBounds={false}
velocityAnimation={{ disabled: true }}
ref={(ref: ReactZoomPanPinchRef) => setTransformRef(ref)}
>
<TransformComponent wrapperClass="!w-screen !h-screen">
<PageGrid $offset={32}>
<AnimatePresence presenceAffectsLayout>
{resume.metadata.layout.map((columns, pageIndex) => (
<motion.div
layout
key={pageIndex}
initial={{ opacity: 0, x: -100 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -100 }}
>
<Artboard resume={resume}>
<PageWrapper>
{showPageNumbers && <PageNumber>Page {pageIndex + 1}</PageNumber>}
{Template !== null && (
<Template
isFirstPage={pageIndex === 0}
columns={columns as SectionKey[][]}
/>
)}
{showBreakLine && <PageBreakLine $pageHeight={pageHeight} />}
</PageWrapper>
</Artboard>
</motion.div>
))}
</AnimatePresence>
</PageGrid>
</TransformComponent>
</TransformWrapper>
<iframe
ref={setFrameRef}
title={resume.id}
src="/artboard/builder"
className="mt-16 w-screen"
style={{ height: `calc(100vh - 64px)` }}
/>
</>
);
};

View File

@ -1,6 +1,5 @@
import { templatesList } from "@reactive-resume/templates";
import { Button, HoverCard, HoverCardContent, HoverCardTrigger } from "@reactive-resume/ui";
import { cn } from "@reactive-resume/utils";
import { cn, templatesList } from "@reactive-resume/utils";
import { useResumeStore } from "@/client/stores/resume";
@ -20,7 +19,7 @@ export const TemplateSection = () => {
</header>
<main className="grid grid-cols-2 gap-4">
{templatesList.map(({ id, name }) => (
{templatesList.map(({ id, name, image }) => (
<HoverCard key={id} openDelay={0} closeDelay={0}>
<HoverCardTrigger asChild>
<Button
@ -36,12 +35,7 @@ export const TemplateSection = () => {
</HoverCardTrigger>
<HoverCardContent className="max-w-xs overflow-hidden border-none bg-white p-0">
<img
alt={name}
loading="lazy"
src={`/templates/${id}.jpg`}
className="aspect-[1/1.4142]"
/>
<img alt={name} src={image} loading="lazy" className="aspect-[1/1.4142]" />
</HoverCardContent>
</HoverCard>
))}

View File

@ -1,7 +1,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { CaretDown, Flask, MagicWand, Plus } from "@phosphor-icons/react";
import { createResumeSchema, ResumeDto } from "@reactive-resume/dto";
import { idSchema } from "@reactive-resume/schema";
import { idSchema, sampleResume } from "@reactive-resume/schema";
import {
AlertDialog,
AlertDialogAction,
@ -38,7 +38,6 @@ import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { sampleResume } from "@/client/constants/sample-resume";
import { useToast } from "@/client/hooks/use-toast";
import { useCreateResume, useDeleteResume, useUpdateResume } from "@/client/services/resume";
import { useImportResume } from "@/client/services/resume/import";
@ -258,7 +257,7 @@ export const ResumeDialog = () => {
{isDuplicate && "Duplicate"}
</Button>
<DropdownMenu>
<DropdownMenuTrigger>
<DropdownMenuTrigger asChild>
<Button type="button" size="icon" className="rounded-l-none border-l">
<CaretDown />
</Button>

View File

@ -1,24 +0,0 @@
import { useTemplate } from "@reactive-resume/hooks";
import { ResumeData, SectionKey } from "@reactive-resume/schema";
import { Artboard, PageWrapper } from "@reactive-resume/templates";
import { Navigate } from "react-router-dom";
import { useSessionStorage } from "usehooks-ts";
export const PrinterPage = () => {
const [resume] = useSessionStorage<ResumeData | null>("resume", null);
const template = useTemplate(resume?.metadata.template);
if (!resume) return <Navigate to="/" replace />;
return (
<Artboard resume={resume} style={{ pointerEvents: "auto" }}>
{resume.metadata.layout.map((columns, pageIndex) => (
<PageWrapper key={pageIndex} data-page={pageIndex + 1}>
{template !== null && (
<template.Component isFirstPage={pageIndex === 0} columns={columns as SectionKey[][]} />
)}
</PageWrapper>
))}
</Artboard>
);
};

View File

@ -1,9 +1,7 @@
import { ResumeDto } from "@reactive-resume/dto";
import { useTemplate } from "@reactive-resume/hooks";
import { SectionKey } from "@reactive-resume/schema";
import { Artboard, PageWrapper } from "@reactive-resume/templates";
import { Button } from "@reactive-resume/ui";
import { pageSizeMap } from "@reactive-resume/utils";
import { useCallback, useEffect, useRef } from "react";
import { Helmet } from "react-helmet-async";
import { Link, LoaderFunction, redirect, useLoaderData } from "react-router-dom";
@ -14,9 +12,42 @@ import { queryClient } from "@/client/libs/query-client";
import { findResumeByUsernameSlug } from "@/client/services/resume";
export const PublicResumePage = () => {
const frameRef = useRef<HTMLIFrameElement>(null);
const { title, data: resume } = useLoaderData() as ResumeDto;
const format = resume.metadata.page.format;
const template = useTemplate(resume.metadata.template);
const updateResumeInFrame = useCallback(() => {
if (!frameRef.current || !frameRef.current.contentWindow) return;
const message = { type: "SET_RESUME", payload: resume };
(() => frameRef.current.contentWindow.postMessage(message, "*"))();
}, [frameRef, resume]);
useEffect(() => {
if (!frameRef.current) return;
frameRef.current.addEventListener("load", updateResumeInFrame);
return () => frameRef.current?.removeEventListener("load", updateResumeInFrame);
}, [frameRef]);
useEffect(() => {
if (!frameRef.current || !frameRef.current.contentWindow) return;
const handleMessage = (event: MessageEvent) => {
if (!frameRef.current || !frameRef.current.contentWindow) return;
if (event.origin !== window.location.origin) return;
if (event.data.type === "PAGE_LOADED") {
frameRef.current.height = event.data.payload.height;
frameRef.current.contentWindow.removeEventListener("message", handleMessage);
}
};
frameRef.current.contentWindow.addEventListener("message", handleMessage);
return () => {
frameRef.current?.contentWindow?.removeEventListener("message", handleMessage);
};
}, [frameRef]);
return (
<div>
@ -26,20 +57,14 @@ export const PublicResumePage = () => {
<div
style={{ width: `${pageSizeMap[format].width}mm` }}
className="mx-auto mb-6 mt-16 flex shadow-xl print:m-0 print:shadow-none"
className="mx-auto mb-6 mt-16 overflow-hidden rounded shadow-xl print:m-0 print:shadow-none"
>
<Artboard resume={resume} style={{ pointerEvents: "auto" }}>
{resume.metadata.layout.map((columns, pageIndex) => (
<PageWrapper key={pageIndex} data-page={pageIndex + 1}>
{template !== null && (
<template.Component
isFirstPage={pageIndex === 0}
columns={columns as SectionKey[][]}
/>
)}
</PageWrapper>
))}
</Artboard>
<iframe
title={title}
ref={frameRef}
src="/artboard/preview"
style={{ width: `${pageSizeMap[format].width}mm` }}
/>
</div>
<div className="flex justify-center py-10 opacity-50 print:hidden">