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

View File

@ -0,0 +1,49 @@
import { useTheme } from "@reactive-resume/hooks";
import { cn, pageSizeMap } from "@reactive-resume/utils";
import { useArtboardStore } from "../store/artboard";
type Props = {
mode?: "preview" | "builder";
pageNumber: number;
children: React.ReactNode;
};
export const MM_TO_PX = 3.78;
export const Page = ({ mode = "preview", pageNumber, children }: Props) => {
const { isDarkMode } = useTheme();
const page = useArtboardStore((state) => state.resume.metadata.page);
const fontFamily = useArtboardStore((state) => state.resume.metadata.typography.font.family);
return (
<div
data-page={pageNumber}
className={cn("relative bg-white", mode === "builder" && "shadow-2xl")}
style={{
fontFamily,
padding: page.margin,
width: `${pageSizeMap[page.format].width * MM_TO_PX * window.devicePixelRatio}px`,
minHeight: `${pageSizeMap[page.format].height * MM_TO_PX * window.devicePixelRatio}px`,
}}
>
{mode === "builder" && page.options.pageNumbers && (
<div className={cn("absolute -top-7 left-0 font-bold", isDarkMode && "text-white")}>
Page {pageNumber}
</div>
)}
{children}
{mode === "builder" && page.options.breakLine && (
<div
className="absolute inset-x-0 border-b border-dashed"
style={{
top: `${pageSizeMap[page.format].height * MM_TO_PX * window.devicePixelRatio}px`,
}}
/>
)}
</div>
);
};

View File

@ -0,0 +1,22 @@
import { isUrl } from "@reactive-resume/utils";
import { useArtboardStore } from "../store/artboard";
export const Picture = () => {
const picture = useArtboardStore((state) => state.resume.basics.picture);
if (!isUrl(picture.url) || picture.effects.hidden) return null;
return (
<img
src={picture.url}
alt="Profile"
className="object-cover"
style={{
maxWidth: `${picture.size}px`,
aspectRatio: `${picture.aspectRatio}`,
borderRadius: `${picture.borderRadius}px`,
}}
/>
);
};

View File

@ -0,0 +1,13 @@
import { StrictMode } from "react";
import * as ReactDOM from "react-dom/client";
import { RouterProvider } from "react-router-dom";
import { router } from "./router";
const root = ReactDOM.createRoot(document.getElementById("root") as HTMLElement);
root.render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
);

View File

@ -0,0 +1,45 @@
import { useEffect, useMemo } from "react";
import { Outlet } from "react-router-dom";
import webfontloader from "webfontloader";
import { useArtboardStore } from "../store/artboard";
export const ArtboardPage = () => {
const metadata = useArtboardStore((state) => state.resume.metadata);
const fontString = useMemo(() => {
const family = metadata.typography.font.family;
const variants = metadata.typography.font.variants.join(",");
const subset = metadata.typography.font.subset;
return `${family}:${variants}:${subset}`;
}, [metadata.typography.font]);
useEffect(() => {
webfontloader.load({
google: { families: [fontString] },
active: () => {
const height = window.document.body.offsetHeight;
const message = { type: "PAGE_LOADED", payload: { height } };
window.postMessage(message, "*");
},
});
}, [fontString]);
// Font Size & Line Height
useEffect(() => {
document.documentElement.style.setProperty("font-size", `${metadata.typography.font.size}px`);
document.documentElement.style.setProperty("line-height", `${metadata.typography.lineHeight}`);
}, [metadata]);
// Underline Links
useEffect(() => {
if (metadata.typography.underlineLinks) {
document.querySelector("#root")!.classList.add("underline-links");
} else {
document.querySelector("#root")!.classList.remove("underline-links");
}
}, [metadata]);
return <Outlet />;
};

View File

