feat(artboard): implement 8 new templates

This commit is contained in:
Amruth Pillai
2023-11-09 21:01:01 +01:00
parent 9acf7e8d22
commit 92bb9f96a0
37 changed files with 5422 additions and 1810 deletions

View File

@ -0,0 +1,506 @@
import {
Award,
Certification,
CustomSection,
CustomSectionGroup,
Education,
Experience,
Interest,
Language,
Profile,
Project,
Publication,
Reference,
SectionKey,
SectionWithItem,
Skill,
URL,
Volunteer,
} from "@reactive-resume/schema";
import { cn, isEmptyString, isUrl, linearTransform } from "@reactive-resume/utils";
import get from "lodash.get";
import React, { Fragment } from "react";
import { Picture } from "../components/picture";
import { useArtboardStore } from "../store/artboard";
import { TemplateProps } from "../types/template";
const Header = () => {
const basics = useArtboardStore((state) => state.resume.basics);
return (
<div className="flex flex-col items-center justify-center space-y-2 pb-2 text-center">
<Picture />
<div>
<div className="text-2xl font-bold">{basics.name}</div>
<div className="text-base">{basics.headline}</div>
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-sm">
{basics.location && (
<div className="flex items-center gap-x-1.5">
<i className="ph ph-bold ph-map-pin text-primary" />
<div>{basics.location}</div>
</div>
)}
{basics.phone && (
<div className="flex items-center gap-x-1.5">
<i className="ph ph-bold ph-phone text-primary" />
<a href={`tel:${basics.phone}`} target="_blank" rel="noreferrer">
{basics.phone}
</a>
</div>
)}
{basics.email && (
<div className="flex items-center gap-x-1.5">
<i className="ph ph-bold ph-at text-primary" />
<a href={`mailto:${basics.email}`} target="_blank" rel="noreferrer">
{basics.email}
</a>
</div>
)}
<Link url={basics.url} />
{basics.customFields.map((item) => (
<div key={item.id} className="flex items-center gap-x-1.5">
<i className={cn(`ph ph-bold ph-${item.icon}`, "text-primary")} />
<span>{[item.name, item.value].filter(Boolean).join(": ")}</span>
</div>
))}
</div>
</div>
);
};
const Summary = () => {
const section = useArtboardStore((state) => state.resume.sections.summary);
if (!section.visible || isEmptyString(section.content)) return null;
return (
<section id={section.id}>
<div className="mb-2 hidden font-bold uppercase text-primary group-[.main]:block">
<h4>{section.name}</h4>
</div>
<div className="mb-2 hidden items-center gap-x-2 text-center font-bold uppercase text-primary group-[.sidebar]:flex">
<div className="h-1.5 w-1.5 rounded-full border border-primary" />
<h4>{section.name}</h4>
<div className="h-1.5 w-1.5 rounded-full border border-primary" />
</div>
<main className={cn("relative space-y-2", "border-l border-primary pl-4")}>
<div className="absolute left-[-4.5px] top-[8px] hidden h-[8px] w-[8px] rounded-full bg-primary group-[.main]:block" />
<div
className="wysiwyg"
style={{ columns: section.columns }}
dangerouslySetInnerHTML={{ __html: section.content }}
/>
</main>
</section>
);
};
type RatingProps = { level: number };
const Rating = ({ level }: RatingProps) => (
<div className="relative h-1 w-[128px] group-[.sidebar]:mx-auto">
<div className="absolute inset-0 h-1 w-[128px] rounded bg-primary opacity-25" />
<div
className="absolute inset-0 h-1 rounded bg-primary"
style={{ width: linearTransform(level, 0, 5, 0, 128) }}
/>
</div>
);
type LinkProps = {
url: URL;
icon?: React.ReactNode;
label?: string;
className?: string;
};
const Link = ({ url, icon, label, className }: LinkProps) => {
if (!isUrl(url.href)) return null;
return (
<div className="flex items-center gap-x-1.5">
{icon ?? <i className="ph ph-bold ph-link text-primary" />}
<a
href={url.href}
target="_blank"
rel="noreferrer noopener nofollow"
className={cn("inline-block", className)}
>
{label || url.label || url.href}
</a>
</div>
);
};
type SectionProps<T> = {
section: SectionWithItem<T> | CustomSectionGroup;
children?: (item: T) => React.ReactNode;
className?: string;
urlKey?: keyof T;
levelKey?: keyof T;
summaryKey?: keyof T;
keywordsKey?: keyof T;
};
const Section = <T,>({
section,
children,
className,
urlKey,
levelKey,
summaryKey,
keywordsKey,
}: SectionProps<T>) => {
if (!section.visible || !section.items.length) return null;
return (
<section id={section.id} className="grid">
<div className="mb-2 hidden font-bold uppercase text-primary group-[.main]:block">
<h4>{section.name}</h4>
</div>
<div className="mx-auto mb-2 hidden items-center gap-x-2 text-center font-bold uppercase text-primary group-[.sidebar]:flex">
<div className="h-1.5 w-1.5 rounded-full border border-primary" />
<h4>{section.name}</h4>
<div className="h-1.5 w-1.5 rounded-full border border-primary" />
</div>
<div
className="grid gap-3 group-[.sidebar]:mx-auto group-[.sidebar]:text-center"
style={{ gridTemplateColumns: `repeat(${section.columns}, 1fr)` }}
>
{section.items
.filter((item) => item.visible)
.map((item) => {
const url = (urlKey && get(item, urlKey)) as URL | undefined;
const level = (levelKey && get(item, levelKey, 0)) as number | undefined;
const summary = (summaryKey && get(item, summaryKey, "")) as string | undefined;
const keywords = (keywordsKey && get(item, keywordsKey, [])) as string[] | undefined;
return (
<div
key={item.id}
className={cn(
"relative space-y-2",
"border-primary group-[.main]:border-l group-[.main]:pl-4",
className,
)}
>
<div className="leading-snug">{children?.(item as T)}</div>
{summary && !isEmptyString(summary) && (
<div className="wysiwyg" dangerouslySetInnerHTML={{ __html: summary }} />
)}
{level && level > 0 && <Rating level={level} />}
{keywords && keywords.length > 0 && (
<p className="text-sm">{keywords.join(", ")}</p>
)}
{url && <Link url={url} />}
<div className="absolute left-[-4.5px] top-px hidden h-[8px] w-[8px] rounded-full bg-primary group-[.main]:block" />
</div>
);
})}
</div>
</section>
);
};
const Profiles = () => {
const section = useArtboardStore((state) => state.resume.sections.profiles);
const fontSize = useArtboardStore((state) => state.resume.metadata.typography.font.size);
return (
<Section<Profile> section={section}>
{(item) => (
<div className="leading-snug">
{isUrl(item.url.href) ? (
<Link
url={item.url}
label={item.username}
icon={
<img
width={fontSize}
height={fontSize}
alt={item.network}
src={`https://cdn.simpleicons.org/${item.icon}`}
/>
}
/>
) : (
<p>{item.username}</p>
)}
<p className="text-sm">{item.network}</p>
</div>
)}
</Section>
);
};
const Experience = () => {
const section = useArtboardStore((state) => state.resume.sections.experience);
return (
<Section<Experience> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div>
<div className="font-bold">{item.company}</div>
<div>{item.position}</div>
<div>{item.location}</div>
<div className="font-bold italic">{item.date}</div>
</div>
)}
</Section>
);
};
const Education = () => {
const section = useArtboardStore((state) => state.resume.sections.education);
return (
<Section<Education> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div>
<div className="font-bold">{item.institution}</div>
<div>{item.area}</div>
<div>{item.score}</div>
<div>{item.studyType}</div>
<div className="font-bold italic">{item.date}</div>
</div>
)}
</Section>
);
};
const Awards = () => {
const section = useArtboardStore((state) => state.resume.sections.awards);
return (
<Section<Award> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div>
<div className="font-bold">{item.title}</div>
<div>{item.awarder}</div>
<div className="font-bold italic">{item.date}</div>
</div>
)}
</Section>
);
};
const Certifications = () => {
const section = useArtboardStore((state) => state.resume.sections.certifications);
return (
<Section<Certification> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div>
<div className="font-bold">{item.name}</div>
<div>{item.issuer}</div>
<div className="font-bold italic">{item.date}</div>
</div>
)}
</Section>
);
};
const Skills = () => {
const section = useArtboardStore((state) => state.resume.sections.skills);
return (
<Section<Skill> section={section} levelKey="level" keywordsKey="keywords">
{(item) => (
<div>
<div className="font-bold">{item.name}</div>
<div>{item.description}</div>
</div>
)}
</Section>
);
};
const Interests = () => {
const section = useArtboardStore((state) => state.resume.sections.interests);
return (
<Section<Interest> section={section} keywordsKey="keywords" className="space-y-0.5">
{(item) => <div className="font-bold">{item.name}</div>}
</Section>
);
};
const Publications = () => {
const section = useArtboardStore((state) => state.resume.sections.publications);
return (
<Section<Publication> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div>
<div className="font-bold">{item.name}</div>
<div>{item.publisher}</div>
<div className="font-bold italic">{item.date}</div>
</div>
)}
</Section>
);
};
const Volunteer = () => {
const section = useArtboardStore((state) => state.resume.sections.volunteer);
return (
<Section<Volunteer> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div>
<div className="font-bold">{item.organization}</div>
<div>{item.position}</div>
<div>{item.location}</div>
<div className="font-bold italic">{item.date}</div>
</div>
)}
</Section>
);
};
const Languages = () => {
const section = useArtboardStore((state) => state.resume.sections.languages);
return (
<Section<Language> section={section} levelKey="fluencyLevel">
{(item) => (
<div>
<div className="font-bold">{item.name}</div>
<div>{item.fluency}</div>
</div>
)}
</Section>
);
};
const Projects = () => {
const section = useArtboardStore((state) => state.resume.sections.projects);
return (
<Section<Project> section={section} urlKey="url" summaryKey="summary" keywordsKey="keywords">
{(item) => (
<div>
<div>
<div className="font-bold">{item.name}</div>
<div>{item.description}</div>
<div className="font-bold italic">{item.date}</div>
</div>
</div>
)}
</Section>
);
};
const References = () => {
const section = useArtboardStore((state) => state.resume.sections.references);
return (
<Section<Reference> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div>
<div className="font-bold">{item.name}</div>
<div>{item.description}</div>
</div>
)}
</Section>
);
};
const Custom = ({ id }: { id: string }) => {
const section = useArtboardStore((state) => state.resume.sections.custom[id]);
return (
<Section<CustomSection>
section={section}
urlKey="url"
summaryKey="summary"
keywordsKey="keywords"
>
{(item) => (
<div>
<div>
<div className="font-bold">{item.name}</div>
<div>{item.description}</div>
<div className="font-bold italic">{item.date}</div>
<div>{item.location}</div>
</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 null;
}
};
export const Azurill = ({ columns, isFirstPage = false }: TemplateProps) => {
const [main, sidebar] = columns;
return (
<div className="space-y-3">
{isFirstPage && <Header />}
<div className="grid grid-cols-3 gap-x-4">
<div className="sidebar group col-span-1 space-y-4">
{sidebar.map((section) => (
<Fragment key={section}>{mapSectionToComponent(section)}</Fragment>
))}
</div>
<div className="main group col-span-2 space-y-4">
{main.map((section) => (
<Fragment key={section}>{mapSectionToComponent(section)}</Fragment>
))}
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,513 @@
import {
Award,
Certification,
CustomSection,
CustomSectionGroup,
Education,
Experience,
Interest,
Language,
Profile,
Project,
Publication,
Reference,
SectionKey,
SectionWithItem,
Skill,
URL,
Volunteer,
} from "@reactive-resume/schema";
import { cn, isEmptyString, isUrl } from "@reactive-resume/utils";
import get from "lodash.get";
import { Fragment } from "react";
import { Picture } from "../components/picture";
import { useArtboardStore } from "../store/artboard";
import { TemplateProps } from "../types/template";
const Header = () => {
const basics = useArtboardStore((state) => state.resume.basics);
return (
<div className="flex flex-col items-center space-y-2 text-center">
<Picture />
<div>
<div className="text-2xl font-bold">{basics.name}</div>
<div className="text-base">{basics.headline}</div>
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm">
{basics.location && (
<div className="flex items-center gap-x-1.5">
<i className="ph ph-bold ph-map-pin text-primary" />
<div>{basics.location}</div>
</div>
)}
{basics.phone && (
<div className="flex items-center gap-x-1.5">
<i className="ph ph-bold ph-phone text-primary" />
<a href={`tel:${basics.phone}`} target="_blank" rel="noreferrer">
{basics.phone}
</a>
</div>
)}
{basics.email && (
<div className="flex items-center gap-x-1.5">
<i className="ph ph-bold ph-at text-primary" />
<a href={`mailto:${basics.email}`} target="_blank" rel="noreferrer">
{basics.email}
</a>
</div>
)}
<Link url={basics.url} />
{basics.customFields.map((item) => (
<div key={item.id} className="flex items-center gap-x-1.5">
<i className={cn(`ph ph-bold ph-${item.icon}`, "text-primary")} />
<span>{[item.name, item.value].filter(Boolean).join(": ")}</span>
</div>
))}
</div>
</div>
);
};
const Summary = () => {
const section = useArtboardStore((state) => state.resume.sections.summary);
if (!section.visible || isEmptyString(section.content)) return null;
return (
<section id={section.id} className="grid grid-cols-5 border-t pt-2.5">
<div className="col-span-1">
<h4 className="text-base font-bold">{section.name}</h4>
</div>
<div
className="wysiwyg col-span-4"
style={{ columns: section.columns }}
dangerouslySetInnerHTML={{ __html: section.content }}
/>
</section>
);
};
type RatingProps = { level: number };
const Rating = ({ level }: RatingProps) => (
<div className="flex items-center gap-x-1.5">
{Array.from({ length: 5 }).map((_, index) => (
<div
key={index}
className={cn("h-2 w-2 rounded-full border border-primary", level > index && "bg-primary")}
/>
))}
</div>
);
type LinkProps = {
url: URL;
icon?: React.ReactNode;
label?: string;
className?: string;
};
const Link = ({ url, icon, label, className }: LinkProps) => {
if (!isUrl(url.href)) return null;
return (
<div className="flex items-center gap-x-1.5">
{icon ?? <i className="ph ph-bold ph-link text-primary" />}
<a
href={url.href}
target="_blank"
rel="noreferrer noopener nofollow"
className={cn("inline-block", className)}
>
{label || url.label || url.href}
</a>
</div>
);
};
type SectionProps<T> = {
section: SectionWithItem<T> | CustomSectionGroup;
children?: (item: T) => React.ReactNode;
className?: string;
urlKey?: keyof T;
levelKey?: keyof T;
summaryKey?: keyof T;
keywordsKey?: keyof T;
};
const Section = <T,>({
section,
children,
className,
urlKey,
levelKey,
summaryKey,
keywordsKey,
}: SectionProps<T>) => {
if (!section.visible || !section.items.length) return null;
return (
<section id={section.id} className="grid grid-cols-5 border-t pt-2.5">
<div className="col-span-1">
<h4 className="text-base font-bold">{section.name}</h4>
</div>
<div
className="col-span-4 grid gap-3"
style={{ gridTemplateColumns: `repeat(${section.columns}, 1fr)` }}
>
{section.items
.filter((item) => item.visible)
.map((item) => {
const url = (urlKey && get(item, urlKey)) as URL | undefined;
const level = (levelKey && get(item, levelKey, 0)) as number | undefined;
const summary = (summaryKey && get(item, summaryKey, "")) as string | undefined;
const keywords = (keywordsKey && get(item, keywordsKey, [])) as string[] | undefined;
return (
<div key={item.id} className={cn("space-y-2", className)}>
<div className="leading-snug">
{children?.(item as T)}
{url && <Link url={url} />}
</div>
{summary && !isEmptyString(summary) && (
<div className="wysiwyg" dangerouslySetInnerHTML={{ __html: summary }} />
)}
{level && level > 0 && <Rating level={level} />}
{keywords && keywords.length > 0 && (
<p className="text-sm">{keywords.join(", ")}</p>
)}
</div>
);
})}
</div>
</section>
);
};
const Profiles = () => {
const section = useArtboardStore((state) => state.resume.sections.profiles);
const fontSize = useArtboardStore((state) => state.resume.metadata.typography.font.size);
return (
<Section<Profile> section={section}>
{(item) => (
<div className="leading-snug">
{isUrl(item.url.href) ? (
<Link
url={item.url}
label={item.username}
icon={
<img
width={fontSize}
height={fontSize}
alt={item.network}
src={`https://cdn.simpleicons.org/${item.icon}`}
/>
}
/>
) : (
<p>{item.username}</p>
)}
<p className="text-sm">{item.network}</p>
</div>
)}
</Section>
);
};
const Experience = () => {
const section = useArtboardStore((state) => state.resume.sections.experience);
return (
<Section<Experience> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div className="flex items-center justify-between">
<div className="text-left">
<div className="font-bold">{item.company}</div>
<div>{item.position}</div>
</div>
<div className="shrink-0 text-right">
<div className="font-bold">{item.date}</div>
<div>{item.location}</div>
</div>
</div>
)}
</Section>
);
};
const Education = () => {
const section = useArtboardStore((state) => state.resume.sections.education);
return (
<Section<Education> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div className="flex items-center justify-between">
<div className="text-left">
<div className="font-bold">{item.institution}</div>
<div>{item.area}</div>
<div>{item.score}</div>
</div>
<div className="shrink-0 text-right">
<div className="font-bold">{item.date}</div>
<div>{item.studyType}</div>
</div>
</div>
)}
</Section>
);
};
const Awards = () => {
const section = useArtboardStore((state) => state.resume.sections.awards);
return (
<Section<Award> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div 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>
</div>
</div>
)}
</Section>
);
};
const Certifications = () => {
const section = useArtboardStore((state) => state.resume.sections.certifications);
return (
<Section<Certification> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div 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>
</div>
</div>
)}
</Section>
);
};
const Skills = () => {
const section = useArtboardStore((state) => state.resume.sections.skills);
return (
<Section<Skill> section={section} levelKey="level" keywordsKey="keywords">
{(item) => (
<div className="space-y-0.5">
<div className="font-bold">{item.name}</div>
<div>{item.description}</div>
</div>
)}
</Section>
);
};
const Interests = () => {
const section = useArtboardStore((state) => state.resume.sections.interests);
return (
<Section<Interest> section={section} keywordsKey="keywords" className="space-y-0.5">
{(item) => <div className="font-bold">{item.name}</div>}
</Section>
);
};
const Publications = () => {
const section = useArtboardStore((state) => state.resume.sections.publications);
return (
<Section<Publication> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div 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>
</div>
</div>
)}
</Section>
);
};
const Volunteer = () => {
const section = useArtboardStore((state) => state.resume.sections.volunteer);
return (
<Section<Volunteer> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div className="flex items-center justify-between">
<div className="text-left">
<div className="font-bold">{item.organization}</div>
<div>{item.position}</div>
</div>
<div className="shrink-0 text-right">
<div className="font-bold">{item.date}</div>
<div>{item.location}</div>
</div>
</div>
)}
</Section>
);
};
const Languages = () => {
const section = useArtboardStore((state) => state.resume.sections.languages);
return (
<Section<Language> section={section} levelKey="fluencyLevel">
{(item) => (
<div className="space-y-0.5">
<div className="font-bold">{item.name}</div>
<div>{item.fluency}</div>
</div>
)}
</Section>
);
};
const Projects = () => {
const section = useArtboardStore((state) => state.resume.sections.projects);
return (
<Section<Project> section={section} urlKey="url" summaryKey="summary" keywordsKey="keywords">
{(item) => (
<div 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>
</div>
</div>
)}
</Section>
);
};
const References = () => {
const section = useArtboardStore((state) => state.resume.sections.references);
return (
<Section<Reference> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div>
<div className="font-bold">{item.name}</div>
<div>{item.description}</div>
</div>
)}
</Section>
);
};
const Custom = ({ id }: { id: string }) => {
const section = useArtboardStore((state) => state.resume.sections.custom[id]);
return (
<Section<CustomSection>
section={section}
urlKey="url"
summaryKey="summary"
keywordsKey="keywords"
>
{(item) => (
<div 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>
<div>{item.location}</div>
</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 null;
}
};
export const Bronzor = ({ columns, isFirstPage = false }: TemplateProps) => {
const [main, sidebar] = columns;
return (
<div className="space-y-4">
{isFirstPage && <Header />}
<div className="space-y-4">
{main.map((section) => (
<Fragment key={section}>{mapSectionToComponent(section)}</Fragment>
))}
{sidebar.map((section) => (
<Fragment key={section}>{mapSectionToComponent(section)}</Fragment>
))}
</div>
</div>
);
};

View File

@ -0,0 +1,516 @@
import {
Award,
Certification,
CustomSection,
CustomSectionGroup,
Education,
Experience,
Interest,
Language,
Project,
Publication,
Reference,
SectionKey,
SectionWithItem,
Skill,
URL,
Volunteer,
} from "@reactive-resume/schema";
import { cn, isEmptyString, isUrl } from "@reactive-resume/utils";
import get from "lodash.get";
import { Fragment } from "react";
import { Picture } from "../components/picture";
import { useArtboardStore } from "../store/artboard";
import { TemplateProps } from "../types/template";
const Header = () => {
const basics = useArtboardStore((state) => state.resume.basics);
const profiles = useArtboardStore((state) => state.resume.sections.profiles);
return (
<div className="grid grid-cols-3">
<div className="col-span-2 flex items-center gap-x-4">
<Picture />
<div className="space-y-2">
<div>
<div className="text-2xl font-bold">{basics.name}</div>
<div className="text-base">{basics.headline}</div>
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm">
{basics.location && (
<div className="flex items-center gap-x-1.5">
<i className="ph ph-bold ph-map-pin text-primary" />
<div>{basics.location}</div>
</div>
)}
{basics.phone && (
<div className="flex items-center gap-x-1.5">
<i className="ph ph-bold ph-phone text-primary" />
<a href={`tel:${basics.phone}`} target="_blank" rel="noreferrer">
{basics.phone}
</a>
</div>
)}
{basics.email && (
<div className="flex items-center gap-x-1.5">
<i className="ph ph-bold ph-at text-primary" />
<a href={`mailto:${basics.email}`} target="_blank" rel="noreferrer">
{basics.email}
</a>
</div>
)}
<Link url={basics.url} />
{basics.customFields.map((item) => (
<div key={item.id} className="flex items-center gap-x-1.5">
<i className={cn(`ph ph-bold ph-${item.icon}`, "text-primary")} />
<span>{[item.name, item.value].filter(Boolean).join(": ")}</span>
</div>
))}
</div>
{profiles.visible && profiles.items.length > 0 && (
<div className="flex items-center gap-x-3 gap-y-0.5">
{profiles.items
.filter((item) => item.visible)
.map((item) => (
<div key={item.id} className="flex items-center gap-x-2">
<Link
url={item.url}
label={item.username}
className="text-sm"
icon={
<img
width="12"
height="12"
alt={item.network}
src={`https://cdn.simpleicons.org/${item.icon}`}
/>
}
/>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
};
const Summary = () => {
const section = useArtboardStore((state) => state.resume.sections.summary);
if (!section.visible || isEmptyString(section.content)) return null;
return (
<section id={section.id}>
<h4 className="mb-2 border-b pb-0.5 text-sm font-bold uppercase">{section.name}</h4>
<div
className="wysiwyg"
style={{ columns: section.columns }}
dangerouslySetInnerHTML={{ __html: section.content }}
/>
</section>
);
};
type RatingProps = { level: number };
const Rating = ({ level }: RatingProps) => (
<div className="flex items-center gap-x-1.5">
{Array.from({ length: 5 }).map((_, index) => (
<div
key={index}
className={cn(
"h-2 w-2 rounded-full border border-primary group-[.sidebar]:border-background",
level > index && "bg-primary group-[.sidebar]:bg-background",
)}
/>
))}
</div>
);
type LinkProps = {
url: URL;
icon?: React.ReactNode;
label?: string;
className?: string;
};
const Link = ({ url, icon, label, className }: LinkProps) => {
if (!isUrl(url.href)) return null;
return (
<div className="flex items-center gap-x-1.5">
{icon ?? <i className="ph ph-bold ph-link text-primary" />}
<a
href={url.href}
target="_blank"
rel="noreferrer noopener nofollow"
className={cn("inline-block", className)}
>
{label || url.label || url.href}
</a>
</div>
);
};
type SectionProps<T> = {
section: SectionWithItem<T> | CustomSectionGroup;
children?: (item: T) => React.ReactNode;
className?: string;
urlKey?: keyof T;
levelKey?: keyof T;
summaryKey?: keyof T;
keywordsKey?: keyof T;
};
const Section = <T,>({
section,
children,
className,
urlKey,
levelKey,
summaryKey,
keywordsKey,
}: SectionProps<T>) => {
if (!section.visible || !section.items.length) return null;
return (
<section id={section.id} className="grid">
<h4 className="mb-2 border-b pb-0.5 text-sm font-bold uppercase">{section.name}</h4>
<div
className="grid gap-3"
style={{ gridTemplateColumns: `repeat(${section.columns}, 1fr)` }}
>
{section.items
.filter((item) => item.visible)
.map((item) => {
const url = (urlKey && get(item, urlKey)) as URL | undefined;
const level = (levelKey && get(item, levelKey, 0)) as number | undefined;
const summary = (summaryKey && get(item, summaryKey, "")) as string | undefined;
const keywords = (keywordsKey && get(item, keywordsKey, [])) as string[] | undefined;
return (
<div key={item.id} className={cn("space-y-2", className)}>
<div className="leading-snug">
{children?.(item as T)}
{url && <Link url={url} />}
</div>
{summary && !isEmptyString(summary) && (
<div className="wysiwyg" dangerouslySetInnerHTML={{ __html: summary }} />
)}
{level && level > 0 && <Rating level={level} />}
{keywords && keywords.length > 0 && (
<p className="text-sm">{keywords.join(", ")}</p>
)}
</div>
);
})}
</div>
</section>
);
};
const Experience = () => {
const section = useArtboardStore((state) => state.resume.sections.experience);
return (
<Section<Experience> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div className="flex items-center justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
<div className="text-left">
<div className="font-bold">{item.company}</div>
<div>{item.position}</div>
</div>
<div className="shrink-0 text-right">
<div className="font-bold">{item.date}</div>
<div>{item.location}</div>
</div>
</div>
)}
</Section>
);
};
const Education = () => {
const section = useArtboardStore((state) => state.resume.sections.education);
return (
<Section<Education> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div className="flex items-center justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
<div className="text-left">
<div className="font-bold">{item.institution}</div>
<div>{item.area}</div>
<div>{item.score}</div>
</div>
<div className="shrink-0 text-right">
<div className="font-bold">{item.date}</div>
<div>{item.studyType}</div>
</div>
</div>
)}
</Section>
);
};
const Awards = () => {
const section = useArtboardStore((state) => state.resume.sections.awards);
return (
<Section<Award> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div className="flex items-center justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
<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>
</div>
</div>
)}
</Section>
);
};
const Certifications = () => {
const section = useArtboardStore((state) => state.resume.sections.certifications);
return (
<Section<Certification> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div className="flex items-center justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
<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>
</div>
</div>
)}
</Section>
);
};
const Skills = () => {
const section = useArtboardStore((state) => state.resume.sections.skills);
return (
<Section<Skill> section={section} levelKey="level" keywordsKey="keywords">
{(item) => (
<div>
<div className="font-bold">{item.name}</div>
<div>{item.description}</div>
</div>
)}
</Section>
);
};
const Interests = () => {
const section = useArtboardStore((state) => state.resume.sections.interests);
return (
<Section<Interest> section={section} keywordsKey="keywords" className="space-y-0.5">
{(item) => <div className="font-bold">{item.name}</div>}
</Section>
);
};
const Publications = () => {
const section = useArtboardStore((state) => state.resume.sections.publications);
return (
<Section<Publication> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div className="flex items-center justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
<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>
</div>
</div>
)}
</Section>
);
};
const Volunteer = () => {
const section = useArtboardStore((state) => state.resume.sections.volunteer);
return (
<Section<Volunteer> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div className="flex items-center justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
<div className="text-left">
<div className="font-bold">{item.organization}</div>
<div>{item.position}</div>
</div>
<div className="shrink-0 text-right">
<div className="font-bold">{item.date}</div>
<div>{item.location}</div>
</div>
</div>
)}
</Section>
);
};
const Languages = () => {
const section = useArtboardStore((state) => state.resume.sections.languages);
return (
<Section<Language> section={section} levelKey="fluencyLevel">
{(item) => (
<div className="space-y-0.5">
<div className="font-bold">{item.name}</div>
<div>{item.fluency}</div>
</div>
)}
</Section>
);
};
const Projects = () => {
const section = useArtboardStore((state) => state.resume.sections.projects);
return (
<Section<Project> section={section} urlKey="url" summaryKey="summary" keywordsKey="keywords">
{(item) => (
<div className="flex items-center justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
<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>
</div>
</div>
)}
</Section>
);
};
const References = () => {
const section = useArtboardStore((state) => state.resume.sections.references);
return (
<Section<Reference> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div>
<div className="font-bold">{item.name}</div>
<div>{item.description}</div>
</div>
)}
</Section>
);
};
const Custom = ({ id }: { id: string }) => {
const section = useArtboardStore((state) => state.resume.sections.custom[id]);
return (
<Section<CustomSection>
section={section}
urlKey="url"
summaryKey="summary"
keywordsKey="keywords"
>
{(item) => (
<div className="flex items-center justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
<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>
<div>{item.location}</div>
</div>
</div>
)}
</Section>
);
};
const mapSectionToComponent = (section: SectionKey) => {
switch (section) {
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 null;
}
};
export const Chikorita = ({ columns, isFirstPage = false }: TemplateProps) => {
const margin = useArtboardStore((state) => state.resume.metadata.page.margin);
const [main, sidebar] = columns;
return (
<div className="space-y-4">
{isFirstPage && <Header />}
<div className="relative z-10 grid grid-cols-3 space-x-8">
<div className="main group col-span-2 space-y-4">
{main.map((section) => (
<Fragment key={section}>{mapSectionToComponent(section)}</Fragment>
))}
</div>
<div className="sidebar group col-span-1 space-y-4 text-background">
{sidebar.map((section) => (
<Fragment key={section}>{mapSectionToComponent(section)}</Fragment>
))}
</div>
</div>
<div className="absolute inset-0 grid grid-cols-3" style={{ top: -margin }}>
<div className="col-span-1 col-start-3 ml-2 bg-primary"></div>
</div>
</div>
);
};

