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

@ -0,0 +1,31 @@
{
"extends": ["plugin:@nx/react", "../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"extends": ["plugin:tailwindcss/recommended"],
"settings": {
"tailwindcss": {
"callees": ["cn", "clsx", "cva"],
"config": "tailwind.config.js"
}
},
"rules": {
// react-hooks
"react-hooks/exhaustive-deps": "off",
// tailwindcss
"tailwindcss/no-custom-classname": "off"
}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
}
]
}

37
apps/artboard/index.html Normal file
View File

@ -0,0 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Artboard | Reactive Resume</title>
<base href="/" />
<!-- Meta -->
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Favicon -->
<link
rel="icon"
type="image/svg+xml"
href="/icon/dark.svg"
media="(prefers-color-scheme: light)"
/>
<link
rel="icon"
type="image/svg+xml"
href="/icon/light.svg"
media="(prefers-color-scheme: dark)"
/>
<!-- Styles -->
<link rel="stylesheet" href="/src/styles/main.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<!-- Phosphor Icons -->
<script src="https://unpkg.com/@phosphor-icons/web"></script>
</body>
</html>

View File

@ -0,0 +1,10 @@
const { join } = require("path");
module.exports = {
plugins: {
tailwindcss: {
config: join(__dirname, "tailwind.config.js"),
},
autoprefixer: {},
},
};

View File

@ -0,0 +1,64 @@
{
"name": "artboard",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "apps/artboard/src",
"projectType": "application",
"targets": {
"build": {
"executor": "@nx/vite:build",
"outputs": ["{options.outputPath}"],
"defaultConfiguration": "production",
"options": {
"outputPath": "dist/apps/artboard"
},
"configurations": {
"development": {
"mode": "development"
},
"production": {
"mode": "production"
}
}
},
"serve": {
"executor": "@nx/vite:dev-server",
"defaultConfiguration": "development",
"options": {
"buildTarget": "artboard:build"
},
"configurations": {
"development": {
"buildTarget": "artboard:build:development",
"hmr": true
},
"production": {
"buildTarget": "artboard:build:production",
"hmr": false
}
}
},
"preview": {
"executor": "@nx/vite:preview-server",
"defaultConfiguration": "development",
"options": {
"buildTarget": "artboard:build"
},
"configurations": {
"development": {
"buildTarget": "artboard:build:development"
},
"production": {
"buildTarget": "artboard:build:production"
}
}
},
"lint": {
"executor": "@nx/eslint:lint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": ["apps/artboard/**/*.{ts,tsx,js,jsx}"]
}
}
},
"tags": ["frontend"]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

View File

@ -0,0 +1,8 @@
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M173.611 166.311L132.877 219.804H173.524L193.973 191.813L213.183 219.804H256L215.673 165.707L215.15 165.046L207.461 155.332L195.329 140.004L195.258 139.915L193.813 138.089L193.923 138.001L176.286 112.861H134.061L173.611 166.311ZM199.89 133.554L214.959 112.861H254.619L219.874 158.8L199.89 133.554Z"
fill="#09090B" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M0 36.1959V174.314H39.0678V137.614H60.3938L60.4323 137.671C60.8436 137.653 61.2518 137.634 61.6569 137.614C75.0665 136.968 85.1471 135.549 96.3849 131.385C96.7596 131.246 97.1355 131.104 97.5128 130.959L97.4591 130.881C105.816 126.86 112.331 121.344 117.006 114.331C122.005 106.702 124.504 97.6915 124.504 87.2997C124.504 76.7764 122.005 67.7 117.006 60.0706C112.007 52.3097 104.904 46.3903 95.6964 42.3125C86.62 38.2347 75.7678 36.1959 63.1399 36.1959H0ZM102.156 137.725L64.8705 144.175L85.4361 174.314H127.266L102.156 137.725ZM39.0678 107.426H60.7721C68.9277 107.426 74.9786 105.65 78.9248 102.098C83.0026 98.5465 85.0415 93.6137 85.0415 87.2997C85.0415 80.8542 83.0026 75.8556 78.9248 72.304C74.9786 68.7523 68.9277 66.9765 60.7721 66.9765H39.0678V107.426Z"
fill="#09090B" />
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,8 @@
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M173.611 166.311L132.877 219.804H173.524L193.973 191.813L213.183 219.804H256L215.673 165.707L215.15 165.046L207.461 155.332L195.329 140.004L195.258 139.915L193.813 138.089L193.923 138.001L176.286 112.861H134.061L173.611 166.311ZM199.89 133.554L214.959 112.861H254.619L219.874 158.8L199.89 133.554Z"
fill="#FAFAFA" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M0 36.1959V174.314H39.0678V137.614H60.3938L60.4323 137.671C60.8436 137.653 61.2517 137.634 61.6567 137.614C75.0665 136.968 85.1471 135.549 96.385 131.385C96.7596 131.246 97.1355 131.104 97.5128 130.959L97.4591 130.881C105.816 126.86 112.331 121.344 117.006 114.331C122.005 106.702 124.504 97.6915 124.504 87.2997C124.504 76.7764 122.005 67.7 117.006 60.0706C112.007 52.3097 104.904 46.3903 95.6964 42.3125C86.62 38.2347 75.7679 36.1959 63.1399 36.1959H0ZM102.156 137.725L64.8705 144.175L85.4361 174.314H127.266L102.156 137.725ZM39.0678 107.426H60.7721C68.9277 107.426 74.9786 105.65 78.9248 102.098C83.0026 98.5465 85.0415 93.6137 85.0415 87.2997C85.0415 80.8542 83.0026 75.8556 78.9248 72.304C74.9786 68.7523 68.9277 66.9765 60.7721 66.9765H39.0678V107.426Z"
fill="#FAFAFA" />
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

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;
};

View File

@ -0,0 +1,13 @@
const { createGlobPatternsForDependencies } = require("@nx/react/tailwind");
const { join } = require("path");
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: "class",
content: [
join(__dirname, "{src,pages,components,app}/**/*!(*.stories|*.spec).{ts,tsx,html}"),
...createGlobPatternsForDependencies(__dirname),
],
theme: {},
plugins: [require("@tailwindcss/typography")],
};

View File

@ -0,0 +1,23 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": [
"node",
"@nx/react/typings/cssmodule.d.ts",
"@nx/react/typings/image.d.ts",
"vite/client"
]
},
"exclude": [
"src/**/*.spec.ts",
"src/**/*.test.ts",
"src/**/*.spec.tsx",
"src/**/*.test.tsx",
"src/**/*.spec.js",
"src/**/*.test.js",
"src/**/*.spec.jsx",
"src/**/*.test.jsx"
],
"include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"]
}

View File

@ -0,0 +1,18 @@
{
"compilerOptions": {
"jsx": "react-jsx",
"allowJs": false,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"types": ["vite/client"]
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.app.json"
}
],
"extends": "../../tsconfig.base.json"
}

View File

@ -0,0 +1,25 @@
/// <reference types='vitest' />
import { nxViteTsPaths } from "@nx/vite/plugins/nx-tsconfig-paths.plugin";
import react from "@vitejs/plugin-react-swc";
import { defineConfig, searchForWorkspaceRoot } from "vite";
export default defineConfig({
base: "/artboard/",
cacheDir: "../../node_modules/.vite/artboard",
server: {
host: true,
port: 6173,
fs: { allow: [searchForWorkspaceRoot(process.cwd())] },
},
plugins: [react(), nxViteTsPaths()],
resolve: {
alias: {
"@/artboard/": `${searchForWorkspaceRoot(process.cwd())}/apps/artboard/src/`,
},
},
});