@ -0,0 +1,63 @@
import { SectionKey } from "@reactive-resume/schema";
import { pageSizeMap } from "@reactive-resume/utils";
import { useEffect, useRef } from "react";
import { ReactZoomPanPinchRef, TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
import { MM_TO_PX, Page } from "../components/page";
import { useArtboardStore } from "../store/artboard";
import { Rhyhorn } from "../templates/rhyhorn";
export const BuilderLayout = () => {
const transformRef = useRef<ReactZoomPanPinchRef>(null);
const format = useArtboardStore((state) => state.resume.metadata.page.format);
const layout = useArtboardStore((state) => state.resume.metadata.layout);
const template = useArtboardStore((state) => state.resume.metadata.template);
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (event.data.type === "ZOOM_IN") transformRef.current?.zoomIn(0.2);
if (event.data.type === "ZOOM_OUT") transformRef.current?.zoomOut(0.2);
if (event.data.type === "CENTER_VIEW") transformRef.current?.centerView();
if (event.data.type === "RESET_VIEW") {
transformRef.current?.resetTransform(0);
setTimeout(() => transformRef.current?.centerView(0.8, 0), 10);
}
};
window.addEventListener("message", handleMessage);
return () => {
window.removeEventListener("message", handleMessage);
};
}, [transformRef]);
return (
<TransformWrapper
centerOnInit
maxScale={2}
minScale={0.4}
initialScale={0.8}
ref={transformRef}
limitToBounds={false}
>
<TransformComponent
wrapperClass="!w-screen !h-screen"
contentClass="grid items-start justify-center space-x-12 pointer-events-none"
contentStyle={{
width: `${layout.length * (pageSizeMap[format].width * MM_TO_PX + 42)}px`,
gridTemplateColumns: `repeat(${layout.length}, 1fr)`,
}}
>
{layout.map((columns, pageIndex) => (
<Page key={pageIndex} mode="builder" pageNumber={pageIndex + 1}>
{template === "rhyhorn" && (
<Rhyhorn isFirstPage={pageIndex === 0} columns={columns as SectionKey[][]} />
)}
</Page>
))}
</TransformComponent>
</TransformWrapper>
);
};

View File

@ -0,0 +1,22 @@
import { SectionKey } from "@reactive-resume/schema";
import { Page } from "../components/page";
import { useArtboardStore } from "../store/artboard";
import { Rhyhorn } from "../templates/rhyhorn";
export const PreviewLayout = () => {
const layout = useArtboardStore((state) => state.resume.metadata.layout);
const template = useArtboardStore((state) => state.resume.metadata.template);
return (
<>
{layout.map((columns, pageIndex) => (
<Page key={pageIndex} mode="preview" pageNumber={pageIndex + 1}>
{template === "rhyhorn" && (
<Rhyhorn isFirstPage={pageIndex === 0} columns={columns as SectionKey[][]} />
)}
</Page>
))}
</>
);
};

View File

@ -0,0 +1,40 @@
import { useEffect } from "react";
import { Outlet } from "react-router-dom";
import { useArtboardStore } from "../store/artboard";
export const Providers = () => {
const resume = useArtboardStore((state) => state.resume);
const setResume = useArtboardStore((state) => state.setResume);
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (event.data.type === "SET_RESUME") setResume(event.data.payload);
if (event.data.type === "SET_THEME") {
event.data.payload === "dark"
? document.documentElement.classList.add("dark")
: document.documentElement.classList.remove("dark");
}
};
const resumeData = window.sessionStorage.getItem("resume");
if (resumeData) return setResume(JSON.parse(resumeData));
window.addEventListener("message", handleMessage);
return () => {
window.removeEventListener("message", handleMessage);
};
}, [setResume]);
// Only for testing, in production this will be fetched from window.postMessage
// useEffect(() => {
// setResume(sampleResume);
// }, [setResume]);
if (!resume) return null;
return <Outlet />;
};

View File

@ -0,0 +1,17 @@
import { createBrowserRouter, createRoutesFromChildren, Route } from "react-router-dom";
import { ArtboardPage } from "../pages/artboard";
import { BuilderLayout } from "../pages/builder";
import { PreviewLayout } from "../pages/preview";
import { Providers } from "../providers";
export const routes = createRoutesFromChildren(
<Route element={<Providers />}>
<Route path="artboard" element={<ArtboardPage />}>
<Route path="builder" element={<BuilderLayout />} />
<Route path="preview" element={<PreviewLayout />} />
</Route>
</Route>,
);
export const router = createBrowserRouter(routes);

View File

@ -0,0 +1,12 @@
import { ResumeData } from "@reactive-resume/schema";
import { create } from "zustand";
export type ArtboardStore = {
resume: ResumeData;
setResume: (resume: ResumeData) => void;
};
export const useArtboardStore = create<ArtboardStore>()((set) => ({
resume: null as unknown as ResumeData,
setResume: (resume) => set({ resume }),
}));

View File