View File

@ -0,0 +1,546 @@
import {
Award,
Certification,
CustomSection,
CustomSectionGroup,
Education,
Experience,
Interest,
Language,
Profile,
Project,
Publication,
Reference,
SectionKey,
SectionWithItem,
Skill,
URL,
Volunteer,
} from "@reactive-resume/schema";
import { cn, isEmptyString, isUrl } from "@reactive-resume/utils";
import get from "lodash.get";
import { Fragment } from "react";
import { Picture } from "../components/picture";
import { useArtboardStore } from "../store/artboard";
import { TemplateProps } from "../types/template";
const Header = () => {
const basics = useArtboardStore((state) => state.resume.basics);
return (
<div className="relative z-20 grid grid-cols-3 space-x-4">
<div className="col-span-1 mx-auto">
<Picture />
</div>
<div className="col-span-2 space-y-0.5 text-background">
<h2 className="min-h-[30px] text-4xl font-bold">{basics.name}</h2>
<p className="min-h-[24px]">{basics.headline}</p>
<div className="text-text !mt-10">
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm">
{basics.location && (
<>
<div className="flex items-center gap-x-1.5">
<i className="ph ph-bold ph-map-pin text-primary" />
<div>{basics.location}</div>
</div>
<div className="bg-text h-1 w-1 rounded-full last:hidden" />
</>
)}
{basics.phone && (
<>
<div className="flex items-center gap-x-1.5">
<i className="ph ph-bold ph-phone text-primary" />
<a href={`tel:${basics.phone}`} target="_blank" rel="noreferrer">
{basics.phone}
</a>
</div>
<div className="bg-text h-1 w-1 rounded-full last:hidden" />
</>
)}
{basics.email && (
<>
<div className="flex items-center gap-x-1.5">
<i className="ph ph-bold ph-at text-primary" />
<a href={`mailto:${basics.email}`} target="_blank" rel="noreferrer">
{basics.email}
</a>
</div>
<div className="bg-text h-1 w-1 rounded-full last:hidden" />
</>
)}
{isUrl(basics.url.href) && (
<>
<Link url={basics.url} />
<div className="bg-text h-1 w-1 rounded-full last:hidden" />
</>
)}
{basics.customFields.map((item) => (
<>
<div key={item.id} className="flex items-center gap-x-1.5">
<i className={cn(`ph ph-bold ph-${item.icon}`, "text-primary")} />
<span>{[item.name, item.value].filter(Boolean).join(": ")}</span>
</div>
<div className="bg-text h-1 w-1 rounded-full last:hidden" />
</>
))}
</div>
</div>
</div>
</div>
);
};
const Summary = () => {
const section = useArtboardStore((state) => state.resume.sections.summary);
if (!section.visible || isEmptyString(section.content)) return null;
return (
<section id={section.id}>
<h4 className="mb-2 text-base font-bold uppercase">{section.name}</h4>
<div
className="wysiwyg"
style={{ columns: section.columns }}
dangerouslySetInnerHTML={{ __html: section.content }}
/>
</section>
);
};
type RatingProps = { level: number };
const Rating = ({ level }: RatingProps) => (
<div className="flex items-center gap-x-1.5">
{Array.from({ length: 5 }).map((_, index) => (
<div
key={index}
className={cn("h-2 w-4 border border-primary", level > index && "bg-primary")}
/>
))}
</div>
);
type LinkProps = {
url: URL;
icon?: React.ReactNode;
label?: string;
className?: string;
};
const Link = ({ url, icon, label, className }: LinkProps) => {
if (!isUrl(url.href)) return null;
return (
<div className="flex items-center gap-x-1.5">
{icon ?? <i className="ph ph-bold ph-link text-primary" />}
<a
href={url.href}
target="_blank"
rel="noreferrer noopener nofollow"
className={cn("inline-block", className)}
>
{label || url.label || url.href}
</a>
</div>
);
};
type SectionProps<T> = {
section: SectionWithItem<T> | CustomSectionGroup;
children?: (item: T) => React.ReactNode;
className?: string;
urlKey?: keyof T;
levelKey?: keyof T;
summaryKey?: keyof T;
keywordsKey?: keyof T;
};
const Section = <T,>({
section,
children,
className,
urlKey,
levelKey,
summaryKey,
keywordsKey,
}: SectionProps<T>) => {
if (!section.visible || !section.items.length) return null;
return (
<section id={section.id} className="grid">
<h4 className="mb-2 text-base font-bold uppercase">{section.name}</h4>
<div
className="grid gap-3"
style={{ gridTemplateColumns: `repeat(${section.columns}, 1fr)` }}
>
{section.items
.filter((item) => item.visible)
.map((item) => {
const url = (urlKey && get(item, urlKey)) as URL | undefined;
const level = (levelKey && get(item, levelKey, 0)) as number | undefined;
const summary = (summaryKey && get(item, summaryKey, "")) as string | undefined;
const keywords = (keywordsKey && get(item, keywordsKey, [])) as string[] | undefined;
return (
<div
key={item.id}
className={cn("relative space-y-2 pl-4 group-[.sidebar]:pl-0", className)}
>
<div className="relative -ml-4 group-[.sidebar]:ml-0">
<div className="pl-4 leading-snug group-[.sidebar]:pl-0">
{children?.(item as T)}
{url && <Link url={url} />}
</div>
<div className="absolute inset-y-0 -left-px border-l-[4px] border-primary group-[.sidebar]:hidden" />
</div>
{summary && !isEmptyString(summary) && (
<div className="wysiwyg" dangerouslySetInnerHTML={{ __html: summary }} />
)}
{level && level > 0 && <Rating level={level} />}
{keywords && keywords.length > 0 && (
<p className="text-sm">{keywords.join(", ")}</p>
)}
<div className="absolute inset-y-0 left-0 border-l border-primary group-[.sidebar]:hidden" />
</div>
);
})}
</div>
</section>
);
};
const Profiles = () => {
const section = useArtboardStore((state) => state.resume.sections.profiles);
const fontSize = useArtboardStore((state) => state.resume.metadata.typography.font.size);
return (
<Section<Profile> section={section}>
{(item) => (
<div className="leading-snug">
{isUrl(item.url.href) ? (
<Link
url={item.url}
label={item.username}
icon={
<img
width={fontSize}
height={fontSize}
alt={item.network}
src={`https://cdn.simpleicons.org/${item.icon}`}
/>
}
/>
) : (
<p>{item.username}</p>
)}
<p className="text-sm">{item.network}</p>
</div>
)}
</Section>
);
};
const Experience = () => {
const section = useArtboardStore((state) => state.resume.sections.experience);
return (
<Section<Experience> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div className="flex items-center justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
<div className="text-left">
<div className="font-bold">{item.company}</div>
<div>{item.position}</div>
</div>
<div className="shrink-0 text-right">
<div className="font-bold">{item.date}</div>
<div>{item.location}</div>
</div>
</div>
)}
</Section>
);
};
const Education = () => {
const section = useArtboardStore((state) => state.resume.sections.education);
return (
<Section<Education> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div className="flex items-center justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
<div className="text-left">
<div className="font-bold">{item.institution}</div>
<div>{item.area}</div>
<div>{item.score}</div>
</div>
<div className="shrink-0 text-right">
<div className="font-bold">{item.date}</div>
<div>{item.studyType}</div>
</div>
</div>
)}
</Section>
);
};
const Awards = () => {
const section = useArtboardStore((state) => state.resume.sections.awards);
return (
<Section<Award> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div className="flex items-center justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
<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>
</div>
</div>
)}
</Section>
);
};
const Certifications = () => {
const section = useArtboardStore((state) => state.resume.sections.certifications);
return (
<Section<Certification> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div className="flex items-center justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
<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>
</div>
</div>
)}
</Section>
);
};
const Skills = () => {
const section = useArtboardStore((state) => state.resume.sections.skills);
return (
<Section<Skill> section={section} levelKey="level" keywordsKey="keywords">
{(item) => (
<div>
<div className="font-bold">{item.name}</div>
<div>{item.description}</div>
</div>
)}
</Section>
);
};
const Interests = () => {
const section = useArtboardStore((state) => state.resume.sections.interests);
return (
<Section<Interest> section={section} className="space-y-0" keywordsKey="keywords">
{(item) => <div className="font-bold">{item.name}</div>}
</Section>
);
};
const Publications = () => {
const section = useArtboardStore((state) => state.resume.sections.publications);
return (
<Section<Publication> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div className="flex items-center justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
<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>
</div>
</div>
)}
</Section>
);
};
const Volunteer = () => {
const section = useArtboardStore((state) => state.resume.sections.volunteer);
return (
<Section<Volunteer> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div className="flex items-center justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
<div className="text-left">
<div className="font-bold">{item.organization}</div>
<div>{item.position}</div>
</div>
<div className="shrink-0 text-right">
<div className="font-bold">{item.date}</div>
<div>{item.location}</div>
</div>
</div>
)}
</Section>
);
};
const Languages = () => {
const section = useArtboardStore((state) => state.resume.sections.languages);
return (
<Section<Language> section={section} levelKey="fluencyLevel">
{(item) => (
<div>
<div className="font-bold">{item.name}</div>
<div>{item.fluency}</div>
</div>
)}
</Section>
);
};
const Projects = () => {
const section = useArtboardStore((state) => state.resume.sections.projects);
return (
<Section<Project> section={section} urlKey="url" summaryKey="summary" keywordsKey="keywords">
{(item) => (
<div className="flex items-center justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
<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>
</div>
</div>
)}
</Section>
);
};
const References = () => {
const section = useArtboardStore((state) => state.resume.sections.references);
return (
<Section<Reference> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div>
<div className="font-bold">{item.name}</div>
<div>{item.description}</div>
</div>
)}
</Section>
);
};
const Custom = ({ id }: { id: string }) => {
const section = useArtboardStore((state) => state.resume.sections.custom[id]);
return (
<Section<CustomSection>
section={section}
urlKey="url"
summaryKey="summary"
keywordsKey="keywords"
>
{(item) => (
<div className="flex items-center justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
<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>
<div>{item.location}</div>
</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 null;
}
};
export const Ditto = ({ columns, isFirstPage = false }: TemplateProps) => {
const [main, sidebar] = columns;
return (
<div className="space-y-4">
{isFirstPage && <div className="absolute inset-x-0 top-0 z-10 h-32 bg-primary" />}
{isFirstPage && <Header />}
<div className="grid grid-cols-3 space-x-4">
<div className="sidebar group col-span-1 space-y-4">
{sidebar.map((section) => (
<Fragment key={section}>{mapSectionToComponent(section)}</Fragment>
))}
</div>
<div className="main group col-span-2 space-y-4">
{main.map((section) => (
<Fragment key={section}>{mapSectionToComponent(section)}</Fragment>
))}
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,33 @@
import { TemplateKey } from "@reactive-resume/utils";
import { Azurill } from "./azurill";
import { Bronzor } from "./bronzor";
import { Chikorita } from "./chikorita";
import { Ditto } from "./ditto";
import { Kakuna } from "./kakuna";
import { Onyx } from "./onyx";
import { Pikachu } from "./pikachu";
import { Rhyhorn } from "./rhyhorn";
export const getTemplate = (template: TemplateKey) => {
switch (template) {
case "onyx":
return Onyx;
case "kakuna":
return Kakuna;
case "rhyhorn":
return Rhyhorn;
case "azurill":
return Azurill;
case "ditto":
return Ditto;
case "chikorita":
return Chikorita;
case "bronzor":
return Bronzor;
case "pikachu":
return Pikachu;
default:
return Onyx;
}
};

View File

@ -0,0 +1,467 @@
import {
Award,
Certification,
CustomSection,
CustomSectionGroup,
Education,
Experience,
Interest,
Language,
Project,
Publication,
Reference,
SectionKey,
SectionWithItem,
Skill,
URL,
Volunteer,
} from "@reactive-resume/schema";
import { cn, isEmptyString, isUrl } from "@reactive-resume/utils";
import get from "lodash.get";
import React, { Fragment } from "react";
import { Picture } from "../components/picture";
import { useArtboardStore } from "../store/artboard";
import { TemplateProps } from "../types/template";
const Header = () => {
const basics = useArtboardStore((state) => state.resume.basics);
const profiles = useArtboardStore((state) => state.resume.sections.profiles);
return (
<div className="flex flex-col items-center justify-center space-y-2 pb-2 text-center">
<Picture />
<div>
<div className="text-2xl font-bold">{basics.name}</div>
<div className="text-base">{basics.headline}</div>
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-sm">
{basics.location && (
<div className="flex items-center gap-x-1.5">
<i className="ph ph-bold ph-map-pin text-primary" />
<div>{basics.location}</div>
</div>
)}
{basics.phone && (
<div className="flex items-center gap-x-1.5">
<i className="ph ph-bold ph-phone text-primary" />
<a href={`tel:${basics.phone}`} target="_blank" rel="noreferrer">
{basics.phone}
</a>
</div>
)}
{basics.email && (
<div className="flex items-center gap-x-1.5">
<i className="ph ph-bold ph-at text-primary" />
<a href={`mailto:${basics.email}`} target="_blank" rel="noreferrer">
{basics.email}
</a>
</div>
)}
<Link url={basics.url} />
{basics.customFields.map((item) => (
<div key={item.id} className="flex items-center gap-x-1.5">
<i className={cn(`ph ph-bold ph-${item.icon}`, "text-primary")} />
<span>{[item.name, item.value].filter(Boolean).join(": ")}</span>
</div>
))}
</div>
{profiles.visible && profiles.items.length > 0 && (
<div className="flex items-center gap-x-3 gap-y-0.5">
{profiles.items
.filter((item) => item.visible)
.map((item) => (
<div key={item.id} className="flex items-center gap-x-2">
<Link
url={item.url}
label={item.username}
className="text-sm"
icon={
<img
width="12"
height="12"
alt={item.network}
src={`https://cdn.simpleicons.org/${item.icon}`}
/>
}
/>
</div>
))}
</div>
)}
</div>
);
};
const Summary = () => {
const section = useArtboardStore((state) => state.resume.sections.summary);
if (!section.visible || isEmptyString(section.content)) return null;
return (
<section id={section.id}>
<h4 className="mb-2 border-b border-primary text-center font-bold uppercase text-primary">
{section.name}
</h4>
<div
className="wysiwyg"
style={{ columns: section.columns }}
dangerouslySetInnerHTML={{ __html: section.content }}
/>
</section>
);
};
type RatingProps = { level: number };
const Rating = ({ level }: RatingProps) => (
<div className="flex items-center gap-x-1.5">
{Array.from({ length: 5 }).map((_, index) => (
<div
key={index}
className={cn("h-3 w-5 rounded border-2 border-primary", level > index && "bg-primary")}
/>
))}
</div>
);
type LinkProps = {
url: URL;
icon?: React.ReactNode;
label?: string;
className?: string;
};
const Link = ({ url, icon, label, className }: LinkProps) => {
if (!isUrl(url.href)) return null;
return (
<div className="flex items-center gap-x-1.5">
{icon ?? <i className="ph ph-bold ph-link text-primary" />}
<a
href={url.href}
target="_blank"
rel="noreferrer noopener nofollow"
className={cn("inline-block", className)}
>
{label || url.label || url.href}
</a>
</div>
);
};
type SectionProps<T> = {
section: SectionWithItem<T> | CustomSectionGroup;
children?: (item: T) => React.ReactNode;
className?: string;
urlKey?: keyof T;
levelKey?: keyof T;
summaryKey?: keyof T;
keywordsKey?: keyof T;
};
const Section = <T,>({
section,
children,
className,
urlKey,
levelKey,
summaryKey,
keywordsKey,
}: SectionProps<T>) => {
if (!section.visible || !section.items.length) return null;
return (
<section id={section.id} className="grid">
<h4 className="mb-2 border-b border-primary text-center font-bold uppercase text-primary">
{section.name}
</h4>
<div
className="grid gap-3"
style={{ gridTemplateColumns: `repeat(${section.columns}, 1fr)` }}
>
{section.items
.filter((item) => item.visible)
.map((item) => {
const url = (urlKey && get(item, urlKey)) as URL | undefined;
const level = (levelKey && get(item, levelKey, 0)) as number | undefined;
const summary = (summaryKey && get(item, summaryKey, "")) as string | undefined;
const keywords = (keywordsKey && get(item, keywordsKey, [])) as string[] | undefined;
return (
<div key={item.id} className={cn("space-y-2", className)}>
<div className="leading-snug">{children?.(item as T)}</div>
{summary && !isEmptyString(summary) && (
<div className="wysiwyg" dangerouslySetInnerHTML={{ __html: summary }} />
)}
{level && level > 0 && <Rating level={level} />}
{keywords && keywords.length > 0 && (
<p className="text-sm">{keywords.join(", ")}</p>
)}
{url && <Link url={url} />}
</div>
);
})}
</div>
</section>
);
};
const Experience = () => {
const section = useArtboardStore((state) => state.resume.sections.experience);
return (
<Section<Experience> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div>
<div className="font-bold">{item.company}</div>
<div>{item.position}</div>
<div>{item.location}</div>
<div className="font-bold italic">{item.date}</div>
</div>
)}
</Section>
);
};
const Education = () => {
const section = useArtboardStore((state) => state.resume.sections.education);
return (
<Section<Education> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div>
<div className="font-bold">{item.institution}</div>
<div>{item.area}</div>
<div>{item.score}</div>
<div>{item.studyType}</div>
<div className="font-bold italic">{item.date}</div>
</div>
)}
</Section>
);
};
const Awards = () => {
const section = useArtboardStore((state) => state.resume.sections.awards);
return (
<Section<Award> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div>
<div className="font-bold">{item.title}</div>
<div>{item.awarder}</div>
<div className="font-bold italic">{item.date}</div>
</div>
)}
</Section>
);
};
const Certifications = () => {
const section = useArtboardStore((state) => state.resume.sections.certifications);
return (
<Section<Certification> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div>
<div className="font-bold">{item.name}</div>
<div>{item.issuer}</div>
<div className="font-bold italic">{item.date}</div>
</div>
)}
</Section>
);
};
const Skills = () => {
const section = useArtboardStore((state) => state.resume.sections.skills);
return (
<Section<Skill> section={section} levelKey="level" keywordsKey="keywords">
{(item) => (
<div>
<div className="font-bold">{item.name}</div>
<div>{item.description}</div>
</div>
)}
</Section>
);
};
const Interests = () => {
const section = useArtboardStore((state) => state.resume.sections.interests);
return (
<Section<Interest> section={section} keywordsKey="keywords" className="space-y-0.5">
{(item) => <div className="font-bold">{item.name}</div>}
</Section>
);
};
const Publications = () => {
const section = useArtboardStore((state) => state.resume.sections.publications);
return (
<Section<Publication> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div>
<div className="font-bold">{item.name}</div>
<div>{item.publisher}</div>
<div className="font-bold italic">{item.date}</div>
</div>
)}
</Section>
);
};
const Volunteer = () => {
const section = useArtboardStore((state) => state.resume.sections.volunteer);
return (
<Section<Volunteer> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div>
<div className="font-bold">{item.organization}</div>
<div>{item.position}</div>
<div>{item.location}</div>
<div className="font-bold italic">{item.date}</div>
</div>
)}
</Section>
);
};
const Languages = () => {
const section = useArtboardStore((state) => state.resume.sections.languages);
return (
<Section<Language> section={section} levelKey="fluencyLevel">
{(item) => (
<div>
<div className="font-bold">{item.name}</div>
<div>{item.fluency}</div>
</div>
)}
</Section>
);
};
const Projects = () => {
const section = useArtboardStore((state) => state.resume.sections.projects);
return (
<Section<Project> section={section} urlKey="url" summaryKey="summary" keywordsKey="keywords">
{(item) => (
<div>
<div>
<div className="font-bold">{item.name}</div>
<div>{item.description}</div>
<div className="font-bold italic">{item.date}</div>
</div>
</div>
)}
</Section>
);
};
const References = () => {
const section = useArtboardStore((state) => state.resume.sections.references);
return (
<Section<Reference> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div>
<div className="font-bold">{item.name}</div>
<div>{item.description}</div>
</div>
)}
</Section>
);
};
const Custom = ({ id }: { id: string }) => {
const section = useArtboardStore((state) => state.resume.sections.custom[id]);
return (
<Section<CustomSection>
section={section}
urlKey="url"
summaryKey="summary"
keywordsKey="keywords"
>
{(item) => (
<div>
<div>
<div className="font-bold">{item.name}</div>
<div>{item.description}</div>
<div>{item.location}</div>
<div className="font-bold italic">{item.date}</div>
</div>
</div>
)}
</Section>
);
};
const mapSectionToComponent = (section: SectionKey) => {
switch (section) {
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 null;
}
};
export const Kakuna = ({ columns, isFirstPage = false }: TemplateProps) => {
const [main, sidebar] = columns;
return (
<div className="space-y-4">
{isFirstPage && <Header />}
<div className="space-y-4">
{main.map((section) => (
<Fragment key={section}>{mapSectionToComponent(section)}</Fragment>
))}
{sidebar.map((section) => (
<Fragment key={section}>{mapSectionToComponent(section)}</Fragment>
))}
</div>
</div>
);
};

