mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2025-11-18 10:41:56 +10:00
feat(templates): replace library with microfrontend app for templates
This commit is contained in:
0
apps/artboard/src/assets/.gitkeep
Normal file
0
apps/artboard/src/assets/.gitkeep
Normal file
49
apps/artboard/src/components/page.tsx
Normal file
49
apps/artboard/src/components/page.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
22
apps/artboard/src/components/picture.tsx
Normal file
22
apps/artboard/src/components/picture.tsx
Normal 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`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
13
apps/artboard/src/main.tsx
Normal file
13
apps/artboard/src/main.tsx
Normal 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>,
|
||||
);
|
||||
45
apps/artboard/src/pages/artboard.tsx
Normal file
45
apps/artboard/src/pages/artboard.tsx
Normal 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 />;
|
||||
};
|
||||
63
apps/artboard/src/pages/builder.tsx
Normal file
63
apps/artboard/src/pages/builder.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
22
apps/artboard/src/pages/preview.tsx
Normal file
22
apps/artboard/src/pages/preview.tsx
Normal 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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
40
apps/artboard/src/providers/index.tsx
Normal file
40
apps/artboard/src/providers/index.tsx
Normal 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 />;
|
||||
};
|
||||
17
apps/artboard/src/router/index.tsx
Normal file
17
apps/artboard/src/router/index.tsx
Normal 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);
|
||||
12
apps/artboard/src/store/artboard.ts
Normal file
12
apps/artboard/src/store/artboard.ts
Normal 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 }),
|
||||
}));
|
||||
19
apps/artboard/src/styles/main.css
Normal file
19
apps/artboard/src/styles/main.css
Normal 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;
|
||||
}
|
||||
695
apps/artboard/src/templates/rhyhorn.tsx
Normal file
695
apps/artboard/src/templates/rhyhorn.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
11
apps/artboard/src/types/template.ts
Normal file
11
apps/artboard/src/types/template.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user