@ -0,0 +1,19 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
@apply border-current;
}
#root {
@apply antialiased;
}
#root.underline-links a {
@apply underline underline-offset-2;
}
.wysiwyg {
@apply prose max-w-none text-current prose-headings:my-1.5 prose-p:my-1.5 prose-ul:my-1.5 prose-li:my-1.5 prose-ol:my-1.5 prose-img:my-1.5 prose-hr:my-1.5;
}

View File

@ -0,0 +1,695 @@
import { SectionKey } from "@reactive-resume/schema";
import { cn, isEmptyString, isUrl } from "@reactive-resume/utils";
import { Fragment } from "react";
import { Picture } from "../components/picture";
import { useArtboardStore } from "../store/artboard";
import { TemplateProps } from "../types/template";
const fieldDisplay = cn("flex items-center gap-x-1.5 border-r pr-2 last:border-r-0 last:pr-0");
const Header = () => {
const basics = useArtboardStore((state) => state.resume.basics);
return (
<div className="flex items-center space-x-4">
<Picture />
<div className="space-y-0.5">
<div className="text-2xl font-bold leading-tight">{basics.name}</div>
<div className="text-base">{basics.headline}</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm">
{basics.location && (
<div className={cn(fieldDisplay)}>
<i className="ph ph-map-pin" />
<div>{basics.location}</div>
</div>
)}
{basics.phone && (
<div className={cn(fieldDisplay)}>
<i className="ph ph-phone" />
<a href={`tel:${basics.phone}`} target="_blank" rel="noreferrer">
{basics.phone}
</a>
</div>
)}
{basics.email && (
<div className={cn(fieldDisplay)}>
<i className="ph ph-envelope" />
<a href={`mailto:${basics.email}`} target="_blank" rel="noreferrer">
{basics.email}
</a>
</div>
)}
{isUrl(basics.url.href) && (
<div className={cn(fieldDisplay)}>
<i className="ph ph-globe" />
<a href={basics.url.href} target="_blank" rel="noreferrer">
{basics.url.label || basics.url.href}
</a>
</div>
)}
{basics.customFields.map((item) => (
<div key={item.id} className={cn(fieldDisplay)}>
<i className={`ph ph-${item.icon}`} />
<span>{[item.name, item.value].filter(Boolean).join(": ")}</span>
</div>
))}
</div>
</div>
</div>
);
};
const sectionHeading = cn("mb-1.5 mt-3 border-b pb-0.5 text-sm font-bold uppercase");
const Profiles = () => {
const section = useArtboardStore((state) => state.resume.sections.profiles);
if (!section.visible || !section.items.length) return null;
return (
<section id={section.id}>
<h4 className={cn(sectionHeading)}>{section.name}</h4>
<div
className="mt-2 grid items-center"
style={{ gridTemplateColumns: `repeat(${section.columns}, 1fr)` }}
>
{section.items.map((item) => (
<div key={item.id} className="flex items-center gap-x-3">
<img
width="16"
height="16"
alt={item.network}
src={`https://cdn.simpleicons.org/${item.icon}`}
/>
<div className="leading-tight">
{isUrl(item.url.href) ? (
<a href={item.url.href} target="_blank" rel="noreferrer" className="font-medium">
{item.url.label || item.username}
</a>
) : (
<span className="font-medium">{item.username}</span>
)}
<p className="text-sm">{item.network}</p>
</div>
</div>
))}
</div>
</section>
);
};
const Summary = () => {
const section = useArtboardStore((state) => state.resume.sections.summary);
if (!section.visible || !section.content) return null;
return (
<section id={section.id}>
<h4 className={cn(sectionHeading)}>{section.name}</h4>
{!isEmptyString(section.content) && (
<main>
<div
className="wysiwyg"
style={{ columns: section.columns }}
dangerouslySetInnerHTML={{ __html: section.content }}
/>
</main>
)}
</section>
);
};
const Experience = () => {
const section = useArtboardStore((state) => state.resume.sections.experience);
if (!section.visible || !section.items.length) return null;
return (
<section id={section.id}>
<h4 className={cn(sectionHeading)}>{section.name}</h4>
<div
className="grid items-start gap-x-4 gap-y-2"
style={{ gridTemplateColumns: `repeat(${section.columns}, 1fr)` }}
>
{section.items.map((item) => (
<div key={item.id}>
<header className="mb-2 flex items-center justify-between">
<div className="text-left">
<div className="font-bold">{item.company}</div>
<div>{item.position}</div>
{isUrl(item.url.href) && (
<div>
<a href={item.url.href} target="_blank" rel="noreferrer">
{item.url.label || item.url.href}
</a>
</div>
)}
</div>
<div className="shrink-0 text-right">
<div className="font-bold">{item.date}</div>
<div>{item.location}</div>
</div>
</header>
{!isEmptyString(item.summary) && (
<main>
<div className="wysiwyg" dangerouslySetInnerHTML={{ __html: item.summary }} />
</main>
)}
</div>
))}
</div>
</section>
);
};
const Education = () => {
const section = useArtboardStore((state) => state.resume.sections.education);
if (!section.visible || !section.items.length) return null;
return (
<section id={section.id}>
<h4 className={cn(sectionHeading)}>{section.name}</h4>
<div
className="grid items-start gap-x-4 gap-y-2"
style={{ gridTemplateColumns: `repeat(${section.columns}, 1fr)` }}
>
{section.items.map((item) => (
<div key={item.id}>
<header className="mb-2 flex items-center justify-between">
<div className="text-left">
<div className="font-bold">{item.institution}</div>
<div>{item.area}</div>
{isUrl(item.url.href) && (
<div>
<a href={item.url.href} target="_blank" rel="noreferrer">
{item.url.label || item.url.href}
</a>
</div>
)}
</div>
<div className="shrink-0 text-right">
<div className="font-bold">{item.date}</div>
<div>{item.studyType}</div>
<div>{item.score}</div>
</div>
</header>
{!isEmptyString(item.summary) && (
<main>
<div className="wysiwyg" dangerouslySetInnerHTML={{ __html: item.summary }} />
</main>
)}
</div>
))}
</div>
</section>
);
};
const Awards = () => {
const section = useArtboardStore((state) => state.resume.sections.awards);
if (!section.visible || !section.items.length) return null;
return (
<section id={section.id}>
<h4 className={cn(sectionHeading)}>{section.name}</h4>
<div
className="grid items-start gap-x-4 gap-y-2"
style={{ gridTemplateColumns: `repeat(${section.columns}, 1fr)` }}
>
{section.items.map((item) => (
<div key={item.id} className="space-y-2">
<header className="flex items-center justify-between">
<div className="text-left">
<div className="font-bold">{item.title}</div>
<div>{item.awarder}</div>
</div>
<div className="shrink-0 text-right">
<div className="font-bold">{item.date}</div>
{isUrl(item.url.href) && (
<div>
<a href={item.url.href} target="_blank" rel="noreferrer">
{item.url.label || item.url.href}
</a>
</div>
)}
</div>
</header>
{!isEmptyString(item.summary) && (
<main>
<div className="wysiwyg" dangerouslySetInnerHTML={{ __html: item.summary }} />
</main>
)}
</div>
))}
</div>
</section>
);
};
const Certifications = () => {
const section = useArtboardStore((state) => state.resume.sections.certifications);
if (!section.visible || !section.items.length) return null;
return (
<section id={section.id}>
<h4 className={cn(sectionHeading)}>{section.name}</h4>
<div
className="grid items-start gap-x-4 gap-y-2"
style={{ gridTemplateColumns: `repeat(${section.columns}, 1fr)` }}
>
{section.items.map((item) => (
<div key={item.id} className="space-y-2">
<header className="flex items-center justify-between">
<div className="text-left">
<div className="font-bold">{item.name}</div>
<div>{item.issuer}</div>
</div>
<div className="shrink-0 text-right">
<div className="font-bold">{item.date}</div>
{isUrl(item.url.href) && (
<div>
<a href={item.url.href} target="_blank" rel="noreferrer">
{item.url.label || item.url.href}
</a>
</div>
)}
</div>
</header>
{!isEmptyString(item.summary) && (
<main>
<div className="wysiwyg" dangerouslySetInnerHTML={{ __html: item.summary }} />
</main>
)}
</div>
))}
</div>
</section>
);
};
const Skills = () => {
const section = useArtboardStore((state) => state.resume.sections.skills);
if (!section.visible || !section.items.length) return null;
return (
<section id={section.id}>
<h4 className={cn(sectionHeading)}>{section.name}</h4>
<div
className="grid items-start gap-x-4 gap-y-2"
style={{ gridTemplateColumns: `repeat(${section.columns}, 1fr)` }}
>
{section.items.map((item) => (
<div key={item.id} className="space-y-2">
<header className="flex items-center justify-between">
<div className="text-left">
<div className="font-bold">{item.name}</div>
<div>{item.description}</div>
</div>
</header>
{item.keywords.length > 0 && (
<footer>
<p className="text-sm">{item.keywords.join(", ")}</p>
</footer>
)}
</div>
))}
</div>
</section>
);
};
const Interests = () => {
const section = useArtboardStore((state) => state.resume.sections.interests);
if (!section.visible || !section.items.length) return null;
return (
<section id={section.id}>
<h4 className={cn(sectionHeading)}>{section.name}</h4>
<div
className="grid items-start gap-x-4 gap-y-2"
style={{ gridTemplateColumns: `repeat(${section.columns}, 1fr)` }}
>
{section.items.map((item) => (
<div key={item.id}>
<header className="flex items-center justify-between">
<div className="text-left">
<div className="font-bold">{item.name}</div>
</div>
</header>
{item.keywords.length > 0 && (
<footer>
<p className="text-sm">{item.keywords.join(", ")}</p>
</footer>
)}
</div>
))}
</div>
</section>
);
};
const Publications = () => {
const section = useArtboardStore((state) => state.resume.sections.publications);
if (!section.visible || !section.items.length) return null;
return (
<section id={section.id}>
<h4 className={cn(sectionHeading)}>{section.name}</h4>
<div
className="grid items-start gap-x-4 gap-y-2"
style={{ gridTemplateColumns: `repeat(${section.columns}, 1fr)` }}
>
{section.items.map((item) => (
<div key={item.id} className="space-y-2">
<header className="flex items-center justify-between">
<div className="text-left">
<div className="font-bold">{item.name}</div>
<div>{item.publisher}</div>
</div>
<div className="shrink-0 text-right">
<div className="font-bold">{item.date}</div>
{isUrl(item.url.href) && (
<div>
<a href={item.url.href} target="_blank" rel="noreferrer">
{item.url.label || item.url.href}
</a>
</div>
)}
</div>
</header>
{!isEmptyString(item.summary) && (
<main>
<div className="wysiwyg" dangerouslySetInnerHTML={{ __html: item.summary }} />
</main>
)}
</div>
))}
</div>
</section>
);
};
const Volunteer = () => {
const section = useArtboardStore((state) => state.resume.sections.volunteer);
if (!section.visible || !section.items.length) return null;
return (
<section id={section.id}>
<h4 className={cn(sectionHeading)}>{section.name}</h4>
<div
className="grid items-start gap-x-4 gap-y-2"
style={{ gridTemplateColumns: `repeat(${section.columns}, 1fr)` }}
>
{section.items.map((item) => (
<div key={item.id}>
<header className="mb-2 flex items-center justify-between">
<div className="text-left">
<div className="font-bold">{item.organization}</div>
<div>{item.position}</div>
{isUrl(item.url.href) && (
<div>
<a href={item.url.href} target="_blank" rel="noreferrer">
{item.url.label || item.url.href}
</a>
</div>
)}
</div>
<div className="shrink-0 text-right">
<div className="font-bold">{item.date}</div>
<div>{item.location}</div>
</div>
</header>
{!isEmptyString(item.summary) && (
<main>
<div className="wysiwyg" dangerouslySetInnerHTML={{ __html: item.summary }} />
</main>
)}
</div>
))}
</div>
</section>
);
};
const Languages = () => {
const section = useArtboardStore((state) => state.resume.sections.languages);
if (!section.visible || !section.items.length) return null;
return (
<section id={section.id}>
<h4 className={cn(sectionHeading)}>{section.name}</h4>
<div
className="grid items-start gap-x-4 gap-y-2"
style={{ gridTemplateColumns: `repeat(${section.columns}, 1fr)` }}
>
{section.items.map((item) => (
<div key={item.id} className="space-y-2">
<header className="flex items-center justify-between">
<div className="text-left">
<div className="font-bold">{item.name}</div>
<div>{item.fluency}</div>
</div>
</header>
</div>
))}
</div>
</section>
);
};
const Projects = () => {
const section = useArtboardStore((state) => state.resume.sections.projects);
if (!section.visible || !section.items.length) return null;
return (
<section id={section.id}>
<h4 className={cn(sectionHeading)}>{section.name}</h4>
<div
className="grid items-start gap-x-4 gap-y-2"
style={{ gridTemplateColumns: `repeat(${section.columns}, 1fr)` }}
>
{section.items.map((item) => (
<div key={item.id} className="space-y-2">
<header className="flex items-center justify-between">
<div className="text-left">
<div className="font-bold">{item.name}</div>
<div>{item.description}</div>
</div>
<div className="shrink-0 text-right">
<div className="font-bold">{item.date}</div>
{isUrl(item.url.href) && (
<div>
<a href={item.url.href} target="_blank" rel="noreferrer">
{item.url.label || item.url.href}
</a>
</div>
)}
</div>
</header>
{!isEmptyString(item.summary) && (
<main>
<div className="wysiwyg" dangerouslySetInnerHTML={{ __html: item.summary }} />
</main>
)}
{item.keywords.length > 0 && (
<footer>
<p className="text-sm">{item.keywords.join(", ")}</p>
</footer>
)}
</div>
))}
</div>
</section>
);
};
const References = () => {
const section = useArtboardStore((state) => state.resume.sections.references);
if (!section.visible || !section.items.length) return null;
return (
<section id={section.id}>
<h4 className={cn(sectionHeading)}>{section.name}</h4>
<div
className="grid items-start gap-x-4 gap-y-2"
style={{ gridTemplateColumns: `repeat(${section.columns}, 1fr)` }}
>
{section.items.map((item) => (
<div key={item.id} className="space-y-2">
<header className="flex items-center justify-between">
<div className="text-left">
<div className="font-bold">{item.name}</div>
<div>{item.description}</div>
{isUrl(item.url.href) && (
<div>
<a href={item.url.href} target="_blank" rel="noreferrer">
{item.url.label || item.url.href}
</a>
</div>
)}
</div>
</header>
{!isEmptyString(item.summary) && (
<main>
<div className="wysiwyg" dangerouslySetInnerHTML={{ __html: item.summary }} />
</main>
)}
</div>
))}
</div>
</section>
);
};
const Custom = ({ id }: { id: string }) => {
const section = useArtboardStore((state) => state.resume.sections.custom[id]);
if (!section || !section.visible || !section.items.length) return null;
return (
<section id={section.id}>
<h4 className={cn(sectionHeading)}>{section.name}</h4>
<div
className="grid items-start gap-x-4 gap-y-2"
style={{ gridTemplateColumns: `repeat(${section.columns}, 1fr)` }}
>
{section.items.map((item) => (
<div key={item.id} className="space-y-2">
<header className="flex items-center justify-between">
<div className="text-left">
<div className="font-bold">{item.name}</div>
<div>{item.description}</div>
{isUrl(item.url.href) && (
<div>
<a href={item.url.href} target="_blank" rel="noreferrer">
{item.url.label || item.url.href}
</a>
</div>
)}
</div>
<div className="shrink-0 text-right">
<div className="font-bold">{item.date}</div>
<div>{item.location}</div>
</div>
</header>
{!isEmptyString(item.summary) && (
<main>
<div className="wysiwyg" dangerouslySetInnerHTML={{ __html: item.summary }} />
</main>
)}
{item.keywords.length > 0 && (
<footer>
<p className="text-sm">{item.keywords.join(", ")}</p>
</footer>
)}
</div>
))}
</div>
</section>
);
};
const mapSectionToComponent = (section: SectionKey) => {
switch (section) {
case "profiles":
return <Profiles />;
case "summary":
return <Summary />;
case "experience":
return <Experience />;
case "education":
return <Education />;
case "awards":
return <Awards />;
case "certifications":
return <Certifications />;
case "skills":
return <Skills />;
case "interests":
return <Interests />;
case "publications":
return <Publications />;
case "volunteer":
return <Volunteer />;
case "languages":
return <Languages />;
case "projects":
return <Projects />;
case "references":
return <References />;
default:
if (section.startsWith("custom.")) return <Custom id={section.split(".")[1]} />;
return <p>{section}</p>;
}
};
export const Rhyhorn = ({ columns, isFirstPage = false }: TemplateProps) => {
const [main, sidebar] = columns;
return (
<div className="space-y-4">
{isFirstPage && <Header />}
<div className="space-y-2">
{main.map((section) => (
<Fragment key={section}>{mapSectionToComponent(section)}</Fragment>
))}
{sidebar.map((section) => (
<Fragment key={section}>{mapSectionToComponent(section)}</Fragment>
))}
</div>
</div>
);
};

View File

@ -0,0 +1,11 @@
import { SectionKey } from "@reactive-resume/schema";
export type TemplateProps = {
columns: SectionKey[][];
isFirstPage?: boolean;
};
export type BaseProps = {
children?: React.ReactNode;
className?: string;
};