View File

@ -0,0 +1,505 @@
import {
Award,
Certification,
CustomSection,
CustomSectionGroup,
Education,
Experience,
Interest,
Language,
Project,
Publication,
Reference,
SectionKey,
SectionWithItem,
Skill,
URL,
Volunteer,
} from "@reactive-resume/schema";
import { cn, isEmptyString, isUrl } from "@reactive-resume/utils";
import get from "lodash.get";
import React, { Fragment } from "react";
import { Picture } from "../components/picture";
import { useArtboardStore } from "../store/artboard";
import { TemplateProps } from "../types/template";
const Header = () => {
const basics = useArtboardStore((state) => state.resume.basics);
const profiles = useArtboardStore((state) => state.resume.sections.profiles);
return (
<div className="flex items-center justify-between space-x-4 border-b border-primary pb-5">
<Picture />
<div className="flex-1 space-y-2">
<div>
<div className="text-2xl font-bold">{basics.name}</div>
<div className="text-base">{basics.headline}</div>
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm">
{basics.location && (
<div className="flex items-center gap-x-1.5">
<i className="ph ph-bold ph-map-pin text-primary" />
<div>{basics.location}</div>
</div>
)}
{basics.phone && (
<div className="flex items-center gap-x-1.5">
<i className="ph ph-bold ph-phone text-primary" />
<a href={`tel:${basics.phone}`} target="_blank" rel="noreferrer">
{basics.phone}
</a>
</div>
)}
{basics.email && (
<div className="flex items-center gap-x-1.5">
<i className="ph ph-bold ph-at text-primary" />
<a href={`mailto:${basics.email}`} target="_blank" rel="noreferrer">
{basics.email}
</a>
</div>
)}
<Link url={basics.url} />
{basics.customFields.map((item) => (
<div key={item.id} className="flex items-center gap-x-1.5">
<i className={cn(`ph ph-bold ph-${item.icon}`, "text-primary")} />
<span>{[item.name, item.value].filter(Boolean).join(": ")}</span>
</div>
))}
</div>
</div>
{profiles.visible && profiles.items.length > 0 && (
<div
className="grid gap-x-4 gap-y-1 self-end text-right"
style={{ gridTemplateColumns: `repeat(${profiles.columns}, auto)` }}
>
{profiles.items
.filter((item) => item.visible)
.map((item) => (
<div key={item.id} className="flex items-center gap-x-2">
<Link
url={item.url}
label={item.username}
className="text-sm"
icon={
<img
width="12"
height="12"
alt={item.network}
src={`https://cdn.simpleicons.org/${item.icon}`}
/>
}
/>
</div>
))}
</div>
)}
</div>
);
};
const Summary = () => {
const section = useArtboardStore((state) => state.resume.sections.summary);
if (!section.visible || isEmptyString(section.content)) return null;
return (
<section id={section.id}>
<h4 className="mb-1 font-bold uppercase text-primary">{section.name}</h4>
<div
className="wysiwyg"
style={{ columns: section.columns }}
dangerouslySetInnerHTML={{ __html: section.content }}
/>
</section>
);
};
type RatingProps = { level: number };
const Rating = ({ level }: RatingProps) => (
<div className="flex items-center gap-x-1.5">
{Array.from({ length: 5 }).map((_, index) => (
<div
key={index}
className={cn("h-3 w-3 rounded border-2 border-primary", level > index && "bg-primary")}
/>
))}
</div>
);
type LinkProps = {
url: URL;
icon?: React.ReactNode;
label?: string;
className?: string;
};
const Link = ({ url, icon, label, className }: LinkProps) => {
if (!isUrl(url.href)) return null;
return (
<div className="flex items-center gap-x-1.5">
{icon ?? <i className="ph ph-bold ph-link text-primary" />}
<a
href={url.href}
target="_blank"
rel="noreferrer noopener nofollow"
className={cn("inline-block", className)}
>
{label || url.label || url.href}
</a>
</div>
);
};
type SectionProps<T> = {
section: SectionWithItem<T> | CustomSectionGroup;
children?: (item: T) => React.ReactNode;
className?: string;
urlKey?: keyof T;
levelKey?: keyof T;
summaryKey?: keyof T;
keywordsKey?: keyof T;
};
const Section = <T,>({
section,
children,
className,
urlKey,
levelKey,
summaryKey,
keywordsKey,
}: SectionProps<T>) => {
if (!section.visible || !section.items.length) return null;
return (
<section id={section.id} className="grid">
<h4 className="mb-1 font-bold uppercase text-primary">{section.name}</h4>
<div
className="grid gap-3"
style={{ gridTemplateColumns: `repeat(${section.columns}, 1fr)` }}
>
{section.items
.filter((item) => item.visible)
.map((item) => {
const url = (urlKey && get(item, urlKey)) as URL | undefined;
const level = (levelKey && get(item, levelKey, 0)) as number | undefined;
const summary = (summaryKey && get(item, summaryKey, "")) as string | undefined;
const keywords = (keywordsKey && get(item, keywordsKey, [])) as string[] | undefined;
return (
<div key={item.id} className={cn("space-y-2", className)}>
<div className="leading-snug">
{children?.(item as T)}
{url && <Link url={url} />}
</div>
{summary && !isEmptyString(summary) && (
<div className="wysiwyg" dangerouslySetInnerHTML={{ __html: summary }} />
)}
{level && level > 0 && <Rating level={level} />}
{keywords && keywords.length > 0 && (
<p className="text-sm">{keywords.join(", ")}</p>
)}
</div>
);
})}
</div>
</section>
);
};
const Experience = () => {
const section = useArtboardStore((state) => state.resume.sections.experience);
return (
<Section<Experience> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div className="flex items-center justify-between">
<div className="text-left">
<div className="font-bold">{item.company}</div>
<div>{item.position}</div>
</div>
<div className="shrink-0 text-right">
<div className="font-bold">{item.date}</div>
<div>{item.location}</div>
</div>
</div>
)}
</Section>
);
};
const Education = () => {
const section = useArtboardStore((state) => state.resume.sections.education);
return (
<Section<Education> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div className="flex items-center justify-between">
<div className="text-left">
<div className="font-bold">{item.institution}</div>
<div>{item.area}</div>
<div>{item.score}</div>
</div>
<div className="shrink-0 text-right">
<div className="font-bold">{item.date}</div>
<div>{item.studyType}</div>
</div>
</div>
)}
</Section>
);
};
const Awards = () => {
const section = useArtboardStore((state) => state.resume.sections.awards);
return (
<Section<Award> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div 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>
</div>
</div>
)}
</Section>
);
};
const Certifications = () => {
const section = useArtboardStore((state) => state.resume.sections.certifications);
return (
<Section<Certification> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div 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>
</div>
</div>
)}
</Section>
);
};
const Skills = () => {
const section = useArtboardStore((state) => state.resume.sections.skills);
return (
<Section<Skill> section={section} levelKey="level" keywordsKey="keywords">
{(item) => (
<div>
<div className="font-bold">{item.name}</div>
<div>{item.description}</div>
</div>
)}
</Section>
);
};
const Interests = () => {
const section = useArtboardStore((state) => state.resume.sections.interests);
return (
<Section<Interest> section={section} keywordsKey="keywords" className="space-y-0.5">
{(item) => <div className="font-bold">{item.name}</div>}
</Section>
);
};
const Publications = () => {
const section = useArtboardStore((state) => state.resume.sections.publications);
return (
<Section<Publication> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div 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>
</div>
</div>
)}
</Section>
);
};
const Volunteer = () => {
const section = useArtboardStore((state) => state.resume.sections.volunteer);
return (
<Section<Volunteer> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div className="flex items-center justify-between">
<div className="text-left">
<div className="font-bold">{item.organization}</div>
<div>{item.position}</div>
</div>
<div className="shrink-0 text-right">
<div className="font-bold">{item.date}</div>
<div>{item.location}</div>
</div>
</div>
)}
</Section>
);
};
const Languages = () => {
const section = useArtboardStore((state) => state.resume.sections.languages);
return (
<Section<Language> section={section} levelKey="fluencyLevel">
{(item) => (
<div className="space-y-0.5">
<div className="font-bold">{item.name}</div>
<div>{item.fluency}</div>
</div>
)}
</Section>
);
};
const Projects = () => {
const section = useArtboardStore((state) => state.resume.sections.projects);
return (
<Section<Project> section={section} urlKey="url" summaryKey="summary" keywordsKey="keywords">
{(item) => (
<div 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>
</div>
</div>
)}
</Section>
);
};
const References = () => {
const section = useArtboardStore((state) => state.resume.sections.references);
return (
<Section<Reference> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div>
<div className="font-bold">{item.name}</div>
<div>{item.description}</div>
</div>
)}
</Section>
);
};
const Custom = ({ id }: { id: string }) => {
const section = useArtboardStore((state) => state.resume.sections.custom[id]);
return (
<Section<CustomSection>
section={section}
urlKey="url"
summaryKey="summary"
keywordsKey="keywords"
>
{(item) => (
<div 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>
<div>{item.location}</div>
</div>
</div>
)}
</Section>
);
};
const mapSectionToComponent = (section: SectionKey) => {
switch (section) {
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 null;
}
};
export const Onyx = ({ columns, isFirstPage = false }: TemplateProps) => {
const [main, sidebar] = columns;
return (
<div className="space-y-4">
{isFirstPage && <Header />}
<div className="space-y-4">
{main.map((section) => (
<Fragment key={section}>{mapSectionToComponent(section)}</Fragment>
))}
{sidebar.map((section) => (
<Fragment key={section}>{mapSectionToComponent(section)}</Fragment>
))}
</div>
</div>
);
};

