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

@ -1,5 +1,9 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"tailwindCSS.experimental.classRegex": [
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
["cn\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
],
"yaml.schemas": {
"https://json.schemastore.org/github-workflow.json": ".github/workflows/*.yml",
"https://raw.githubusercontent.com/compose-spec/compose-spec/master/schema/compose-spec.json": [

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

@ -1,6 +1,11 @@
import { SectionKey } from "@reactive-resume/schema";
export type TemplateProps = {
isFirstPage?: boolean;
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

@ -10,14 +10,14 @@
]
},
"exclude": [
"**/*.spec.ts",
"**/*.test.ts",
"**/*.spec.tsx",
"**/*.test.tsx",
"**/*.spec.js",
"**/*.test.js",
"**/*.spec.jsx",
"**/*.test.jsx"
"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

@ -5,16 +5,13 @@
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"types": ["vite/client", "vitest"]
"types": ["vite/client"]
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
"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/`,
},
},
});

View File

@ -2,5 +2,9 @@
"/api": {
"target": "http://localhost:3000",
"secure": false
},
"/artboard": {
"target": "http://localhost:6173",
"secure": false
}
}

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">

View File

@ -9,13 +9,13 @@ import { ResetPasswordPage } from "../pages/auth/reset-password/page";
import { VerifyEmailPage } from "../pages/auth/verify-email/page";
import { VerifyOtpPage } from "../pages/auth/verify-otp/page";
import { BuilderLayout } from "../pages/builder/layout";
import { builderLoader, BuilderPage } from "../pages/builder/page";
import { BuilderPage } from "../pages/builder/page";
import { builderLoader } from "../pages/builder/page";
import { DashboardLayout } from "../pages/dashboard/layout";
import { ResumesPage } from "../pages/dashboard/resumes/page";
import { SettingsPage } from "../pages/dashboard/settings/page";
import { HomeLayout } from "../pages/home/layout";
import { HomePage } from "../pages/home/page";
import { PrinterPage } from "../pages/printer/page";
import { publicLoader, PublicResumePage } from "../pages/public/page";
import { Providers } from "../providers";
import { AuthGuard } from "./guards/auth";
@ -80,8 +80,6 @@ export const routes = createRoutesFromElements(
</Route>
</Route>
<Route path="printer" element={<PrinterPage />} />
{/* Public Routes */}
<Route path=":username">
<Route path=":slug" loader={publicLoader} element={<PublicResumePage />} />

View File

@ -1,5 +1,4 @@
import { ImperativePanelHandle } from "react-resizable-panels";
import { ReactZoomPanPinchRef } from "react-zoom-pan-pinch";
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
@ -10,15 +9,15 @@ type Sheet = {
type Panel = {
isDragging: boolean;
setDragging: (dragging: boolean) => void;
ref: ImperativePanelHandle | null;
setRef: (ref: ImperativePanelHandle | null) => void;
setDragging: (dragging: boolean) => void;
};
interface BuilderState {
transform: {
ref: Omit<ReactZoomPanPinchRef, "instance"> | null;
setRef: (ref: Omit<ReactZoomPanPinchRef, "instance"> | null) => void;
frame: {
ref: HTMLIFrameElement | null;
setRef: (ref: HTMLIFrameElement | null) => void;
};
sheet: {
left: Sheet;
@ -36,11 +35,12 @@ interface BuilderActions {
export const useBuilderStore = create<BuilderState & BuilderActions>()(
immer((set) => ({
transform: {
frame: {
ref: null,
setRef: (ref) => {
set((state) => {
state.transform.ref = ref;
// @ts-expect-error Unable to set ref type
state.frame.ref = ref;
});
},
},

View File

@ -21,11 +21,6 @@ export default defineConfig({
fs: { allow: [searchForWorkspaceRoot(process.cwd())] },
},
preview: {
host: true,
port: +(process.env.__DEV__CLIENT_PORT ?? 5173),
},
plugins: [react(), nxViteTsPaths(), splitVendorChunkPlugin()],
test: {

View File

@ -27,10 +27,6 @@ import { UtilsModule } from "./utils/utils.module";
CacheModule,
UtilsModule,
HealthModule,
ServeStaticModule.forRoot({
rootPath: join(__dirname, "..", "client"),
exclude: ["/api*", "/docs*"],
}),
// Feature Modules
AuthModule.register(),
@ -38,6 +34,16 @@ import { UtilsModule } from "./utils/utils.module";
ResumeModule,
StorageModule,
PrinterModule,
// Static Assets
ServeStaticModule.forRoot({
serveRoot: "/artboard",
rootPath: join(__dirname, "..", "artboard"),
}),
ServeStaticModule.forRoot({
renderPath: "/*",
rootPath: join(__dirname, "..", "client"),
}),
],
providers: [
{

View File

@ -4,7 +4,7 @@ import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import fontkit from "@pdf-lib/fontkit";
import { ResumeDto } from "@reactive-resume/dto";
import { getFontUrls, pageSizeMap, withTimeout } from "@reactive-resume/utils";
import { getFontUrls, withTimeout } from "@reactive-resume/utils";
import retry from "async-retry";
import { readFile } from "fs/promises";
import { join } from "path";
@ -16,7 +16,6 @@ import { ErrorMessage } from "../constants/error-message";
import { StorageService } from "../storage/storage.service";
import { UtilsService } from "../utils/utils.service";
const MM_TO_PX = 3.78;
const PRINTER_TIMEOUT = 10000; // 10 seconds
@Injectable()
@ -114,59 +113,38 @@ export class PrinterService {
}
// Set the data of the resume to be printed in the browser's session storage
const format = resume.data.metadata.page.format;
const numPages = resume.data.metadata.layout.length;
await page.evaluateOnNewDocument((data: string) => {
sessionStorage.setItem("resume", data);
}, JSON.stringify(resume.data));
await page.evaluateOnNewDocument((data) => {
sessionStorage.setItem("resume", JSON.stringify(data));
}, resume.data);
await page.goto(`${url}/printer`, { waitUntil: "networkidle0" });
await page.emulateMediaType("print");
await page.goto(`${url}/artboard/preview`, { waitUntil: "networkidle0" });
const pagesBuffer: Buffer[] = [];
// Hide all the pages (elements with [data-page] attribute) using CSS
const hidePages = () => {
return page.$eval("iframe", (frame) => {
frame.contentDocument?.documentElement.querySelectorAll("[data-page]").forEach((page) => {
page.setAttribute("style", "display: none");
});
});
};
const processPage = async (index: number) => {
const pageElement = await page.$(`[data-page="${index}"]`);
const width = (await (await pageElement?.getProperty("scrollWidth"))?.jsonValue()) ?? 0;
const height = (await (await pageElement?.getProperty("scrollHeight"))?.jsonValue()) ?? 0;
const processPage = (index: number) => {
// Calculate the height of the page based on the format, convert mm to pixels
const pageSize = {
width: pageSizeMap[format].width * MM_TO_PX,
height: pageSizeMap[format].height * MM_TO_PX,
};
const tempHtml = await page.evaluate((element: HTMLDivElement) => {
const clonedElement = element.cloneNode(true) as HTMLDivElement;
const tempHtml = document.body.innerHTML;
document.body.innerHTML = `${clonedElement.outerHTML}`;
return tempHtml;
}, pageElement);
return page.$eval(
"iframe",
(frame, index, pageSize) => {
const page = frame.contentDocument?.querySelector(`[data-page="${index}"]`);
page?.setAttribute("style", "display: block");
pagesBuffer.push(await page.pdf({ width, height }));
return {
width: Math.max(pageSize.width, page?.scrollWidth ?? 0),
height: Math.max(pageSize.height, page?.scrollHeight ?? 0),
};
},
index,
pageSize,
);
await page.evaluate((tempHtml: string) => {
document.body.innerHTML = tempHtml;
}, tempHtml);
};
// Loop through all the pages and print them, by first displaying them, printing the PDF and then hiding them back
for (let index = 1; index <= numPages; index++) {
await hidePages();
const { width, height } = await processPage(index);
const buffer = await page.pdf({ width, height });
pagesBuffer.push(buffer);
await hidePages();
await processPage(index);
}
// Using 'pdf-lib', merge all the pages from their buffers into a single PDF
@ -265,18 +243,13 @@ export class PrinterService {
}
// Set the data of the resume to be printed in the browser's session storage
const format = resume.data.metadata.page.format;
await page.evaluateOnNewDocument((data: string) => {
sessionStorage.setItem("resume", data);
}, JSON.stringify(resume.data));
await page.setViewport({
width: Math.round(pageSizeMap[format].width * MM_TO_PX),
height: Math.round(pageSizeMap[format].height * MM_TO_PX),
});
await page.setViewport({ width: 794, height: 1123 });
await page.goto(`${url}/printer`, { waitUntil: "networkidle0" });
await page.goto(`${url}/artboard/preview`, { waitUntil: "networkidle0" });
// Save the JPEG to storage and return the URL
// Store the URL in cache for future requests, under the previously generated hash digest

View File

@ -110,8 +110,8 @@ export class ResumeController {
@Delete(":id")
@UseGuards(TwoFactorGuard)
async remove(@User() user: UserEntity, @Param("id") id: string) {
await this.resumeService.remove(user.id, id);
remove(@User() user: UserEntity, @Param("id") id: string) {
return this.resumeService.remove(user.id, id);
}
@Get("/print/:id")
@ -122,6 +122,7 @@ export class ResumeController {
return { url };
} catch (error) {
console.log(error);
Logger.error(error);
throw new InternalServerErrorException(error);
}

View File

@ -179,10 +179,9 @@ export class ResumeService {
// Remove files in storage, and their cached keys
this.storageService.deleteObject(userId, "resumes", id),
this.storageService.deleteObject(userId, "previews", id),
// Remove resume from database
this.prisma.resume.delete({ where: { userId_id: { userId, id } } }),
]);
return this.prisma.resume.delete({ where: { userId_id: { userId, id } } });
}
async printResume(resume: ResumeDto) {

View File

@ -1,12 +0,0 @@
import { templatesList } from "@reactive-resume/templates";
import { useMemo } from "react";
export const useTemplate = (templateId?: string) => {
const template = useMemo(() => {
return templatesList.find((template) => template.id === templateId);
}, [templateId]);
if (!template || !template.Component) return null;
return template;
};

View File

@ -1,5 +1,4 @@
export * from "./hooks/use-breakpoint";
export * from "./hooks/use-form-field";
export * from "./hooks/use-password-toggle";
export * from "./hooks/use-template";
export * from "./hooks/use-theme";

View File

@ -23,5 +23,6 @@ export const defaultResumeData: ResumeData = {
export * from "./basics";
export * from "./metadata";
export * from "./sample";
export * from "./sections";
export * from "./shared";

View File

@ -1,4 +1,4 @@
import { ResumeData } from "@reactive-resume/schema";
import { ResumeData } from ".";
export const sampleResume: ResumeData = {
basics: {
@ -13,7 +13,7 @@ export const sampleResume: ResumeData = {
},
customFields: [],
picture: {
url: "http://localhost:9000/reactive-resume/clomqcm7b0004xi96goz2gqmt/pictures/clomqcm7b0004xi96goz2gqmt.jpg",
url: "https://res.cloudinary.com/amruth-pillai/image/upload/v1699362669/reactive-resume/sample-resume/sample-picture_iitowc.jpg",
size: 64,
aspectRatio: 0.75,
borderRadius: 6,
@ -31,19 +31,19 @@ export const sampleResume: ResumeData = {
visible: true,
id: "summary",
content:
"<p>With over <strong><em>two decades of experience</em></strong> at the forefront of digital asset financial technology (FinTech) integration across the entire spectrum of financial service company operations, I bring a strategic and analytical approach, underpinned by a deep understanding of cryptocurrency markets, digital asset financial management, compliance, and regulatory frameworks.</p><h3>Career Highlights</h3><ul><li><p>I have a proven track record in the <strong>design and deployment of bespoke trading platforms</strong> tailored to the unique needs of clients and independent financial entities, exemplified by driving a remarkable <strong>1,200% increase in Assets Under Management (AUM)</strong> for Mesirow Financial Holdings, Inc.</p></li><li><p>Recognized for exceptional leadership abilities, I have consistently <strong>cultivated high-performance trading and software engineering teams</strong>, achieving retention rates that surpass industry standards.</p></li><li><p>Demonstrated innovation and thought leadership by co-creating and <strong>introducing Telcoin's stablecoin</strong> concept to key stakeholders within the banking industry.</p></li><li><p>Instrumental in <strong>advancing Telcoins strategic legislative initiatives</strong>, my direct involvement has been pivotal in the enactment of supportive financial regulations.</p></li></ul>",
"<p>With over <strong><em>two decades of experience</em></strong> at the forefront of digital asset financial technology (FinTech) integration across the entire spectrum of financial service company operations, I bring a strategic and analytical approach, underpinned by a deep understanding of cryptocurrency markets, digital asset financial management, compliance, and regulatory frameworks.</p><h4>Career Highlights</h4><ul><li><p>I have a proven track record in the <strong>design and deployment of bespoke trading platforms</strong> tailored to the unique needs of clients and independent financial entities, exemplified by driving a remarkable <strong>1,200% increase in Assets Under Management (AUM)</strong> for Mesirow Financial Holdings, Inc.</p></li><li><p>Recognized for exceptional leadership abilities, I have consistently <strong>cultivated high-performance trading and software engineering teams</strong>, achieving retention rates that surpass industry standards.</p></li><li><p>Instrumental in <strong>advancing Telcoins strategic legislative initiatives</strong>, my direct involvement has been pivotal in the enactment of supportive financial regulations.</p></li></ul>",
},
awards: {
name: "Awards",
columns: 1,
columns: 2,
visible: true,
id: "awards",
items: [
{
id: "xk8q4bsp3vdlixehddifqsej",
visible: true,
title: 'Presented "Current Landscape of Digital Assets and Cryptocurrency"',
awarder: "Meeting of the Midwest International Trade Association",
title: "Current Landscape of Digital Assets",
awarder: "Midwest International Trade Association",
date: "Jul 2022",
summary: "",
url: {
@ -204,7 +204,7 @@ export const sampleResume: ResumeData = {
},
languages: {
name: "Languages",
columns: 4,
columns: 2,
visible: true,
id: "languages",
items: [
@ -361,7 +361,7 @@ export const sampleResume: ResumeData = {
},
skills: {
name: "Skills",
columns: 2,
columns: 1,
visible: true,
id: "skills",
items: [
@ -440,7 +440,7 @@ export const sampleResume: ResumeData = {
date: "Jan 2018 - Jul 2020",
location: "Nassau, Bahamas",
summary:
"<p>Proprietary quantitative trading firm and privately-held quantitative futures fund with $1B in AUM and a focus on futures and OTC FX opportunities. 25+ year track record of trading global derivatives markets.</p><h3>Execution Team Manager</h3><p>Led international OTC FX and futures trading desk, software development, compliance, and market data for fund averaging $5B in monthly FX transactions and generating 500K+ futures contracts per month.</p><p>Steered a 12-member team of 4 traders and 8 software engineers. Oversaw co-located trade execution and managed direct market access in 4 data centers globally for exchanges not co-located. Built partnerships with trading technology providers. Point of contact with banks, brokerages, FXSpotStream, and 9 other liquidity providers. Led market data feed agreements with exchanges. Maintained CME and Eurex exchange memberships. Headed fulfillment of regulatory compliance and due diligence requests.</p><ul><li><p><strong>Decreased HFT FX trading costs</strong> by integrating streaming of 1 million NDF prices into co-located servers, coordinating with engineers of the FX pricing engine.</p></li></ul><ul><li><p><strong>Streamlined market data contracts and reduced costs by improving data quality and availability.</strong> Consolidated technology providers (Bloomberg and Reuters) and enhanced data collection for time periods needed, data types, and breadth of data.</p></li></ul><ul><li><p><strong>Scaled capabilities by recruiting to enlarge the team</strong>, hiring 3 traders, 2 software engineers, and a consultant with 85% retention for 2+ years. Sourced top talent by posting jobs, leveraging network of contacts, and outsourcing with recruiters.</p></li></ul><ul><li><p><strong>Reduced bank, vendor, and futures broker fees, expanded access to services</strong>, and maintained open lines of communication by strengthening existing relationships. Contacted sales representatives to facilitate introductions.</p></li></ul>",
"<p>Proprietary quantitative trading firm and privately-held quantitative futures fund with $1B in AUM and a focus on futures and OTC FX opportunities. 25+ year track record of trading global derivatives markets.</p><h4>Execution Team Manager</h4><p>Led international OTC FX and futures trading desk, software development, compliance, and market data for fund averaging $5B in monthly FX transactions and generating 500K+ futures contracts per month.</p><p>Steered a 12-member team of 4 traders and 8 software engineers. Oversaw co-located trade execution and managed direct market access in 4 data centers globally for exchanges not co-located. Built partnerships with trading technology providers. Point of contact with banks, brokerages, FXSpotStream, and 9 other liquidity providers. Led market data feed agreements with exchanges. Maintained CME and Eurex exchange memberships. Headed fulfillment of regulatory compliance and due diligence requests.</p><ul><li><p><strong>Decreased HFT FX trading costs</strong> by integrating streaming of 1 million NDF prices into co-located servers, coordinating with engineers of the FX pricing engine.</p></li></ul><ul><li><p><strong>Streamlined market data contracts and reduced costs by improving data quality and availability.</strong> Consolidated technology providers (Bloomberg and Reuters) and enhanced data collection for time periods needed, data types, and breadth of data.</p></li></ul><ul><li><p><strong>Scaled capabilities by recruiting to enlarge the team</strong>, hiring 3 traders, 2 software engineers, and a consultant with 85% retention for 2+ years. Sourced top talent by posting jobs, leveraging network of contacts, and outsourcing with recruiters.</p></li></ul><ul><li><p><strong>Reduced bank, vendor, and futures broker fees, expanded access to services</strong>, and maintained open lines of communication by strengthening existing relationships. Contacted sales representatives to facilitate introductions.</p></li></ul>",
keywords: [],
url: {
label: "",
@ -456,7 +456,7 @@ export const sampleResume: ResumeData = {
template: "rhyhorn",
layout: [
[["profiles", "summary", "experience"], []],
[["custom.juryi0w9w9jabsgorks0bixq", "education", "awards", "certifications"], []],
[["custom.juryi0w9w9jabsgorks0bixq", "education", "certifications", "awards"], []],
[
["skills", "interests", "publications", "volunteer", "languages", "projects", "references"],
[],
@ -477,7 +477,7 @@ export const sampleResume: ResumeData = {
theme: {
background: "#ffffff",
text: "#000000",
primary: "#222222",
primary: "#78716c",
},
typography: {
font: {

View File

@ -1,4 +0,0 @@
{
"presets": [["@nx/react/babel", { "runtime": "automatic", "useBuiltIns": "usage" }]],
"plugins": [["styled-components", { "pure": true, "ssr": false }]]
}

View File

@ -1,18 +0,0 @@
{
"extends": ["plugin:@nx/react", "../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
}
]
}

View File

@ -1,16 +0,0 @@
{
"name": "@reactive-resume/templates",
"version": "0.0.1",
"private": false,
"main": "./index.js",
"types": "./index.d.ts",
"publishConfig": {
"access": "public"
},
"exports": {
".": {
"import": "./index.mjs",
"require": "./index.js"
}
}
}

View File

@ -1,40 +0,0 @@
{
"name": "templates",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/templates/src",
"projectType": "library",
"tags": ["frontend"],
"targets": {
"lint": {
"executor": "@nx/eslint:lint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": ["libs/templates/**/*.{ts,tsx,js,jsx}"]
}
},
"build": {
"executor": "@nx/vite:build",
"outputs": ["{options.outputPath}"],
"defaultConfiguration": "production",
"options": {
"outputPath": "dist/libs/templates"
},
"configurations": {
"development": {
"mode": "development"
},
"production": {
"mode": "production"
}
}
},
"test": {
"executor": "@nx/vite:test",
"outputs": ["{options.reportsDirectory}"],
"options": {
"passWithNoTests": true,
"reportsDirectory": "../../coverage/libs/templates"
}
}
}
}

View File

@ -1,3 +0,0 @@
export * from "./shared";
export * from "./styles";
export * from "./templates";

View File

@ -1,49 +0,0 @@
import { ResumeData } from "@reactive-resume/schema";
import { useEffect, useMemo } from "react";
import { FrameContextConsumer } from "react-frame-component";
import { StyleSheetManager } from "styled-components";
import { GlobalStyles } from "../styles";
import { GlobalStyleProps } from "../styles/shared";
import { FrameWrapper } from "./frame";
import { useStore } from "./store";
type Props = {
resume: ResumeData;
children: React.ReactNode;
style?: React.CSSProperties;
};
export const Artboard = ({ resume, style, children }: Props) => {
const store = useStore();
const metadata = useStore((state) => state.metadata);
const styles: GlobalStyleProps | null = useMemo(() => {
if (!metadata) return null;
return {
$css: metadata.css,
$page: metadata.page,
$theme: metadata.theme,
$typography: metadata.typography,
};
}, [metadata]);
useEffect(() => useStore.setState(resume), [resume]);
if (Object.keys(store).length === 0 || !styles) return;
return (
<FrameWrapper style={style}>
<FrameContextConsumer>
{({ document }) => (
<StyleSheetManager target={document?.head}>
<GlobalStyles {...styles} />
{children}
</StyleSheetManager>
)}
</FrameContextConsumer>
</FrameWrapper>
);
};

View File

@ -1,97 +0,0 @@
import { pageSizeMap } from "@reactive-resume/utils";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Frame from "react-frame-component";
import webfontloader from "webfontloader";
import { useStore } from "./store";
type Props = {
children: React.ReactNode;
style?: React.CSSProperties;
};
export const FrameWrapper = ({ children, style }: Props) => {
const resume = useStore();
const format = resume.metadata.page.format;
const font = resume.metadata.typography.font;
const fontString = useMemo(() => {
const family = font.family;
const variants = font.variants.join(",");
const subset = font.subset;
return `${family}:${variants}:${subset}`;
}, [font]);
const frameRef = useRef<HTMLIFrameElement | null>(null);
const width = useMemo(() => `${pageSizeMap[format].width}mm`, [format]);
const [height, setHeight] = useState(`${pageSizeMap[format].height}mm`);
const handleResize = useCallback((frame: HTMLIFrameElement) => {
const height = frame.contentDocument?.body?.scrollHeight ?? 0;
setHeight(`${height}px`);
}, []);
const loadFonts = useCallback(
(frame: HTMLIFrameElement) => {
if (font.family === "CMU Serif") {
return webfontloader.load({
classes: false,
custom: {
families: ["CMU Serif"],
urls: ["https://cdn.jsdelivr.net/npm/computer-modern/cmu-serif.min.css"],
},
context: frame.contentWindow!,
fontactive: () => handleResize(frame!),
});
}
webfontloader.load({
classes: false,
google: { families: [fontString] },
context: frame.contentWindow!,
fontactive: () => handleResize(frame!),
});
},
[font, fontString, handleResize],
);
const loadIconFonts = useCallback((frame: HTMLIFrameElement) => {
const document = frame.contentDocument!;
const link = document.createElement("link");
link.type = "text/css";
link.rel = "stylesheet";
link.href = "https://unpkg.com/@phosphor-icons/web@2.0.3/src/regular/style.css";
document.head.appendChild(link);
}, []);
const onLoad = useCallback(() => {
if (!frameRef.current) return;
handleResize(frameRef.current);
loadFonts(frameRef.current);
loadIconFonts(frameRef.current);
}, [frameRef, handleResize, loadFonts, loadIconFonts]);
useEffect(() => {
onLoad();
}, [resume, onLoad]);
useEffect(() => {
setTimeout(onLoad, 250);
}, [onLoad]);
return (
<Frame
ref={frameRef}
onLoad={onLoad}
onLoadedData={onLoad}
style={{ width, height, pointerEvents: "none", ...style }}
>
{children}
</Frame>
);
};

View File

@ -1,4 +0,0 @@
export * from "./artboard";
export * from "./frame";
export * from "./store";
export * from "./templates";

View File

@ -1,4 +0,0 @@
import { ResumeData } from "@reactive-resume/schema";
import { create } from "zustand";
export const useStore = create<ResumeData>()(() => ({}) as ResumeData);

View File

@ -1,14 +0,0 @@
import styled from "styled-components";
export const ItemGrid = styled.div<{ $columns?: number }>`
display: grid;
grid-gap: 16px;
grid-template-columns: repeat(${({ $columns }) => $columns ?? 1}, 1fr);
`;
export const PageGrid = styled.div<{ $offset: number }>`
display: flex;
flex-direction: row;
align-items: flex-start;
column-gap: ${({ $offset }) => $offset}px;
`;

View File

@ -1,13 +0,0 @@
import { createGlobalStyle } from "styled-components";
import { Reset } from "./reset";
import { GlobalStyleProps, Shared } from "./shared";
export const GlobalStyles = createGlobalStyle<GlobalStyleProps>`
${Reset}
${Shared}
`;
export * from "./grid";
export * from "./page";
export * from "./picture";

View File

@ -1,59 +0,0 @@
import styled from "styled-components";
export const PageWrapper = styled.div`
position: relative;
width: var(--page-width);
padding: var(--page-margin);
min-height: var(--page-height);
/* Theme */
color: var(--color-text);
background-color: var(--color-background);
@media print {
margin: 0 auto;
&:not(:last-child) {
height: var(--page-height);
}
}
`;
export const PageNumber = styled.p`
top: 0;
right: 0;
color: black;
font-size: 12px;
font-weight: 600;
padding: 0 0.5rem;
position: absolute;
outline: 1px solid black;
background-color: white;
`;
export const PageBreakLine = styled.div<{ $pageHeight: number }>`
position: absolute;
top: ${({ $pageHeight }) => $pageHeight}mm;
left: 0;
right: 0;
z-index: 10;
border: 1px dashed var(--color-text);
/* Text */
&:before {
content: "End of Page";
background: white;
color: black;
display: block;
font-size: 12px;
font-weight: 600;
height: auto;
line-height: 0rem;
padding: 12px 16px;
position: absolute;
right: 0;
text-align: right;
top: -25px;
}
`;

View File

@ -1,11 +0,0 @@
import { Basics } from "@reactive-resume/schema";
import styled from "styled-components";
export const Picture = styled.img<{ $picture: Basics["picture"] }>`
width: ${({ $picture }) => $picture.size}px;
aspect-ratio: ${({ $picture }) => $picture.aspectRatio};
border-radius: ${({ $picture }) => $picture.borderRadius}px;
${({ $picture }) => $picture.effects.grayscale && `filter: grayscale(1);`}
${({ $picture }) => $picture.effects.border && `border: 2px solid var(--color-primary);`}
`;

View File

@ -1,122 +0,0 @@
import { css } from "styled-components";
export const Reset = css`
/***
The new CSS reset - version 1.11.1 (last updated 24.10.2023)
GitHub page: https://github.com/elad2412/the-new-css-reset
***/
/*
Remove all the styles of the "User-Agent-Stylesheet", except for the 'display' property
- The "symbol *" part is to solve Firefox SVG sprite bug
- The "html" element is excluded, otherwise a bug in Chrome breaks the CSS hyphens property (https://github.com/elad2412/the-new-css-reset/issues/36)
*/
*:where(:not(html, iframe, canvas, img, svg, video, audio):not(svg *, symbol *)) {
all: unset;
display: revert;
}
/* Preferred box-sizing value */
*,
*::before,
*::after {
box-sizing: border-box;
}
/* Fix mobile Safari increase font-size on landscape mode */
html {
-moz-text-size-adjust: none;
-webkit-text-size-adjust: none;
text-size-adjust: none;
font-size: var(--font-size);
font-family: var(--font-family);
}
body {
overflow: hidden;
}
/* Reapply the pointer cursor for anchor tags */
a,
button {
cursor: revert;
}
/* Remove list styles (bullets/numbers) */
ol,
ul,
menu,
summary {
list-style: none;
}
/* For images to not be able to exceed their container */
img {
max-inline-size: 100%;
max-block-size: 100%;
}
/* removes spacing between cells in tables */
table {
border-collapse: collapse;
}
/* Safari - solving issue when using user-select:none on the <body> text input doesn't working */
input,
textarea {
user-select: auto;
-webkit-user-select: auto;
}
/* revert the 'white-space' property for textarea elements on Safari */
textarea {
white-space: revert;
}
/* minimum style to allow to style meter element */
meter {
-webkit-appearance: revert;
appearance: revert;
}
/* preformatted text - use only for this feature */
:where(pre) {
all: revert;
box-sizing: border-box;
}
/* reset default text opacity of input placeholder */
::placeholder {
color: unset;
}
/* fix the feature of 'hidden' attribute.
display:revert; revert to element instead of attribute */
:where([hidden]) {
display: none;
}
/* revert for bug in Chromium browsers
- fix for the content editable attribute will work properly.
- webkit-user-select: auto; added for Safari in case of using user-select:none on wrapper element*/
:where([contenteditable]:not([contenteditable="false"])) {
-moz-user-modify: read-write;
-webkit-user-modify: read-write;
overflow-wrap: break-word;
line-break: after-white-space;
-webkit-line-break: after-white-space;
user-select: auto;
-webkit-user-select: auto;
}
/* apply back the draggable feature - exist only in Chromium and Safari */
:where([draggable="true"]) {
-webkit-user-drag: element;
}
/* Revert Modal native behavior */
:where(dialog:modal) {
all: revert;
box-sizing: border-box;
}
`;

View File

@ -1,147 +0,0 @@
import { Metadata } from "@reactive-resume/schema";
import { pageSizeMap } from "@reactive-resume/utils";
import { css } from "styled-components";
export type GlobalStyleProps = {
$css: Metadata["css"];
$page: Metadata["page"];
$theme: Metadata["theme"];
$typography: Metadata["typography"];
};
export const Shared = css<GlobalStyleProps>`
/* CSS Variables */
:root {
/* Theme */
--color-text: ${({ $theme }) => $theme.text};
--color-primary: ${({ $theme }) => $theme.primary};
--color-background: ${({ $theme }) => $theme.background};
/* Page */
--page-width: ${({ $page }) => pageSizeMap[$page.format].width}mm;
--page-height: ${({ $page }) => pageSizeMap[$page.format].height}mm;
--page-margin: ${({ $page }) => $page.margin}px;
/* Typography */
--font-size: ${({ $typography }) => $typography.font.size}px;
--font-family: ${({ $typography }) => $typography.font.family};
--line-height: ${({ $typography }) => $typography.lineHeight}rem;
}
/* Headings */
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: bold;
line-height: var(--line-height);
}
h1 {
font-size: 2rem;
}
h2 {
font-size: 1.5rem;
}
h3 {
font-size: 1rem;
}
/* Paragraphs */
p {
font-size: var(--font-size);
line-height: var(--line-height);
}
b,
strong {
font-weight: bold;
}
small {
font-size: calc(var(--font-size) - 2px);
line-height: calc(var(--line-height) - 2px);
}
i,
em {
font-style: italic;
}
u {
text-decoration: underline;
text-underline-offset: 1.5px;
}
a {
text-decoration: ${({ $typography }) => ($typography.underlineLinks ? "underline" : "none")};
text-underline-offset: 1.5px;
}
s,
del {
text-decoration: line-through;
}
pre,
code {
font-family: monospace;
}
pre code {
display: block;
padding: 1rem;
border-radius: 4px;
color: white;
background-color: black;
}
mark {
color: black;
background-color: #fcd34d;
}
/* Lists */
menu,
ol,
ul {
list-style: disc inside;
li {
margin: 0.25rem 0;
line-height: var(--line-height);
p {
display: inline;
line-height: var(--line-height);
}
}
menu,
ol,
ul {
list-style: circle inside;
li {
padding-left: 32px;
}
}
}
/* Horizontal Rules */
hr {
margin: 0.5rem 0;
border: 0.5px solid currentColor;
}
/* Images */
img {
display: block;
max-width: 100%;
object-fit: cover;
}
`;

View File

@ -1,16 +0,0 @@
import { TemplateProps } from "../shared";
import { Rhyhorn } from "./rhyhorn";
type Template = {
id: string;
name: string;
Component: (props: TemplateProps) => JSX.Element;
};
export const templatesList: Template[] = [
{
id: "rhyhorn",
name: "Rhyhorn",
Component: Rhyhorn,
},
];

View File

@ -1,56 +0,0 @@
import { SectionKey } from "@reactive-resume/schema";
import { TemplateProps } from "../../shared";
import { Awards } from "./sections/awards";
import { Certifications } from "./sections/certifications";
import { CustomSection } from "./sections/custom";
import { Education } from "./sections/education";
import { Experience } from "./sections/experience";
import { Header } from "./sections/header";
import { Interests } from "./sections/interests";
import { Languages } from "./sections/languages";
import { Profiles } from "./sections/profiles";
import { Projects } from "./sections/projects";
import { Publications } from "./sections/publications";
import { References } from "./sections/references";
import { Skills } from "./sections/skills";
import { Summary } from "./sections/summary";
import { Volunteer } from "./sections/volunteer";
import { RhyhornWrapper } from "./style";
const sectionMap: Partial<Record<SectionKey, () => React.ReactNode>> = {
summary: Summary,
profiles: Profiles,
experience: Experience,
education: Education,
awards: Awards,
skills: Skills,
certifications: Certifications,
interests: Interests,
languages: Languages,
volunteer: Volunteer,
projects: Projects,
publications: Publications,
references: References,
};
const getSection = (id: SectionKey) => {
const Section = sectionMap[id];
// Custom Section
if (!Section) return <CustomSection key={id} id={id} />;
return <Section key={id} />;
};
export const Rhyhorn = ({ isFirstPage, columns }: TemplateProps) => (
<RhyhornWrapper>
{isFirstPage && <Header />}
{/* Main */}
{columns[0].map(getSection)}
{/* Sidebar */}
{columns[1].map(getSection)}
</RhyhornWrapper>
);

View File

@ -1,37 +0,0 @@
import { Award as IAward } from "@reactive-resume/schema";
import { useStore } from "@reactive-resume/templates";
import { isUrl } from "@reactive-resume/utils";
import { SectionBase } from "../shared/section-base";
export const Awards = () => {
const section = useStore((state) => state.sections.awards);
return (
<SectionBase<IAward>
section={section}
header={(item) => (
<>
<div>
<h6>{item.title}</h6>
<p>{item.awarder}</p>
</div>
<div>
<h6>{item.date}</h6>
</div>
</>
)}
main={(item) => <div dangerouslySetInnerHTML={{ __html: item.summary }} />}
footer={(item) => (
<div>
{isUrl(item.url.href) && (
<a href={item.url.href} target="_blank" rel="noopener noreferrer nofollow">
<h6>{item.url.label || item.url.href}</h6>
</a>
)}
</div>
)}
/>
);
};

View File

@ -1,37 +0,0 @@
import { Certification as ICertification } from "@reactive-resume/schema";
import { useStore } from "@reactive-resume/templates";
import { isUrl } from "@reactive-resume/utils";
import { SectionBase } from "../shared/section-base";
export const Certifications = () => {
const section = useStore((state) => state.sections.certifications);
return (
<SectionBase<ICertification>
section={section}
header={(item) => (
<>
<div>
<h6>{item.name}</h6>
<p>{item.issuer}</p>
</div>
<div>
<h6>{item.date}</h6>
</div>
</>
)}
main={(item) => <div dangerouslySetInnerHTML={{ __html: item.summary }} />}
footer={(item) => (
<div>
{isUrl(item.url.href) && (
<a href={item.url.href} target="_blank" rel="noopener noreferrer nofollow">
<h6>{item.url.label || item.url.href}</h6>
</a>
)}
</div>
)}
/>
);
};

View File

@ -1,54 +0,0 @@
import {
CustomSection as ICustomSection,
CustomSectionItem,
SectionKey,
} from "@reactive-resume/schema";
import { useStore } from "@reactive-resume/templates";
import { isUrl } from "@reactive-resume/utils";
import get from "lodash.get";
import { SectionBase } from "../shared/section-base";
type Props = {
id: SectionKey;
};
export const CustomSection = ({ id }: Props) => {
const section = useStore((state) => get(state.sections, id));
if (!section) return null;
return (
// @ts-expect-error Unable to infer type of Custom Section accurately, ignoring for now
<SectionBase<ICustomSection>
section={section}
header={(item: CustomSectionItem) => (
<>
<div>
<h6>{item.name}</h6>
<p>{item.description}</p>
</div>
<div>
<h6>{item.date}</h6>
<p>{item.location}</p>
</div>
</>
)}
main={(item: CustomSectionItem) => <div dangerouslySetInnerHTML={{ __html: item.summary }} />}
footer={(item: CustomSectionItem) => (
<>
<small>{item.keywords.join(", ")}</small>
<div>
{isUrl(item.url.href) && (
<a href={item.url.href} target="_blank" rel="noopener noreferrer nofollow">
<h6>{item.url.label || item.url.href}</h6>
</a>
)}
</div>
</>
)}
/>
);
};

View File

@ -1,41 +0,0 @@
import { Education as IEducation } from "@reactive-resume/schema";
import { useStore } from "@reactive-resume/templates";
import { isUrl } from "@reactive-resume/utils";
import { SectionBase } from "../shared/section-base";
export const Education = () => {
const section = useStore((state) => state.sections.education);
return (
<SectionBase<IEducation>
section={section}
header={(item) => (
<>
<div>
<h6>{item.institution}</h6>
<p>{item.area}</p>
</div>
<div>
<h6>{item.date}</h6>
<p>
{item.studyType}
{item.score ? ` | ${item.score}` : ""}
</p>
</div>
</>
)}
main={(item) => <div dangerouslySetInnerHTML={{ __html: item.summary }} />}
footer={(item) => (
<div>
{isUrl(item.url.href) && (
<a href={item.url.href} target="_blank" rel="noopener noreferrer nofollow">
<h6>{item.url.label || item.url.href}</h6>
</a>
)}
</div>
)}
/>
);
};

View File

@ -1,38 +0,0 @@
import { Experience as IExperience } from "@reactive-resume/schema";
import { useStore } from "@reactive-resume/templates";
import { isUrl } from "@reactive-resume/utils";
import { SectionBase } from "../shared/section-base";
export const Experience = () => {
const section = useStore((state) => state.sections.experience);
return (
<SectionBase<IExperience>
section={section}
header={(item) => (
<>
<div>
<h6>{item.company}</h6>
<p>{item.position}</p>
</div>
<div>
<h6>{item.date}</h6>
<p>{item.location}</p>
</div>
</>
)}
main={(item) => <div dangerouslySetInnerHTML={{ __html: item.summary }} />}
footer={(item) => (
<div>
{isUrl(item.url.href) && (
<a href={item.url.href} target="_blank" rel="noopener noreferrer nofollow">
<h6>{item.url.label || item.url.href}</h6>
</a>
)}
</div>
)}
/>
);
};

View File

@ -1,57 +0,0 @@
import { Picture, useStore } from "@reactive-resume/templates";
import { isUrl } from "@reactive-resume/utils";
export const Header = () => {
const basics = useStore((state) => state.basics);
return (
<div className="header">
{isUrl(basics.picture.url) && !basics.picture.effects.hidden && (
<Picture
alt={basics.name}
src={basics.picture.url}
$picture={basics.picture}
className="header__picture"
/>
)}
<div className="header__basics">
<h1 className="header__name">{basics.name}</h1>
<p className="header__headline">{basics.headline}</p>
<div className="header__meta">
{basics.location && <span className="header__meta-location">{basics.location}</span>}
{basics.phone && (
<span className="header__meta-phone">
<a href={`tel:${basics.phone}`} target="_blank" rel="noopener noreferrer nofollow">
{basics.phone}
</a>
</span>
)}
{basics.email && (
<span className="header__meta-email">
<a href={`mailto:${basics.email}`} target="_blank" rel="noopener noreferrer nofollow">
{basics.email}
</a>
</span>
)}
{basics.url.href && (
<span className="header__meta-url">
<a href={basics.url.href} target="_blank" rel="noopener noreferrer nofollow">
{basics.url.label || basics.url.href}
</a>
</span>
)}
</div>
<div className="header__meta custom-fields">
{basics.customFields.map((field) => (
<span key={field.id} className="header__meta custom-field">
{[field.name, field.value].filter(Boolean).join(": ")}
</span>
))}
</div>
</div>
</div>
);
};

View File

@ -1,24 +0,0 @@
import { Interest as IInterest } from "@reactive-resume/schema";
import { useStore } from "@reactive-resume/templates";
import { SectionBase } from "../shared/section-base";
export const Interests = () => {
const section = useStore((state) => state.sections.interests);
return (
<SectionBase<IInterest>
section={section}
header={(item) => (
<>
<div>
<h6>{item.name}</h6>
<p>{item.keywords.join(", ")}</p>
</div>
<div />
</>
)}
/>
);
};

View File

@ -1,25 +0,0 @@
import { Language as ILanguage } from "@reactive-resume/schema";
import { useStore } from "@reactive-resume/templates";
import { getCEFRLevel } from "@reactive-resume/utils";
import { SectionBase } from "../shared/section-base";
export const Languages = () => {
const section = useStore((state) => state.sections.languages);
return (
<SectionBase<ILanguage>
section={section}
header={(item) => (
<>
<div>
<h6>{item.name}</h6>
<p>{item.fluency || getCEFRLevel(item.fluencyLevel)}</p>
</div>
<div />
</>
)}
/>
);
};

View File

@ -1,48 +0,0 @@
import { Profile as IProfile } from "@reactive-resume/schema";
import { useStore } from "@reactive-resume/templates";
import { isUrl } from "@reactive-resume/utils";
import styled from "styled-components";
import { SectionBase } from "../shared/section-base";
const Username = styled.h6`
line-height: 1;
font-weight: 500;
`;
export const Profiles = () => {
const section = useStore((state) => state.sections.profiles);
return (
<SectionBase<IProfile>
section={section}
header={(item) => (
<>
<div>
{item.icon && (
<i>
<img
width="16"
height="16"
alt={item.network}
src={`https://cdn.simpleicons.org/${item.icon}`}
/>
</i>
)}
{isUrl(item.url.href) ? (
<a href={item.url.href} target="_blank" rel="noopener noreferrer nofollow">
<Username>{item.username}</Username>
</a>
) : (
<Username>{item.username}</Username>
)}
<small>{item.network}</small>
</div>
<div />
</>
)}
/>
);
};

View File

@ -1,41 +0,0 @@
import { Project as IProject } from "@reactive-resume/schema";
import { useStore } from "@reactive-resume/templates";
import { isUrl } from "@reactive-resume/utils";
import { SectionBase } from "../shared/section-base";
export const Projects = () => {
const section = useStore((state) => state.sections.projects);
return (
<SectionBase<IProject>
section={section}
header={(item) => (
<>
<div>
<h6>{item.name}</h6>
<p>{item.description}</p>
</div>
<div>
<h6>{item.date}</h6>
</div>
</>
)}
main={(item) => <div dangerouslySetInnerHTML={{ __html: item.summary }} />}
footer={(item) => (
<>
<small>{item.keywords.join(", ")}</small>
<div>
{isUrl(item.url.href) && (
<a href={item.url.href} target="_blank" rel="noopener noreferrer nofollow">
<h6>{item.url.label || item.url.href}</h6>
</a>
)}
</div>
</>
)}
/>
);
};

View File

@ -1,37 +0,0 @@
import { Publication as IPublication } from "@reactive-resume/schema";
import { useStore } from "@reactive-resume/templates";
import { isUrl } from "@reactive-resume/utils";
import { SectionBase } from "../shared/section-base";
export const Publications = () => {
const section = useStore((state) => state.sections.publications);
return (
<SectionBase<IPublication>
section={section}
header={(item) => (
<>
<div>
<h6>{item.name}</h6>
<p>{item.publisher}</p>
</div>
<div>
<h6>{item.date}</h6>
</div>
</>
)}
main={(item) => <div dangerouslySetInnerHTML={{ __html: item.summary }} />}
footer={(item) => (
<div>
{isUrl(item.url.href) && (
<a href={item.url.href} target="_blank" rel="noopener noreferrer nofollow">
<h6>{item.url.label || item.url.href}</h6>
</a>
)}
</div>
)}
/>
);
};

View File

@ -1,35 +0,0 @@
import { Reference as IReference } from "@reactive-resume/schema";
import { useStore } from "@reactive-resume/templates";
import { isUrl } from "@reactive-resume/utils";
import { SectionBase } from "../shared/section-base";
export const References = () => {
const section = useStore((state) => state.sections.references);
return (
<SectionBase<IReference>
section={section}
header={(item) => (
<>
<div>
<h6>{item.name}</h6>
<p>{item.description}</p>
</div>
<div />
</>
)}
main={(item) => <div dangerouslySetInnerHTML={{ __html: item.summary }} />}
footer={(item) => (
<div>
{isUrl(item.url.href) && (
<a href={item.url.href} target="_blank" rel="noopener noreferrer nofollow">
<h6>{item.url.label || item.url.href}</h6>
</a>
)}
</div>
)}
/>
);
};

View File

@ -1,25 +0,0 @@
import { Skill as ISkill } from "@reactive-resume/schema";
import { useStore } from "@reactive-resume/templates";
import { SectionBase } from "../shared/section-base";
export const Skills = () => {
const section = useStore((state) => state.sections.skills);
return (
<SectionBase<ISkill>
section={section}
header={(item) => (
<>
<div>
<h6>{item.name}</h6>
<p>{item.description}</p>
</div>
<div />
</>
)}
footer={(item) => <small>{item.keywords.join(", ")}</small>}
/>
);
};

View File

@ -1,20 +0,0 @@
import { useStore } from "@reactive-resume/templates";
export const Summary = () => {
const section = useStore((state) => state.sections.summary);
if (!section.visible || !section.content) return null;
return (
<section id={section.id} className={`section section__${section.id}`}>
<h4 className="section__heading">{section.name}</h4>
<main className="section__item-content">
<div
style={{ columns: section.columns }}
dangerouslySetInnerHTML={{ __html: section.content }}
/>
</main>
</section>
);
};

View File

@ -1,38 +0,0 @@
import { Volunteer as IVolunteer } from "@reactive-resume/schema";
import { useStore } from "@reactive-resume/templates";
import { isUrl } from "@reactive-resume/utils";
import { SectionBase } from "../shared/section-base";
export const Volunteer = () => {
const section = useStore((state) => state.sections.volunteer);
return (
<SectionBase<IVolunteer>
section={section}
header={(item) => (
<>
<div>
<h6>{item.organization}</h6>
<p>{item.position}</p>
</div>
<div>
<h6>{item.date}</h6>
<p>{item.location}</p>
</div>
</>
)}
main={(item) => <div dangerouslySetInnerHTML={{ __html: item.summary }} />}
footer={(item) => (
<div>
{isUrl(item.url.href) && (
<a href={item.url.href} target="_blank" rel="noopener noreferrer nofollow">
<h6>{item.url.label || item.url.href}</h6>
</a>
)}
</div>
)}
/>
);
};

View File

@ -1,31 +0,0 @@
import { Item, SectionItem, SectionWithItem } from "@reactive-resume/schema";
import { ItemGrid } from "@reactive-resume/templates";
type Props<T extends Item> = {
section: SectionWithItem<T>;
header?: (item: T) => React.ReactNode;
main?: (item: T) => React.ReactNode;
footer?: (item: T) => React.ReactNode;
};
export const SectionBase = <T extends SectionItem>({ section, header, main, footer }: Props<T>) => {
if (!section.visible || !section.items.length) return null;
return (
<section id={section.id} className={`section section__${section.id}`}>
<h4 className="section__heading">{section.name}</h4>
<ItemGrid className="section__items" $columns={section.columns}>
{section.items
.filter((item) => !!item.visible)
.map((item) => (
<div key={item.id} className="section__item">
{header && <header className="section__item-header">{header(item as T)}</header>}
{main && <main className="section__item-main">{main(item as T)}</main>}
{footer && <footer className="section__item-footer">{footer(item as T)}</footer>}
</div>
))}
</ItemGrid>
</section>
);
};

View File

@ -1,105 +0,0 @@
import styled from "styled-components";
export const RhyhornWrapper = styled.div`
display: grid;
row-gap: 16px;
.header {
display: flex;
&__picture {
align-self: center;
margin-right: 12px;
}
&__basics {
align-self: center;
}
&__name {
font-size: 1.5rem;
line-height: calc(var(--line-height) + 0.5rem);
}
&__headline {
color: var(--color-primary);
}
&__meta {
font-size: 0.875rem;
line-height: var(--line-height);
span {
padding: 0 6px;
border-right: 1px solid currentColor;
&:first-child {
padding-left: 0;
}
&:last-child {
border-right: none;
}
}
}
}
.section {
&__heading {
font-size: 0.9rem;
line-height: 1.2rem;
padding-bottom: 2px;
margin-bottom: 6px;
text-transform: uppercase;
color: var(--color-primary);
border-bottom: 1px solid var(--color-text);
}
&__item {
display: flex;
flex-direction: column;
gap: 4px;
&-header {
position: relative;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 6px;
& > div:last-child {
text-align: right;
}
&:has(i) div {
margin-left: 22px;
}
& > div > i {
position: absolute;
top: 6px;
left: 0;
}
}
&-content p:not(:last-child),
&-main p:not(:last-child) {
padding-bottom: 0.75rem;
}
& .rating {
display: flex;
justify-content: flex-end;
margin-top: 4px;
& > span {
width: 8px;
height: 8px;
margin-left: 4px;
border-radius: 50%;
border: 1px solid currentColor;
}
}
}
}
`;

View File

@ -1,19 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": ["vitest/globals", "vitest/importMeta", "vite/client", "node", "vitest"]
},
"include": [
"vite.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
]
}

View File

@ -1,39 +0,0 @@
/// <reference types='vitest' />
import { nxViteTsPaths } from "@nx/vite/plugins/nx-tsconfig-paths.plugin";
import react from "@vitejs/plugin-react";
import * as path from "path";
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
cacheDir: "../../node_modules/.vite/templates",
plugins: [
react(),
nxViteTsPaths(),
dts({
entryRoot: "src",
tsconfigPath: path.join(__dirname, "tsconfig.lib.json"),
}),
],
build: {
lib: {
entry: "src/index.ts",
name: "templates",
fileName: "index",
formats: ["es", "cjs"],
},
rollupOptions: {
external: ["react", "react-dom", "react/jsx-runtime"],
},
},
test: {
globals: true,
environment: "jsdom",
cache: { dir: "../../node_modules/.vitest" },
include: ["src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
},
});

View File

@ -1,11 +1,7 @@
import { cva } from "class-variance-authority";
export const buttonVariants = cva(
[
"inline-flex items-center justify-center rounded-sm text-sm font-medium ring-offset-background disabled:pointer-events-none disabled:opacity-50",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary focus-visible:ring-offset-2",
"scale-100 transition-[transform,background-color] active:scale-95",
],
"inline-flex scale-100 items-center justify-center rounded-sm text-sm font-medium ring-offset-background transition-[transform,background-color] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary focus-visible:ring-offset-2 active:scale-95 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {

View File

@ -1,3 +1,5 @@
import { Template } from "./types";
export const pageSizeMap = {
a4: {
width: 210,
@ -8,3 +10,12 @@ export const pageSizeMap = {
height: 279,
},
};
export const templatesList: Template[] = [
{
id: "rhyhorn",
name: "Rhyhorn",
image:
"https://res.cloudinary.com/amruth-pillai/image/upload/v1699370067/reactive-resume/templates/482-2480x3508_vuf5ev.jpg",
},
];

View File

@ -15,6 +15,11 @@ export const isUrl = (string: string) => {
return urlRegex.test(string);
};
export const isEmptyString = (string: string) => {
if (string === "<p></p>") return true;
return string.trim().length === 0;
};
export const extractUrl = (string: string) => {
const urlRegex = /https?:\/\/[^ \n]+/i;

View File

@ -1,5 +1,7 @@
export type Json = Record<string, unknown>;
export type Template = { id: string; name: string; image: string };
export type LayoutLocator = { page: number; column: number; section: number };
export type DeepPartial<T> = T extends object ? { [P in keyof T]?: DeepPartial<T[P]> } : T;

View File

@ -51,28 +51,27 @@
"@tailwindcss/typography": "^0.5.10",
"@tanstack/eslint-plugin-query": "^5.6.0",
"@testing-library/react": "14.0.0",
"@types/async-retry": "^1.4.7",
"@types/bcryptjs": "^2.4.5",
"@types/cookie-parser": "^1.4.5",
"@types/express": "^4.17.20",
"@types/file-saver": "^2.0.6",
"@types/async-retry": "^1.4.8",
"@types/bcryptjs": "^2.4.6",
"@types/cookie-parser": "^1.4.6",
"@types/express": "^4.17.21",
"@types/file-saver": "^2.0.7",
"@types/jest": "^29.5.7",
"@types/lodash.debounce": "^4.0.8",
"@types/lodash.get": "^4.4.8",
"@types/lodash.set": "^4.3.8",
"@types/multer": "^1.4.9",
"@types/lodash.debounce": "^4.0.9",
"@types/lodash.get": "^4.4.9",
"@types/lodash.set": "^4.3.9",
"@types/multer": "^1.4.10",
"@types/node": "20.8.10",
"@types/nodemailer": "^6.4.13",
"@types/papaparse": "^5.3.10",
"@types/passport": "^1.0.14",
"@types/passport-github2": "^1.2.8",
"@types/passport-google-oauth20": "^2.0.13",
"@types/passport-local": "^1.0.37",
"@types/papaparse": "^5.3.11",
"@types/passport": "^1.0.15",
"@types/passport-github2": "^1.2.9",
"@types/passport-google-oauth20": "^2.0.14",
"@types/passport-local": "^1.0.38",
"@types/react": "18.2.36",
"@types/react-dom": "18.2.14",
"@types/react-is": "18.2.3",
"@types/retry": "^0.12.4",
"@types/styled-components": "5.1.29",
"@types/webfontloader": "^1.6.36",
"@typescript-eslint/eslint-plugin": "^6.10.0",
"@typescript-eslint/parser": "^6.10.0",
@ -81,7 +80,6 @@
"@vitest/coverage-v8": "^0.34.6",
"@vitest/ui": "~0.34.6",
"autoprefixer": "^10.4.16",
"babel-plugin-styled-components": "2.1.4",
"cypress": "^13.4.0",
"cz-conventional-changelog": "^3.3.0",
"eslint": "~8.53.0",
@ -115,10 +113,10 @@
"vitest": "~0.34.6"
},
"dependencies": {
"@dnd-kit/core": "^6.0.8",
"@dnd-kit/modifiers": "^6.0.1",
"@dnd-kit/sortable": "^7.0.2",
"@dnd-kit/utilities": "^3.2.1",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/modifiers": "^7.0.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource/ibm-plex-sans": "^5.0.17",
"@hookform/resolvers": "^3.3.2",
"@nestjs-modules/mailer": "^1.9.1",
@ -172,7 +170,7 @@
"@tiptap/extension-underline": "^2.1.12",
"@tiptap/react": "^2.1.12",
"@tiptap/starter-kit": "^2.1.12",
"@types/passport-jwt": "^3.0.12",
"@types/passport-jwt": "^3.0.13",
"async-retry": "^1.3.3",
"await-to-js": "^3.0.0",
"axios": "^1.6.0",
@ -188,7 +186,7 @@
"deepmerge": "^4.3.1",
"file-saver": "^2.0.5",
"framer-motion": "^10.16.4",
"helmet": "^7.0.0",
"helmet": "^7.1.0",
"immer": "^10.0.3",
"ioredis": "^5.3.2",
"jszip": "^3.10.1",
@ -201,7 +199,7 @@
"nestjs-prisma": "^0.22.0",
"nestjs-zod": "^3.0.0",
"nodemailer": "^6.9.7",
"openai": "^4.16.0",
"openai": "^4.16.1",
"otplib": "^12.0.1",
"papaparse": "^5.4.1",
"passport": "^0.6.0",
@ -215,10 +213,8 @@
"react": "18.2.0",
"react-colorful": "^5.6.1",
"react-dom": "18.2.0",
"react-frame-component": "^5.2.6",
"react-helmet-async": "^1.3.0",
"react-hook-form": "^7.48.2",
"react-is": "18.2.0",
"react-parallax-tilt": "^1.7.172",
"react-resizable-panels": "^0.0.55",
"react-router-dom": "6.18.0",
@ -226,7 +222,6 @@
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1",
"sharp": "^0.32.6",
"styled-components": "6.1.0",
"tailwind-merge": "^2.0.0",
"tslib": "^2.6.2",
"unique-names-generator": "^4.7.1",

226
pnpm-lock.yaml generated
View File

@ -6,17 +6,17 @@ settings:
dependencies:
'@dnd-kit/core':
specifier: ^6.0.8
version: 6.0.8(react-dom@18.2.0)(react@18.2.0)
specifier: ^6.1.0
version: 6.1.0(react-dom@18.2.0)(react@18.2.0)
'@dnd-kit/modifiers':
specifier: ^6.0.1
version: 6.0.1(@dnd-kit/core@6.0.8)(react@18.2.0)
specifier: ^7.0.0
version: 7.0.0(@dnd-kit/core@6.1.0)(react@18.2.0)
'@dnd-kit/sortable':
specifier: ^7.0.2
version: 7.0.2(@dnd-kit/core@6.0.8)(react@18.2.0)
specifier: ^8.0.0
version: 8.0.0(@dnd-kit/core@6.1.0)(react@18.2.0)
'@dnd-kit/utilities':
specifier: ^3.2.1
version: 3.2.1(react@18.2.0)
specifier: ^3.2.2
version: 3.2.2(react@18.2.0)
'@fontsource/ibm-plex-sans':
specifier: ^5.0.17
version: 5.0.17
@ -177,8 +177,8 @@ dependencies:
specifier: ^2.1.12
version: 2.1.12(@tiptap/pm@2.1.12)
'@types/passport-jwt':
specifier: ^3.0.12
version: 3.0.12
specifier: ^3.0.13
version: 3.0.13
async-retry:
specifier: ^1.3.3
version: 1.3.3
@ -225,8 +225,8 @@ dependencies:
specifier: ^10.16.4
version: 10.16.4(react-dom@18.2.0)(react@18.2.0)
helmet:
specifier: ^7.0.0
version: 7.0.0
specifier: ^7.1.0
version: 7.1.0
immer:
specifier: ^10.0.3
version: 10.0.3
@ -264,8 +264,8 @@ dependencies:
specifier: ^6.9.7
version: 6.9.7
openai:
specifier: ^4.16.0
version: 4.16.0
specifier: ^4.16.1
version: 4.16.1
otplib:
specifier: ^12.0.1
version: 12.0.1
@ -455,35 +455,35 @@ devDependencies:
specifier: 14.0.0
version: 14.0.0(react-dom@18.2.0)(react@18.2.0)
'@types/async-retry':
specifier: ^1.4.7
version: 1.4.7
specifier: ^1.4.8
version: 1.4.8
'@types/bcryptjs':
specifier: ^2.4.5
version: 2.4.5
specifier: ^2.4.6
version: 2.4.6
'@types/cookie-parser':
specifier: ^1.4.5
version: 1.4.5
specifier: ^1.4.6
version: 1.4.6
'@types/express':
specifier: ^4.17.20
version: 4.17.20
specifier: ^4.17.21
version: 4.17.21
'@types/file-saver':
specifier: ^2.0.6
version: 2.0.6
specifier: ^2.0.7
version: 2.0.7
'@types/jest':
specifier: ^29.5.7
version: 29.5.7
'@types/lodash.debounce':
specifier: ^4.0.8
version: 4.0.8
specifier: ^4.0.9
version: 4.0.9
'@types/lodash.get':
specifier: ^4.4.8
version: 4.4.8
specifier: ^4.4.9
version: 4.4.9
'@types/lodash.set':
specifier: ^4.3.8
version: 4.3.8
specifier: ^4.3.9
version: 4.3.9
'@types/multer':
specifier: ^1.4.9
version: 1.4.9
specifier: ^1.4.10
version: 1.4.10
'@types/node':
specifier: 20.8.10
version: 20.8.10
@ -491,20 +491,20 @@ devDependencies:
specifier: ^6.4.13
version: 6.4.13
'@types/papaparse':
specifier: ^5.3.10
version: 5.3.10
specifier: ^5.3.11
version: 5.3.11
'@types/passport':
specifier: ^1.0.14
version: 1.0.14
specifier: ^1.0.15
version: 1.0.15
'@types/passport-github2':
specifier: ^1.2.8
version: 1.2.8
specifier: ^1.2.9
version: 1.2.9
'@types/passport-google-oauth20':
specifier: ^2.0.13
version: 2.0.13
specifier: ^2.0.14
version: 2.0.14
'@types/passport-local':
specifier: ^1.0.37
version: 1.0.37
specifier: ^1.0.38
version: 1.0.38
'@types/react':
specifier: 18.2.36
version: 18.2.36
@ -2303,8 +2303,8 @@ packages:
- supports-color
dev: true
/@dnd-kit/accessibility@3.0.1(react@18.2.0):
resolution: {integrity: sha512-HXRrwS9YUYQO9lFRc/49uO/VICbM+O+ZRpFDe9Pd1rwVv2PCNkRiTZRdxrDgng/UkvdC3Re9r2vwPpXXrWeFzg==}
/@dnd-kit/accessibility@3.1.0(react@18.2.0):
resolution: {integrity: sha512-ea7IkhKvlJUv9iSHJOnxinBcoOI3ppGnnL+VDJ75O45Nss6HtZd8IdN8touXPDtASfeI2T2LImb8VOZcL47wjQ==}
peerDependencies:
react: '>=16.8.0'
dependencies:
@ -2312,45 +2312,45 @@ packages:
tslib: 2.6.2
dev: false
/@dnd-kit/core@6.0.8(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-lYaoP8yHTQSLlZe6Rr9qogouGUz9oRUj4AHhDQGQzq/hqaJRpFo65X+JKsdHf8oUFBzx5A+SJPUvxAwTF2OabA==}
/@dnd-kit/core@6.1.0(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-J3cQBClB4TVxwGo3KEjssGEXNJqGVWx17aRTZ1ob0FliR5IjYgTxl5YJbKTzA6IzrtelotH19v6y7uoIRUZPSg==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
dependencies:
'@dnd-kit/accessibility': 3.0.1(react@18.2.0)
'@dnd-kit/utilities': 3.2.1(react@18.2.0)
'@dnd-kit/accessibility': 3.1.0(react@18.2.0)
'@dnd-kit/utilities': 3.2.2(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
tslib: 2.6.2
dev: false
/@dnd-kit/modifiers@6.0.1(@dnd-kit/core@6.0.8)(react@18.2.0):
resolution: {integrity: sha512-rbxcsg3HhzlcMHVHWDuh9LCjpOVAgqbV78wLGI8tziXY3+qcMQ61qVXIvNKQFuhj75dSfD+o+PYZQ/NUk2A23A==}
/@dnd-kit/modifiers@7.0.0(@dnd-kit/core@6.1.0)(react@18.2.0):
resolution: {integrity: sha512-BG/ETy3eBjFap7+zIti53f0PCLGDzNXyTmn6fSdrudORf+OH04MxrW4p5+mPu4mgMk9kM41iYONjc3DOUWTcfg==}
peerDependencies:
'@dnd-kit/core': ^6.0.6
'@dnd-kit/core': ^6.1.0
react: '>=16.8.0'
dependencies:
'@dnd-kit/core': 6.0.8(react-dom@18.2.0)(react@18.2.0)
'@dnd-kit/utilities': 3.2.1(react@18.2.0)
'@dnd-kit/core': 6.1.0(react-dom@18.2.0)(react@18.2.0)
'@dnd-kit/utilities': 3.2.2(react@18.2.0)
react: 18.2.0
tslib: 2.6.2
dev: false
/@dnd-kit/sortable@7.0.2(@dnd-kit/core@6.0.8)(react@18.2.0):
resolution: {integrity: sha512-wDkBHHf9iCi1veM834Gbk1429bd4lHX4RpAwT0y2cHLf246GAvU2sVw/oxWNpPKQNQRQaeGXhAVgrOl1IT+iyA==}
/@dnd-kit/sortable@8.0.0(@dnd-kit/core@6.1.0)(react@18.2.0):
resolution: {integrity: sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==}
peerDependencies:
'@dnd-kit/core': ^6.0.7
'@dnd-kit/core': ^6.1.0
react: '>=16.8.0'
dependencies:
'@dnd-kit/core': 6.0.8(react-dom@18.2.0)(react@18.2.0)
'@dnd-kit/utilities': 3.2.1(react@18.2.0)
'@dnd-kit/core': 6.1.0(react-dom@18.2.0)(react@18.2.0)
'@dnd-kit/utilities': 3.2.2(react@18.2.0)
react: 18.2.0
tslib: 2.6.2
dev: false
/@dnd-kit/utilities@3.2.1(react@18.2.0):
resolution: {integrity: sha512-OOXqISfvBw/1REtkSK2N3Fi2EQiLMlWUlqnOK/UpOISqBZPWpE6TqL+jcPtMOkE8TqYGiURvRdPSI9hltNUjEA==}
/@dnd-kit/utilities@3.2.2(react@18.2.0):
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
peerDependencies:
react: '>=16.8.0'
dependencies:
@ -6769,8 +6769,8 @@ packages:
resolution: {integrity: sha512-0Z6Tr7wjKJIk4OUEjVUQMtyunLDy339vcMaj38Kpj6jM2OE1p3S4kXExKZ7a3uXQAPCoy3sbrP1wibDKaf39oA==}
dev: true
/@types/async-retry@1.4.7:
resolution: {integrity: sha512-4NH5wuf9x7LZWD23/knI6RBywD1qCmLm7wNaqq0riy7hdDrCGGfkPOUvx0Cb78lVrFrEnCvocoL8+UcvSQlBDw==}
/@types/async-retry@1.4.8:
resolution: {integrity: sha512-Qup/B5PWLe86yI5I3av6ePGaeQrIHNKCwbsQotD6aHQ6YkHsMUxVZkZsmx/Ry3VZQ6uysHwTjQ7666+k6UjVJA==}
dependencies:
'@types/retry': 0.12.4
dev: true
@ -6804,8 +6804,8 @@ packages:
'@babel/types': 7.23.0
dev: true
/@types/bcryptjs@2.4.5:
resolution: {integrity: sha512-tOF6TivOIvq+TWQm78335CMdyVJhpBG3NUdWQDAp95ax4E2rSKbws/ELHLk5EBoucwx/tHt3/hhLOHwWJgVrSw==}
/@types/bcryptjs@2.4.6:
resolution: {integrity: sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==}
dev: true
/@types/body-parser@1.19.4:
@ -6851,10 +6851,10 @@ packages:
dependencies:
'@types/node': 20.8.10
/@types/cookie-parser@1.4.5:
resolution: {integrity: sha512-cbpH1NldYslPt7WRHXZFm+G7DTfUg57dQSCf1qrHwT8wtGX41JHLYf3Cieiqg7waPWjorVgcSSllZov+A1PJbg==}
/@types/cookie-parser@1.4.6:
resolution: {integrity: sha512-KoooCrD56qlLskXPLGUiJxOMnv5l/8m7cQD2OxJ73NPMhuSz9PmvwRD6EpjDyKBVrdJDdQ4bQK7JFNHnNmax0w==}
dependencies:
'@types/express': 4.17.20
'@types/express': 4.17.21
dev: true
/@types/ejs@3.1.4:
@ -6888,16 +6888,16 @@ packages:
'@types/range-parser': 1.2.6
'@types/send': 0.17.3
/@types/express@4.17.20:
resolution: {integrity: sha512-rOaqlkgEvOW495xErXMsmyX3WKBInbhG5eqojXYi3cGUaLoRDlXa5d52fkfWZT963AZ3v2eZ4MbKE6WpDAGVsw==}
/@types/express@4.17.21:
resolution: {integrity: sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==}
dependencies:
'@types/body-parser': 1.19.4
'@types/express-serve-static-core': 4.17.39
'@types/qs': 6.9.9
'@types/serve-static': 1.15.4
/@types/file-saver@2.0.6:
resolution: {integrity: sha512-Mw671DVqoMHbjw0w4v2iiOro01dlT/WhWp5uwecBa0Wg8c+bcZOjgF1ndBnlaxhtvFCgTRBtsGivSVhrK/vnag==}
/@types/file-saver@2.0.7:
resolution: {integrity: sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==}
dev: true
/@types/graceful-fs@4.1.8:
@ -6975,20 +6975,20 @@ packages:
'@types/node': 20.8.10
dev: true
/@types/lodash.debounce@4.0.8:
resolution: {integrity: sha512-REumepIJjQFSOaBUoj81U5ZzF9YIhovzE2Lm6ejUbycmwx597k2ivG1cVfPtAj4eVuSbGoZDkJR0sRIahsE6/Q==}
/@types/lodash.debounce@4.0.9:
resolution: {integrity: sha512-Ma5JcgTREwpLRwMM+XwBR7DaWe96nC38uCBDFKZWbNKD+osjVzdpnUSwBcqCptrp16sSOLBAUb50Car5I0TCsQ==}
dependencies:
'@types/lodash': 4.14.200
dev: true
/@types/lodash.get@4.4.8:
resolution: {integrity: sha512-XK+co6sBkJxh1vaVP8al6cAA17dX//RNCknGG8JhpHFJfxq/GXKAYB9NKheG22pu2xpWpxfFd65W08EhH2IFlg==}
/@types/lodash.get@4.4.9:
resolution: {integrity: sha512-J5dvW98sxmGnamqf+/aLP87PYXyrha9xIgc2ZlHl6OHMFR2Ejdxep50QfU0abO1+CH6+ugx+8wEUN1toImAinA==}
dependencies:
'@types/lodash': 4.14.200
dev: true
/@types/lodash.set@4.3.8:
resolution: {integrity: sha512-WYIWnVO5xkcEKehhZf0Whrf9wj9D1AuaGTpwT/mCEJXKgdC2UWcMpvRqJahKQNhnOjmGEhpUqbYNJ6gUgdGSQw==}
/@types/lodash.set@4.3.9:
resolution: {integrity: sha512-KOxyNkZpbaggVmqbpr82N2tDVTx05/3/j0f50Es1prxrWB0XYf9p3QNxqcbWb7P1Q9wlvsUSlCFnwlPCIJ46PQ==}
dependencies:
'@types/lodash': 4.14.200
dev: true
@ -7007,10 +7007,10 @@ packages:
resolution: {integrity: sha512-Kfe/D3hxHTusnPNRbycJE1N77WHDsdS4AjUYIzlDzhDrS47NrwuL3YW4VITxwR7KCVpzwgy4Rbj829KSSQmwXQ==}
dev: true
/@types/multer@1.4.9:
resolution: {integrity: sha512-9NSvPJ2E8bNTc8XtJq1Cimx2Wrn2Ah48F15B2Du/hM8a8CHLhVbJMlF3ZCqhvMdht7Sa+YdP0aKP7N4fxDcrrg==}
/@types/multer@1.4.10:
resolution: {integrity: sha512-6l9mYMhUe8wbnz/67YIjc7ZJyQNZoKq7fRXVf7nMdgWgalD0KyzJ2ywI7hoATUSXSbTu9q2HBiEwzy0tNN1v2w==}
dependencies:
'@types/express': 4.17.20
'@types/express': 4.17.21
dev: true
/@types/node-fetch@2.6.8:
@ -7060,8 +7060,8 @@ packages:
resolution: {integrity: sha512-qZqHmdGEALeSATMB1djT1S5szv6Wtpb7DKpHrt2XG4iyKlV7C2Xk8GmDXr1KXakOqUfX6ohw7ceruYt4NVmB1Q==}
dev: false
/@types/papaparse@5.3.10:
resolution: {integrity: sha512-mS1Fta/xJ9EDYmAvpeWzcV9Gr0cOl1ClpW7di9+wSUNDIDO55tBtyXg97O7K+Syrd9rDEmuejM2iqmJIJ1SO5g==}
/@types/papaparse@5.3.11:
resolution: {integrity: sha512-ISil0lMkpRDrBTKRPnUgVb5IqxWwj19gWBrX/ROk3pbkkslBN3URa713r/BSfAUj+w9gTPg3S3f45aMToVfh1w==}
dependencies:
'@types/node': 20.8.10
dev: true
@ -7070,56 +7070,56 @@ packages:
resolution: {integrity: sha512-3YmXzzPAdOTVljVMkTMBdBEvlOLg2cDQaDhnnhT3nT9uDbnJzjWhKlzb+desT12Y7tGqaN6d+AbozcKzyL36Ng==}
dev: true
/@types/passport-github2@1.2.8:
resolution: {integrity: sha512-OD1WQ5AOIyTHgBWYJxG/yudHhojAfZXLpidzyoohjwFfu5sjJf/A5J23sm8YCQpnDjcWwaPFQ/NB7XC3QZjNJg==}
/@types/passport-github2@1.2.9:
resolution: {integrity: sha512-/nMfiPK2E6GKttwBzwj0Wjaot8eHrM57hnWxu52o6becr5/kXlH/4yE2v2rh234WGvSgEEzIII02Nc5oC5xEHA==}
dependencies:
'@types/express': 4.17.20
'@types/passport': 1.0.14
'@types/express': 4.17.21
'@types/passport': 1.0.15
'@types/passport-oauth2': 1.4.14
dev: true
/@types/passport-google-oauth20@2.0.13:
resolution: {integrity: sha512-idIhUp1RyBqk8cgApCHvqIvk09QVZv83hQJ/39VonIHYZkBps8p0AfB9INtPee3iuittdFx9J+i35pdZBgCqUQ==}
/@types/passport-google-oauth20@2.0.14:
resolution: {integrity: sha512-ZaZpRUAeMl3vy298ulKO1wGLn9SQtj/CyIfZL/Px5xU9pybMiQU3mhXDCBiWSbg0EK9uXT4ZoWC3ktuWY+5fwQ==}
dependencies:
'@types/express': 4.17.20
'@types/passport': 1.0.14
'@types/express': 4.17.21
'@types/passport': 1.0.15
'@types/passport-oauth2': 1.4.14
dev: true
/@types/passport-jwt@3.0.12:
resolution: {integrity: sha512-nXCd1lu20rw//nZ5AnK1FnlVZdSC4R5xksquev9oAJlXwJw0irMdZ7dRAE4KDlalptKObiaoam6BQ8lpujeZog==}
/@types/passport-jwt@3.0.13:
resolution: {integrity: sha512-fjHaC6Bv8EpMMqzTnHP32SXlZGaNfBPC/Po5dmRGYi2Ky7ljXPbGnOy+SxZqa6iZvFgVhoJ1915Re3m93zmcfA==}
dependencies:
'@types/express': 4.17.20
'@types/express': 4.17.21
'@types/jsonwebtoken': 9.0.4
'@types/passport-strategy': 0.2.37
dev: false
/@types/passport-local@1.0.37:
resolution: {integrity: sha512-c57CwMHhMP2BBiOLyQZGRP43F8JtC84H976YVJdiU4EIWvqRCZ3F7QtsEgksOEIgMOk1Kz3EEKGA93OiDPQtRQ==}
/@types/passport-local@1.0.38:
resolution: {integrity: sha512-nsrW4A963lYE7lNTv9cr5WmiUD1ibYJvWrpE13oxApFsRt77b0RdtZvKbCdNIY4v/QZ6TRQWaDDEwV1kCTmcXg==}
dependencies:
'@types/express': 4.17.20
'@types/passport': 1.0.14
'@types/express': 4.17.21
'@types/passport': 1.0.15
'@types/passport-strategy': 0.2.37
dev: true
/@types/passport-oauth2@1.4.14:
resolution: {integrity: sha512-wZBvnRwqdvm35l1Jn9ebYm2Q7UtxYIdBu1PjoKXMoxJytniVjXxYJmrlDXn5fMZROWbJbnEnp1XSDANqtvMdGQ==}
dependencies:
'@types/express': 4.17.20
'@types/express': 4.17.21
'@types/oauth': 0.9.3
'@types/passport': 1.0.14
'@types/passport': 1.0.15
dev: true
/@types/passport-strategy@0.2.37:
resolution: {integrity: sha512-ltgwLnwHVfpjK7/66lpv43hiz90nIVb36JmeB0iF3FAZoHX6+LbkY5Ey97Bm8Jr0uGhQyDFEsSOOfejp5PJehg==}
dependencies:
'@types/express': 4.17.20
'@types/passport': 1.0.14
'@types/express': 4.17.21
'@types/passport': 1.0.15
/@types/passport@1.0.14:
resolution: {integrity: sha512-D6p2ygR2S7Cq5PO7iUaEIQu/5WrM0tONu6Lxgk0C9r3lafQIlVpWCo3V/KI9To3OqHBxcfQaOeK+8AvwW5RYmw==}
/@types/passport@1.0.15:
resolution: {integrity: sha512-oHOgzPBp5eLI1U/7421qYV/ZySQXMYCBSfRkDe1tQ0YrIbLY/M/76qIXE7Bs7lFyvw1x5QqiNQ9imvh0fQHe9Q==}
dependencies:
'@types/express': 4.17.20
'@types/express': 4.17.21
/@types/prop-types@15.7.9:
resolution: {integrity: sha512-n1yyPsugYNSmHgxDFjicaI2+gCNjsBck8UX9kuofAKlc0h1bL+20oSF72KeNaW2DUlesbEVCFgyV2dPGTiY42g==}
@ -7183,7 +7183,7 @@ packages:
/@types/serve-index@1.9.3:
resolution: {integrity: sha512-4KG+yMEuvDPRrYq5fyVm/I2uqAJSAwZK9VSa+Zf+zUq9/oxSSvy3kkIqyL+jjStv6UCVi8/Aho0NHtB1Fwosrg==}
dependencies:
'@types/express': 4.17.20
'@types/express': 4.17.21
dev: true
/@types/serve-static@1.15.4:
@ -12073,8 +12073,8 @@ packages:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true
/helmet@7.0.0:
resolution: {integrity: sha512-MsIgYmdBh460ZZ8cJC81q4XJknjG567wzEmv46WOBblDb6TUd3z8/GhgmsM9pn8g2B80tAJ4m5/d3Bi1KrSUBQ==}
/helmet@7.1.0:
resolution: {integrity: sha512-g+HZqgfbpXdCkme/Cd/mZkV0aV3BZZZSugecH03kl38m/Kmdx8jKjBikpDj2cr+Iynv4KpYEviojNdTJActJAg==}
engines: {node: '>=16.0.0'}
dev: false
@ -12253,7 +12253,7 @@ packages:
- supports-color
dev: false
/http-proxy-middleware@2.0.6(@types/express@4.17.20):
/http-proxy-middleware@2.0.6(@types/express@4.17.21):
resolution: {integrity: sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw==}
engines: {node: '>=12.0.0'}
peerDependencies:
@ -12262,7 +12262,7 @@ packages:
'@types/express':
optional: true
dependencies:
'@types/express': 4.17.20
'@types/express': 4.17.21
'@types/http-proxy': 1.17.13
http-proxy: 1.18.1
is-glob: 4.0.3
@ -15397,8 +15397,8 @@ packages:
is-wsl: 2.2.0
dev: true
/openai@4.16.0:
resolution: {integrity: sha512-P7IWqvaP0EuD3cQvzmU24KprnS0tHavrTuMiChEOa7pugqrKUfcmTnHcr+w01sJHny3vYoTvydCZ77cQolYj+g==}
/openai@4.16.1:
resolution: {integrity: sha512-Gr+uqUN1ICSk6VhrX64E+zL7skjI1TgPr/XUN+ZQuNLLOvx15+XZulx/lSW4wFEAQzgjBDlMBbBeikguGIjiMg==}
hasBin: true
dependencies:
'@types/node': 18.18.8
@ -19675,7 +19675,7 @@ packages:
dependencies:
'@types/bonjour': 3.5.12
'@types/connect-history-api-fallback': 1.5.2
'@types/express': 4.17.20
'@types/express': 4.17.21
'@types/serve-index': 1.9.3
'@types/serve-static': 1.15.4
'@types/sockjs': 0.3.35
@ -19690,7 +19690,7 @@ packages:
express: 4.18.2
graceful-fs: 4.2.11
html-entities: 2.4.0
http-proxy-middleware: 2.0.6(@types/express@4.17.20)
http-proxy-middleware: 2.0.6(@types/express@4.17.21)
ipaddr.js: 2.1.0
launch-editor: 2.6.1
open: 8.4.2

View File

@ -15,15 +15,13 @@
"skipDefaultLibCheck": true,
"baseUrl": ".",
"paths": {
// App Paths
"@/client/*": ["apps/client/src/*"],
"@/server/*": ["apps/server/src/*"],
// Library Paths
"@/artboard/*": ["apps/artboard/src/*"],
"@reactive-resume/dto": ["libs/dto/src/index.ts"],
"@reactive-resume/hooks": ["libs/hooks/src/index.ts"],
"@reactive-resume/parser": ["libs/parser/src/index.ts"],
"@reactive-resume/schema": ["libs/schema/src/index.ts"],
"@reactive-resume/templates": ["libs/templates/src/index.ts"],
"@reactive-resume/ui": ["libs/ui/src/index.ts"],
"@reactive-resume/utils": ["libs/utils/src/index.ts"]
}