View File

@ -0,0 +1,546 @@
import {
Award,
Certification,
CustomSection,
CustomSectionGroup,
Education,
Experience,
Interest,
Language,
Profile,
Project,
Publication,
Reference,
SectionKey,
SectionWithItem,
Skill,
URL,
Volunteer,
} from "@reactive-resume/schema";
import { cn, isEmptyString, isUrl } from "@reactive-resume/utils";
import get from "lodash.get";
import { Fragment } from "react";
import { Picture } from "../components/picture";
import { useArtboardStore } from "../store/artboard";
import { TemplateProps } from "../types/template";
const Header = () => {
const basics = useArtboardStore((state) => state.resume.basics);
const borderRadius = useArtboardStore((state) => state.resume.basics.picture.borderRadius);
return (
<div
className="summary group bg-primary px-6 pb-7 pt-6 text-background"
style={{ borderRadius: `calc(${borderRadius}px - 2px)` }}
>
<div className="col-span-2 space-y-4">
<div className="leading-tight">
<h2 className="text-2xl font-bold">{basics.name}</h2>
<p>{basics.headline}</p>
</div>
<hr className="border-background opacity-50" />
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm">
{basics.location && (
<>
<div className="flex items-center gap-x-1.5">
<i className="ph ph-bold ph-map-pin" />
<div>{basics.location}</div>
</div>
<div className="h-1 w-1 rounded-full bg-background last:hidden" />
</>
)}
{basics.phone && (
<>
<div className="flex items-center gap-x-1.5">
<i className="ph ph-bold ph-phone" />
<a href={`tel:${basics.phone}`} target="_blank" rel="noreferrer">
{basics.phone}
</a>
</div>
<div className="h-1 w-1 rounded-full bg-background last:hidden" />
</>
)}
{basics.email && (
<>
<div className="flex items-center gap-x-1.5">
<i className="ph ph-bold ph-at" />
<a href={`mailto:${basics.email}`} target="_blank" rel="noreferrer">
{basics.email}
</a>
</div>
<div className="h-1 w-1 rounded-full bg-background last:hidden" />
</>
)}
{isUrl(basics.url.href) && (
<>
<Link url={basics.url} />
<div className="h-1 w-1 rounded-full bg-background last:hidden" />
</>
)}
{basics.customFields.map((item) => (
<>
<div key={item.id} className="flex items-center gap-x-1.5">
<i className={cn(`ph ph-bold ph-${item.icon}`)} />
<span>{[item.name, item.value].filter(Boolean).join(": ")}</span>
</div>
<div className="h-1 w-1 rounded-full bg-background last:hidden" />
</>
))}
</div>
</div>
</div>
);
};
const Summary = () => {
const section = useArtboardStore((state) => state.resume.sections.summary);
if (!section.visible || isEmptyString(section.content)) return null;
return (
<section id={section.id}>
<h4 className="mb-2 text-base font-bold uppercase">{section.name}</h4>
<div
className="wysiwyg"
style={{ columns: section.columns }}
dangerouslySetInnerHTML={{ __html: section.content }}
/>
</section>
);
};
type RatingProps = { level: number };
const Rating = ({ level }: RatingProps) => (
<div className="flex items-center gap-x-1.5">
{Array.from({ length: 5 }).map((_, index) => (
<i
key={index}
className={cn(
"ph ph-diamond text-primary",
level > index && "ph-fill",
level <= index && "ph-bold",
)}
/>
// <div
// key={index}
// className={cn("h-2 w-4 border border-primary", level > index && "bg-primary")}
// />
))}
</div>
);
type LinkProps = {
url: URL;
icon?: React.ReactNode;
label?: string;
className?: string;
};
const Link = ({ url, icon, label, className }: LinkProps) => {
if (!isUrl(url.href)) return null;
return (
<div className="flex items-center gap-x-1.5">
{icon ?? <i className="ph ph-bold ph-link text-primary group-[.summary]:text-background" />}
<a
href={url.href}
target="_blank"
rel="noreferrer noopener nofollow"
className={cn("inline-block", className)}
>
{label || url.label || url.href}
</a>
</div>
);
};
type SectionProps<T> = {
section: SectionWithItem<T> | CustomSectionGroup;
children?: (item: T) => React.ReactNode;
className?: string;
urlKey?: keyof T;
levelKey?: keyof T;
summaryKey?: keyof T;
keywordsKey?: keyof T;
};
const Section = <T,>({
section,
children,
className,
urlKey,
levelKey,
summaryKey,
keywordsKey,
}: SectionProps<T>) => {
if (!section.visible || !section.items.length) return null;
return (
<section id={section.id} className="grid">
<h4 className="mb-2 border-b border-primary text-base font-bold uppercase">{section.name}</h4>
<div
className="grid gap-3"
style={{ gridTemplateColumns: `repeat(${section.columns}, 1fr)` }}
>
{section.items
.filter((item) => item.visible)
.map((item) => {
const url = (urlKey && get(item, urlKey)) as URL | undefined;
const level = (levelKey && get(item, levelKey, 0)) as number | undefined;
const summary = (summaryKey && get(item, summaryKey, "")) as string | undefined;
const keywords = (keywordsKey && get(item, keywordsKey, [])) as string[] | undefined;
return (
<div key={item.id} className={cn("space-y-2", className)}>
<div className="leading-snug">
{children?.(item as T)}
{url && <Link url={url} />}
</div>
{summary && !isEmptyString(summary) && (
<div className="wysiwyg" dangerouslySetInnerHTML={{ __html: summary }} />
)}
{level && level > 0 && <Rating level={level} />}
{keywords && keywords.length > 0 && (
<p className="text-sm">{keywords.join(", ")}</p>
)}
</div>
);
})}
</div>
</section>
);
};
const Profiles = () => {
const section = useArtboardStore((state) => state.resume.sections.profiles);
const fontSize = useArtboardStore((state) => state.resume.metadata.typography.font.size);
return (
<Section<Profile> section={section}>
{(item) => (
<div className="leading-snug">
{isUrl(item.url.href) ? (
<Link
url={item.url}
label={item.username}
icon={
<img
width={fontSize}
height={fontSize}
alt={item.network}
src={`https://cdn.simpleicons.org/${item.icon}`}
/>
}
/>
) : (
<p>{item.username}</p>
)}
<p className="text-sm">{item.network}</p>
</div>
)}
</Section>
);
};
const Experience = () => {
const section = useArtboardStore((state) => state.resume.sections.experience);
return (
<Section<Experience> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div className="flex items-center justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
<div className="text-left">
<div className="font-bold">{item.company}</div>
<div>{item.position}</div>
</div>
<div className="shrink-0 text-right">
<div className="font-bold">{item.date}</div>
<div>{item.location}</div>
</div>
</div>
)}
</Section>
);
};
const Education = () => {
const section = useArtboardStore((state) => state.resume.sections.education);
return (
<Section<Education> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div className="flex items-center justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
<div className="text-left">
<div className="font-bold">{item.institution}</div>
<div>{item.area}</div>
<div>{item.score}</div>
</div>
<div className="shrink-0 text-right">
<div className="font-bold">{item.date}</div>
<div>{item.studyType}</div>
</div>
</div>
)}
</Section>
);
};
const Awards = () => {
const section = useArtboardStore((state) => state.resume.sections.awards);
return (
<Section<Award> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div className="flex items-center justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
<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>
</div>
</div>
)}
</Section>
);
};
const Certifications = () => {
const section = useArtboardStore((state) => state.resume.sections.certifications);
return (
<Section<Certification> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div className="flex items-center justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
<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>
</div>
</div>
)}
</Section>
);
};
const Skills = () => {
const section = useArtboardStore((state) => state.resume.sections.skills);
return (
<Section<Skill> section={section} levelKey="level" keywordsKey="keywords">
{(item) => (
<div>
<div className="font-bold">{item.name}</div>
<div>{item.description}</div>
</div>
)}
</Section>
);
};
const Interests = () => {
const section = useArtboardStore((state) => state.resume.sections.interests);
return (
<Section<Interest> section={section} className="space-y-0" keywordsKey="keywords">
{(item) => <div className="font-bold">{item.name}</div>}
</Section>
);
};
const Publications = () => {
const section = useArtboardStore((state) => state.resume.sections.publications);
return (
<Section<Publication> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div className="flex items-center justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
<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>
</div>
</div>
)}
</Section>
);
};
const Volunteer = () => {
const section = useArtboardStore((state) => state.resume.sections.volunteer);
return (
<Section<Volunteer> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div className="flex items-center justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
<div className="text-left">
<div className="font-bold">{item.organization}</div>
<div>{item.position}</div>
</div>
<div className="shrink-0 text-right">
<div className="font-bold">{item.date}</div>
<div>{item.location}</div>
</div>
</div>
)}
</Section>
);
};
const Languages = () => {
const section = useArtboardStore((state) => state.resume.sections.languages);
return (
<Section<Language> section={section} levelKey="fluencyLevel">
{(item) => (
<div>
<div className="font-bold">{item.name}</div>
<div>{item.fluency}</div>
</div>
)}
</Section>
);
};
const Projects = () => {
const section = useArtboardStore((state) => state.resume.sections.projects);
return (
<Section<Project> section={section} urlKey="url" summaryKey="summary" keywordsKey="keywords">
{(item) => (
<div className="flex items-center justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
<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>
</div>
</div>
)}
</Section>
);
};
const References = () => {
const section = useArtboardStore((state) => state.resume.sections.references);
return (
<Section<Reference> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div>
<div className="font-bold">{item.name}</div>
<div>{item.description}</div>
</div>
)}
</Section>
);
};
const Custom = ({ id }: { id: string }) => {
const section = useArtboardStore((state) => state.resume.sections.custom[id]);
return (
<Section<CustomSection>
section={section}
urlKey="url"
summaryKey="summary"
keywordsKey="keywords"
>
{(item) => (
<div className="flex items-center justify-between group-[.sidebar]:flex-col group-[.sidebar]:items-start">
<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>
<div>{item.location}</div>
</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 null;
}
};
export const Pikachu = ({ columns, isFirstPage = false }: TemplateProps) => {
const [main, sidebar] = columns;
return (
<div className="space-y-4">
<div className="grid grid-cols-3 space-x-6">
<div className="sidebar group col-span-1 space-y-4">
{isFirstPage && <Picture className="w-full !max-w-none" />}
{sidebar.map((section) => (
<Fragment key={section}>{mapSectionToComponent(section)}</Fragment>
))}
</div>
<div className="main group col-span-2 space-y-4">
{isFirstPage && <Header />}
{main.map((section) => (
<Fragment key={section}>{mapSectionToComponent(section)}</Fragment>
))}
</div>
</div>
</div>
);
};

View File

@ -1,13 +1,30 @@
import { SectionKey } from "@reactive-resume/schema";
import {
Award,
Certification,
CustomSection,
CustomSectionGroup,
Education,
Experience,
Interest,
Language,
Profile,
Project,
Publication,
Reference,
SectionKey,
SectionWithItem,
Skill,
URL,
Volunteer,
} from "@reactive-resume/schema";
import { cn, isEmptyString, isUrl } from "@reactive-resume/utils";
import get from "lodash.get";
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);
@ -16,43 +33,39 @@ const Header = () => {
<Picture />
<div className="space-y-0.5">
<div className="text-2xl font-bold leading-tight">{basics.name}</div>
<div className="text-2xl font-bold">{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 className="flex items-center gap-x-1.5 border-r pr-2 last:border-r-0 last:pr-0">
<i className="ph ph-bold ph-map-pin text-primary" />
<div>{basics.location}</div>
</div>
)}
{basics.phone && (
<div className={cn(fieldDisplay)}>
<i className="ph ph-phone" />
<div className="flex items-center gap-x-1.5 border-r pr-2 last:border-r-0 last:pr-0">
<i className="ph ph-bold ph-phone text-primary" />
<a href={`tel:${basics.phone}`} target="_blank" rel="noreferrer">
{basics.phone}
</a>
</div>
)}
{basics.email && (
<div className={cn(fieldDisplay)}>
<i className="ph ph-envelope" />
<div className="flex items-center gap-x-1.5 border-r pr-2 last:border-r-0 last:pr-0">
<i className="ph ph-bold ph-at text-primary" />
<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>
)}
<Link url={basics.url} />
{basics.customFields.map((item) => (
<div key={item.id} className={cn(fieldDisplay)}>
<i className={`ph ph-${item.icon}`} />
<div
key={item.id}
className="flex items-center gap-x-1.5 border-r pr-2 last:border-r-0 last:pr-0"
>
<i className={cn(`ph ph-bold ph-${item.icon}`, "text-primary")} />
<span>{[item.name, item.value].filter(Boolean).join(": ")}</span>
</div>
))}
@ -62,580 +75,384 @@ const Header = () => {
);
};
const sectionHeading = cn("mb-1.5 mt-3 border-b pb-0.5 text-sm font-bold uppercase");
const Summary = () => {
const section = useArtboardStore((state) => state.resume.sections.summary);
const Profiles = () => {
const section = useArtboardStore((state) => state.resume.sections.profiles);
if (!section.visible || !section.items.length) return null;
if (!section.visible || isEmptyString(section.content)) return null;
return (
<section id={section.id}>
<h4 className={cn(sectionHeading)}>{section.name}</h4>
<h4 className="mb-2 border-b pb-0.5 text-sm font-bold uppercase">{section.name}</h4>
<div
className="mt-2 grid items-center"
className="wysiwyg"
style={{ columns: section.columns }}
dangerouslySetInnerHTML={{ __html: section.content }}
/>
</section>
);
};
type RatingProps = { level: number };
const Rating = ({ level }: RatingProps) => (
<div className="flex items-center gap-x-1.5">
{Array.from({ length: 5 }).map((_, index) => (
<div
key={index}
className={cn("h-2 w-2 rounded-full border border-primary", level > index && "bg-primary")}
/>
))}
</div>
);
type LinkProps = {
url: URL;
icon?: React.ReactNode;
label?: string;
className?: string;
};
const Link = ({ url, icon, label, className }: LinkProps) => {
if (!isUrl(url.href)) return null;
return (
<div className="flex items-center gap-x-1.5">
{icon ?? <i className="ph ph-bold ph-link text-primary" />}
<a
href={url.href}
target="_blank"
rel="noreferrer noopener nofollow"
className={cn("inline-block", className)}
>
{label || url.label || url.href}
</a>
</div>
);
};
type SectionProps<T> = {
section: SectionWithItem<T> | CustomSectionGroup;
children?: (item: T) => React.ReactNode;
className?: string;
urlKey?: keyof T;
levelKey?: keyof T;
summaryKey?: keyof T;
keywordsKey?: keyof T;
};
const Section = <T,>({
section,
children,
className,
urlKey,
levelKey,
summaryKey,
keywordsKey,
}: SectionProps<T>) => {
if (!section.visible || !section.items.length) return null;
return (
<section id={section.id} className="grid">
<h4 className="mb-2 border-b pb-0.5 text-sm font-bold uppercase">{section.name}</h4>
<div
className="grid gap-3"
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}`}
/>
{section.items
.filter((item) => item.visible)
.map((item) => {
const url = (urlKey && get(item, urlKey)) as URL | undefined;
const level = (levelKey && get(item, levelKey, 0)) as number | undefined;
const summary = (summaryKey && get(item, summaryKey, "")) as string | undefined;
const keywords = (keywordsKey && get(item, keywordsKey, [])) as string[] | undefined;
<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>
)}
return (
<div key={item.id} className={cn("space-y-2", className)}>
<div className="leading-snug">
{children?.(item as T)}
{url && <Link url={url} />}
</div>
<p className="text-sm">{item.network}</p>
</div>
</div>
))}
{summary && !isEmptyString(summary) && (
<div className="wysiwyg" dangerouslySetInnerHTML={{ __html: summary }} />
)}
{level && level > 0 && <Rating level={level} />}
{keywords && keywords.length > 0 && (
<p className="text-sm">{keywords.join(", ")}</p>
)}
</div>
);
})}
</div>
</section>
);
};
const Summary = () => {
const section = useArtboardStore((state) => state.resume.sections.summary);
if (!section.visible || !section.content) return null;
const Profiles = () => {
const section = useArtboardStore((state) => state.resume.sections.profiles);
const fontSize = useArtboardStore((state) => state.resume.metadata.typography.font.size);
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<Profile> section={section}>
{(item) => (
<div className="leading-snug">
{isUrl(item.url.href) ? (
<Link
url={item.url}
label={item.username}
icon={
<img
width={fontSize}
height={fontSize}
alt={item.network}
src={`https://cdn.simpleicons.org/${item.icon}`}
/>
}
/>
) : (
<p>{item.username}</p>
)}
<p className="text-sm">{item.network}</p>
</div>
)}
</section>
</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>
)}
<Section<Experience> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div className="flex items-center justify-between">
<div className="text-left">
<div className="font-bold">{item.company}</div>
<div>{item.position}</div>
</div>
))}
</div>
</section>
<div className="shrink-0 text-right">
<div className="font-bold">{item.date}</div>
<div>{item.location}</div>
</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>
)}
<Section<Education> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div className="flex items-center justify-between">
<div className="text-left">
<div className="font-bold">{item.institution}</div>
<div>{item.area}</div>
<div>{item.score}</div>
</div>
))}
</div>
</section>
<div className="shrink-0 text-right">
<div className="font-bold">{item.date}</div>
<div>{item.studyType}</div>
</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>
)}
<Section<Award> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div className="flex items-center justify-between">
<div className="text-left">
<div className="font-bold">{item.title}</div>
<div>{item.awarder}</div>
</div>
))}
</div>
</section>
<div className="shrink-0 text-right">
<div className="font-bold">{item.date}</div>
</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>
)}
<Section<Certification> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div className="flex items-center justify-between">
<div className="text-left">
<div className="font-bold">{item.name}</div>
<div>{item.issuer}</div>
</div>
))}
</div>
</section>
<div className="shrink-0 text-right">
<div className="font-bold">{item.date}</div>
</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>
<Section<Skill> section={section} levelKey="level" keywordsKey="keywords">
{(item) => (
<div className="leading-tight">
<div className="font-bold">{item.name}</div>
<div>{item.description}</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>
<Section<Interest> section={section} keywordsKey="keywords" className="space-y-0.5">
{(item) => <div className="font-bold">{item.name}</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>
)}
<Section<Publication> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div className="flex items-center justify-between">
<div className="text-left">
<div className="font-bold">{item.name}</div>
<div>{item.publisher}</div>
</div>
))}
</div>
</section>
<div className="shrink-0 text-right">
<div className="font-bold">{item.date}</div>
</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>
)}
<Section<Volunteer> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div className="flex items-center justify-between">
<div className="text-left">
<div className="font-bold">{item.organization}</div>
<div>{item.position}</div>
</div>
))}
</div>
</section>
<div className="shrink-0 text-right">
<div className="font-bold">{item.date}</div>
<div>{item.location}</div>
</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>
<Section<Language> section={section} levelKey="fluencyLevel">
{(item) => (
<div className="space-y-0.5">
<div className="font-bold">{item.name}</div>
<div>{item.fluency}</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>
)}
<Section<Project> section={section} urlKey="url" summaryKey="summary" keywordsKey="keywords">
{(item) => (
<div className="flex items-center justify-between">
<div className="text-left">
<div className="font-bold">{item.name}</div>
<div>{item.description}</div>
</div>
))}
</div>
</section>
<div className="shrink-0 text-right">
<div className="font-bold">{item.date}</div>
</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>
<Section<Reference> section={section} urlKey="url" summaryKey="summary">
{(item) => (
<div>
<div className="font-bold">{item.name}</div>
<div>{item.description}</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>
)}
<Section<CustomSection>
section={section}
urlKey="url"
summaryKey="summary"
keywordsKey="keywords"
>
{(item) => (
<div className="flex items-center justify-between">
<div className="text-left">
<div className="font-bold">{item.name}</div>
<div>{item.description}</div>
</div>
))}
</div>
</section>
<div className="shrink-0 text-right">
<div className="font-bold">{item.date}</div>
<div>{item.location}</div>
</div>
</div>
)}
</Section>
);
};
@ -670,7 +487,7 @@ const mapSectionToComponent = (section: SectionKey) => {
default:
if (section.startsWith("custom.")) return <Custom id={section.split(".")[1]} />;
return <p>{section}</p>;
return null;
}
};
@ -681,7 +498,7 @@ export const Rhyhorn = ({ columns, isFirstPage = false }: TemplateProps) => {
<div className="space-y-4">
{isFirstPage && <Header />}
<div className="space-y-2">
<div className="space-y-4">
{main.map((section) => (
<Fragment key={section}>{mapSectionToComponent(section)}</Fragment>
))}