initial commit of v5

This commit is contained in:
Amruth Pillai
2026-01-19 23:31:54 +01:00
parent 55bdfd0067
commit cad390fa13
1132 changed files with 200807 additions and 165288 deletions
@@ -0,0 +1,52 @@
import { useMemo } from "react";
import type z from "zod";
import type { resumeDataSchema } from "@/schema/resume/data";
const pageDimensions = {
a4: {
width: "210mm",
height: "297mm",
},
letter: {
width: "216mm",
height: "279mm",
},
} as const;
type UseCssVariablesProps = Pick<z.infer<typeof resumeDataSchema>, "picture" | "metadata">;
export const useCSSVariables = ({ picture, metadata }: UseCssVariablesProps) => {
const fontWeightStyles = useMemo(() => {
const lowestBodyFontWeight = Math.min(...metadata.typography.body.fontWeights.map(Number));
const lowestHeadingFontWeight = Math.min(...metadata.typography.heading.fontWeights.map(Number));
const highestBodyFontWeight = Math.max(...metadata.typography.body.fontWeights.map(Number));
const highestHeadingFontWeight = Math.max(...metadata.typography.heading.fontWeights.map(Number));
return { lowestBodyFontWeight, lowestHeadingFontWeight, highestBodyFontWeight, highestHeadingFontWeight };
}, [metadata.typography.body.fontWeights, metadata.typography.heading.fontWeights]);
return {
"--picture-border-radius": `${picture.borderRadius}pt`,
"--page-width": pageDimensions[metadata.page.format].width,
"--page-height": pageDimensions[metadata.page.format].height,
"--page-sidebar-width": `${metadata.layout.sidebarWidth}%`,
"--page-text-color": metadata.design.colors.text,
"--page-primary-color": metadata.design.colors.primary,
"--page-background-color": metadata.design.colors.background,
"--page-body-font-family": `'${metadata.typography.body.fontFamily}', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`,
"--page-body-font-weight": fontWeightStyles.lowestBodyFontWeight,
"--page-body-font-weight-bold": fontWeightStyles.highestBodyFontWeight,
"--page-body-font-size": metadata.typography.body.fontSize,
"--page-body-line-height": metadata.typography.body.lineHeight,
"--page-heading-font-family": `'${metadata.typography.heading.fontFamily}', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`,
"--page-heading-font-weight": fontWeightStyles.lowestHeadingFontWeight,
"--page-heading-font-weight-bold": fontWeightStyles.highestHeadingFontWeight,
"--page-heading-font-size": metadata.typography.heading.fontSize,
"--page-heading-line-height": metadata.typography.heading.lineHeight,
"--page-margin-x": `${metadata.page.marginX}pt`,
"--page-margin-y": `${metadata.page.marginY}pt`,
"--page-gap-x": `${metadata.page.gapX}pt`,
"--page-gap-y": `${metadata.page.gapY}pt`,
} as React.CSSProperties;
};
@@ -0,0 +1,41 @@
import { useEffect } from "react";
import type z from "zod";
import webfontlist from "@/components/typography/webfontlist.json";
import type { typographySchema } from "@/schema/resume/data";
export function useWebfonts(typography: z.infer<typeof typographySchema>) {
useEffect(() => {
async function loadFont(family: string, weights: string[]) {
const font = webfontlist.find((font) => font.family === family);
if (!font) return;
type FontUrl = { url: string; weight: string; style: "italic" | "normal" };
const fontUrls: FontUrl[] = [];
for (const weight of weights) {
for (const [fileWeight, url] of Object.entries(font.files)) {
if (weight === fileWeight) {
fontUrls.push({ url, weight, style: "normal" });
}
if (fileWeight === `${weight}italic`) {
fontUrls.push({ url, weight, style: "italic" });
}
}
}
for (const { url, weight, style } of fontUrls) {
const fontFace = new FontFace(family, `url("${url}")`, { style, weight, display: "swap" });
if (!document.fonts.has(fontFace)) document.fonts.add(await fontFace.load());
}
}
const bodyTypography = typography.body;
const headingTypography = typography.heading;
Promise.all([
loadFont(bodyTypography.fontFamily, bodyTypography.fontWeights),
loadFont(headingTypography.fontFamily, headingTypography.fontWeights),
]);
}, [typography]);
}
+69
View File
@@ -0,0 +1,69 @@
.page {
width: var(--page-width);
min-height: var(--page-height);
font-family: var(--page-body-font-family);
font-size: calc(var(--page-body-font-size) * 1pt);
font-weight: var(--page-body-font-weight);
line-height: var(--page-body-line-height);
color: var(--page-text-color);
background-color: var(--page-background-color);
&:not(:first-child) {
content-visibility: auto;
contain-intrinsic-size: var(--page-width) var(--page-height);
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(--page-heading-font-family);
font-weight: var(--page-heading-font-weight);
line-height: var(--page-heading-line-height);
}
h1 {
font-size: calc(var(--page-heading-font-size) * 1.5pt);
}
h2 {
font-size: calc(var(--page-heading-font-size) * 1.25pt);
}
h3 {
font-size: calc(var(--page-heading-font-size) * 1.125pt);
}
h4 {
font-size: calc(var(--page-heading-font-size) * 1pt);
}
h5 {
font-size: calc(var(--page-heading-font-size) * 0.875pt);
}
h6 {
font-size: calc(var(--page-heading-font-size) * 0.75pt);
}
a {
text-decoration: underline;
text-underline-offset: 0.15rem;
}
@media print {
a {
text-decoration: none;
}
}
strong {
font-weight: var(--page-body-font-weight-bold);
}
small {
font-size: calc(var(--page-body-font-size) * 0.9pt);
}
}
+136
View File
@@ -0,0 +1,136 @@
import { Trans } from "@lingui/react/macro";
import { IconContext, type IconProps } from "@phosphor-icons/react";
import { useMemo } from "react";
import { match } from "ts-pattern";
import type { Template } from "@/schema/templates";
import { sanitizeCss } from "@/utils/sanitize";
import { cn } from "@/utils/style";
import { useCSSVariables } from "./hooks/use-css-variables";
import { useWebfonts } from "./hooks/use-webfonts";
import styles from "./preview.module.css";
import { useResumeStore } from "./store/resume";
import { AzurillTemplate } from "./templates/azurill";
import { BronzorTemplate } from "./templates/bronzor";
import { ChikoritaTemplate } from "./templates/chikorita";
import { DitgarTemplate } from "./templates/ditgar";
import { DittoTemplate } from "./templates/ditto";
import { GengarTemplate } from "./templates/gengar";
import { GlalieTemplate } from "./templates/glalie";
import { KakunaTemplate } from "./templates/kakuna";
import { LaprasTemplate } from "./templates/lapras";
import { LeafishTemplate } from "./templates/leafish";
import { OnyxTemplate } from "./templates/onyx";
import { PikachuTemplate } from "./templates/pikachu";
import { RhyhornTemplate } from "./templates/rhyhorn";
export type ExtendedIconProps = IconProps & {
hidden?: boolean;
};
type Props = React.ComponentProps<"div"> & {
pageClassName?: string;
showPageNumbers?: boolean;
};
const CSS_RULE_SPLIT_PATTERN = /\n(?=\s*[.#a-zA-Z])/;
const CSS_SELECTOR_PATTERN = /^([^{]+)(\{)/;
function getTemplateComponent(template: Template) {
return match(template)
.with("azurill", () => AzurillTemplate)
.with("bronzor", () => BronzorTemplate)
.with("chikorita", () => ChikoritaTemplate)
.with("ditto", () => DittoTemplate)
.with("ditgar", () => DitgarTemplate)
.with("gengar", () => GengarTemplate)
.with("glalie", () => GlalieTemplate)
.with("kakuna", () => KakunaTemplate)
.with("lapras", () => LaprasTemplate)
.with("leafish", () => LeafishTemplate)
.with("onyx", () => OnyxTemplate)
.with("pikachu", () => PikachuTemplate)
.with("rhyhorn", () => RhyhornTemplate)
.exhaustive();
}
export const ResumePreview = ({ showPageNumbers, pageClassName, className, ...props }: Props) => {
const picture = useResumeStore((state) => state.resume.data.picture);
const metadata = useResumeStore((state) => state.resume.data.metadata);
useWebfonts(metadata.typography);
const style = useCSSVariables({ picture, metadata });
const totalNumberOfPages = metadata.layout.pages.length;
const iconProps = useMemo<ExtendedIconProps>(() => {
return {
weight: "regular",
hidden: metadata.page.hideIcons,
color: "var(--page-primary-color)",
size: metadata.typography.body.fontSize * 1.5,
} satisfies ExtendedIconProps;
}, [metadata.typography.body.fontSize, metadata.page.hideIcons]);
const scopedCSS = useMemo(() => {
if (!metadata.css.enabled || !metadata.css.value.trim()) return null;
const sanitizedCss = sanitizeCss(metadata.css.value);
const scoped = sanitizedCss
.split(CSS_RULE_SPLIT_PATTERN)
.map((rule) => {
const trimmed = rule.trim();
if (!trimmed || trimmed.startsWith("@")) return trimmed;
return trimmed.replace(CSS_SELECTOR_PATTERN, (_match, selectors, brace) => {
const prefixed = selectors
.split(",")
.map((selector: string) => `.resume-preview-container ${selector.trim()} `)
.join(", ");
return `${prefixed}${brace}`;
});
})
.join("\n");
return scoped;
}, [metadata.css.enabled, metadata.css.value]);
const TemplateComponent = useMemo(() => getTemplateComponent(metadata.template), [metadata.template]);
return (
<IconContext.Provider value={iconProps}>
{/** biome-ignore lint/security/noDangerouslySetInnerHtml: CSS is sanitized with sanitizeCss */}
{scopedCSS && <style dangerouslySetInnerHTML={{ __html: scopedCSS }} />}
<div style={style} className={cn("resume-preview-container", className)} {...props}>
{metadata.layout.pages.map((pageLayout, pageIndex) => {
const pageNumber = pageIndex + 1;
return (
<div key={pageIndex} className="relative">
{showPageNumbers && totalNumberOfPages > 1 && (
<div className="absolute -top-6 left-0">
<span className="font-medium text-foreground text-xs">
<Trans>
Page {pageNumber} of {totalNumberOfPages}
</Trans>
</span>
</div>
)}
<div
className={cn(
`page page-${pageIndex}`,
pageIndex > 0 && "print:break-before-page",
styles.page,
pageClassName,
)}
>
<TemplateComponent pageIndex={pageIndex} pageLayout={pageLayout} />
</div>
</div>
);
})}
</div>
</IconContext.Provider>
);
};
@@ -0,0 +1,135 @@
import { match } from "ts-pattern";
import type { SectionType } from "@/schema/resume/data";
import { AwardsItem } from "./items/awards-item";
import { CertificationsItem } from "./items/certifications-item";
import { EducationItem } from "./items/education-item";
import { ExperienceItem } from "./items/experience-item";
import { InterestsItem } from "./items/interests-item";
import { LanguagesItem } from "./items/languages-item";
import { ProfilesItem } from "./items/profiles-item";
import { ProjectsItem } from "./items/projects-item";
import { PublicationsItem } from "./items/publications-item";
import { ReferencesItem } from "./items/references-item";
import { SkillsItem } from "./items/skills-item";
import { VolunteerItem } from "./items/volunteer-item";
import { PageCustomSection } from "./page-custom";
import { PageSection } from "./page-section";
import { PageSummary } from "./page-summary";
type SectionComponentProps = {
sectionClassName?: string;
itemClassName?: string;
};
export function getSectionComponent(
section: "summary" | SectionType | (string & {}),
{ sectionClassName, itemClassName }: SectionComponentProps = {},
) {
return match(section)
.with("summary", () => {
const SummarySection = ({ id: _id }: { id: string }) => <PageSummary className={sectionClassName} />;
return SummarySection;
})
.with("profiles", () => {
const ProfilesSection = ({ id: _id }: { id: string }) => (
<PageSection type="profiles" className={sectionClassName}>
{(item) => <ProfilesItem {...item} className={itemClassName} />}
</PageSection>
);
return ProfilesSection;
})
.with("experience", () => {
const ExperienceSection = ({ id: _id }: { id: string }) => (
<PageSection type="experience" className={sectionClassName}>
{(item) => <ExperienceItem {...item} className={itemClassName} />}
</PageSection>
);
return ExperienceSection;
})
.with("education", () => {
const EducationSection = ({ id: _id }: { id: string }) => (
<PageSection type="education" className={sectionClassName}>
{(item) => <EducationItem {...item} className={itemClassName} />}
</PageSection>
);
return EducationSection;
})
.with("projects", () => {
const ProjectsSection = ({ id: _id }: { id: string }) => (
<PageSection type="projects" className={sectionClassName}>
{(item) => <ProjectsItem {...item} className={itemClassName} />}
</PageSection>
);
return ProjectsSection;
})
.with("skills", () => {
const SkillsSection = ({ id: _id }: { id: string }) => (
<PageSection type="skills" className={sectionClassName}>
{(item) => <SkillsItem {...item} className={itemClassName} />}
</PageSection>
);
return SkillsSection;
})
.with("languages", () => {
const LanguagesSection = ({ id: _id }: { id: string }) => (
<PageSection type="languages" className={sectionClassName}>
{(item) => <LanguagesItem {...item} className={itemClassName} />}
</PageSection>
);
return LanguagesSection;
})
.with("interests", () => {
const InterestsSection = ({ id: _id }: { id: string }) => (
<PageSection type="interests" className={sectionClassName}>
{(item) => <InterestsItem {...item} className={itemClassName} />}
</PageSection>
);
return InterestsSection;
})
.with("awards", () => {
const AwardsSection = ({ id: _id }: { id: string }) => (
<PageSection type="awards" className={sectionClassName}>
{(item) => <AwardsItem {...item} className={itemClassName} />}
</PageSection>
);
return AwardsSection;
})
.with("certifications", () => {
const CertificationsSection = ({ id: _id }: { id: string }) => (
<PageSection type="certifications" className={sectionClassName}>
{(item) => <CertificationsItem {...item} className={itemClassName} />}
</PageSection>
);
return CertificationsSection;
})
.with("publications", () => {
const PublicationsSection = ({ id: _id }: { id: string }) => (
<PageSection type="publications" className={sectionClassName}>
{(item) => <PublicationsItem {...item} className={itemClassName} />}
</PageSection>
);
return PublicationsSection;
})
.with("volunteer", () => {
const VolunteerSection = ({ id: _id }: { id: string }) => (
<PageSection type="volunteer" className={sectionClassName}>
{(item) => <VolunteerItem {...item} className={itemClassName} />}
</PageSection>
);
return VolunteerSection;
})
.with("references", () => {
const ReferencesSection = ({ id: _id }: { id: string }) => (
<PageSection type="references" className={sectionClassName}>
{(item) => <ReferencesItem {...item} className={itemClassName} />}
</PageSection>
);
return ReferencesSection;
})
.otherwise(() => {
const CustomSection = ({ id }: { id: string }) => (
<PageCustomSection sectionId={id} className={sectionClassName} />
);
return CustomSection;
});
}
@@ -0,0 +1,32 @@
import { TiptapContent } from "@/components/input/rich-input";
import type { SectionItem } from "@/schema/resume/data";
import { cn } from "@/utils/style";
import { PageLink } from "../page-link";
type AwardsItemProps = SectionItem<"awards"> & {
className?: string;
};
export function AwardsItem({ className, ...item }: AwardsItemProps) {
return (
<div className={cn("awards-item", className)}>
<div className="section-item-header awards-item-header">
<div className="flex items-center justify-between">
<p className="section-item-title awards-item-title">
<strong>{item.title}</strong>
</p>
<p className="section-item-metadata text-right">{item.date}</p>
</div>
<div className="flex items-center justify-between">
<p className="section-item-awarder awards-item-awarder">{item.awarder}</p>
</div>
</div>
<div className="section-item-description awards-item-description">
<TiptapContent content={item.description} />
</div>
<div className="section-item-link awards-item-link">
<PageLink {...item.website} label={item.website.label} />
</div>
</div>
);
}
@@ -0,0 +1,32 @@
import { TiptapContent } from "@/components/input/rich-input";
import type { SectionItem } from "@/schema/resume/data";
import { cn } from "@/utils/style";
import { PageLink } from "../page-link";
type CertificationsItemProps = SectionItem<"certifications"> & {
className?: string;
};
export function CertificationsItem({ className, ...item }: CertificationsItemProps) {
return (
<div className={cn("certifications-item", className)}>
<div className="section-item-header certifications-item-header">
<div className="flex items-center justify-between">
<p className="section-item-title certifications-item-title">
<strong>{item.title}</strong>
</p>
<p className="section-item-metadata text-right">{item.date}</p>
</div>
<div className="flex items-center justify-between">
<p className="section-item-issuer certifications-item-issuer">{item.issuer}</p>
</div>
</div>
<div className="section-item-description certifications-item-description">
<TiptapContent content={item.description} />
</div>
<div className="section-item-link certifications-item-link">
<PageLink {...item.website} label={item.website.label} />
</div>
</div>
);
}
@@ -0,0 +1,37 @@
import { TiptapContent } from "@/components/input/rich-input";
import type { SectionItem } from "@/schema/resume/data";
import { cn } from "@/utils/style";
import { PageLink } from "../page-link";
type EducationItemProps = SectionItem<"education"> & {
className?: string;
};
export function EducationItem({ className, ...item }: EducationItemProps) {
return (
<div className={cn("education-item", className)}>
<div className="section-item-header education-item-header mb-2">
<div className="flex items-center justify-between">
<p className="section-item-title education-item-title">
<strong>{item.school}</strong>
</p>
<p className="section-item-metadata education-item-metadata text-right">
{[item.degree, item.grade].filter(Boolean).join(" • ")}
</p>
</div>
<div className="flex items-center justify-between">
<p>{item.area}</p>
<p className="section-item-metadata education-item-metadata text-right">
{[item.location, item.period].filter(Boolean).join(" • ")}
</p>
</div>
</div>
<div className="section-item-description education-item-description">
<TiptapContent content={item.description} />
</div>
<div className="section-item-link education-item-link">
<PageLink {...item.website} label={item.website.label} />
</div>
</div>
);
}
@@ -0,0 +1,33 @@
import { TiptapContent } from "@/components/input/rich-input";
import type { SectionItem } from "@/schema/resume/data";
import { cn } from "@/utils/style";
import { PageLink } from "../page-link";
type ExperienceItemProps = SectionItem<"experience"> & {
className?: string;
};
export function ExperienceItem({ className, ...item }: ExperienceItemProps) {
return (
<div className={cn("experience-item", className)}>
<div className="section-item-header experience-item-header">
<div className="flex items-center justify-between">
<p className="section-item-title experience-item-title">
<strong>{item.company}</strong>
</p>
<p className="section-item-metadata experience-item-metadata text-right">{item.location}</p>
</div>
<div className="flex items-center justify-between">
<p>{item.position}</p>
<p className="section-item-metadata experience-item-metadata text-right">{item.period}</p>
</div>
</div>
<div className="section-item-description experience-item-description">
<TiptapContent content={item.description} />
</div>
<div className="section-item-link experience-item-link">
<PageLink {...item.website} label={item.website.label} />
</div>
</div>
);
}
@@ -0,0 +1,22 @@
import type { SectionItem } from "@/schema/resume/data";
import { cn } from "@/utils/style";
import { PageIcon } from "../page-icon";
type InterestsItemProps = SectionItem<"interests"> & {
className?: string;
};
export function InterestsItem({ className, ...item }: InterestsItemProps) {
return (
<div className={cn("interests-item", className)}>
<div className="section-item-header flex items-center gap-x-1.5">
<PageIcon icon={item.icon} className="section-item-icon interests-item-icon shrink-0" />
<p className="section-item-name interests-item-name">
<strong>{item.name}</strong>
</p>
</div>
<p className="section-item-keywords interests-item-keywords opacity-80">{item.keywords.join(", ")}</p>
</div>
);
}
@@ -0,0 +1,22 @@
import type { SectionItem } from "@/schema/resume/data";
import { cn } from "@/utils/style";
import { PageLevel } from "../page-level";
type LanguagesItemProps = SectionItem<"languages"> & {
className?: string;
};
export function LanguagesItem({ className, ...item }: LanguagesItemProps) {
return (
<div className={cn("languages-item", className)}>
<div className="section-item-header">
<p className="section-item-name languages-item-name">
<strong>{item.language}</strong>
</p>
<p className="section-item-fluency languages-item-fluency opacity-80">{item.fluency}</p>
</div>
<PageLevel level={item.level} className="section-item-level languages-item-level" />
</div>
);
}
@@ -0,0 +1,27 @@
import type { SectionItem } from "@/schema/resume/data";
import { cn } from "@/utils/style";
import { PageIcon } from "../page-icon";
import { PageLink } from "../page-link";
type ProfilesItemProps = SectionItem<"profiles"> & {
className?: string;
};
export function ProfilesItem({ className, ...item }: ProfilesItemProps) {
return (
<div className={cn("profiles-item", className)}>
<div className="section-item-header flex items-center gap-x-1.5">
<PageIcon icon={item.icon} className="section-item-icon profiles-item-icon shrink-0" />
<p className="section-item-network profiles-item-network">
<strong>{item.network}</strong>
</p>
</div>
<PageLink
{...item.website}
label={item.website.label || item.username}
className="section-item-link profiles-item-link"
/>
</div>
);
}
@@ -0,0 +1,29 @@
import { TiptapContent } from "@/components/input/rich-input";
import type { SectionItem } from "@/schema/resume/data";
import { cn } from "@/utils/style";
import { PageLink } from "../page-link";
type ProjectsItemProps = SectionItem<"projects"> & {
className?: string;
};
export function ProjectsItem({ className, ...item }: ProjectsItemProps) {
return (
<div className={cn("projects-item", className)}>
<div className="section-item-header projects-item-header">
<div className="flex items-center justify-between">
<p className="section-item-title projects-item-title">
<strong>{item.name}</strong>
</p>
<p className="section-item-metadata text-right">{item.period}</p>
</div>
</div>
<div className="section-item-description projects-item-description">
<TiptapContent content={item.description} />
</div>
<div className="section-item-link projects-item-link">
<PageLink {...item.website} label={item.website.label} />
</div>
</div>
);
}
@@ -0,0 +1,32 @@
import { TiptapContent } from "@/components/input/rich-input";
import type { SectionItem } from "@/schema/resume/data";
import { cn } from "@/utils/style";
import { PageLink } from "../page-link";
type PublicationsItemProps = SectionItem<"publications"> & {
className?: string;
};
export function PublicationsItem({ className, ...item }: PublicationsItemProps) {
return (
<div className={cn("publications-item", className)}>
<div className="section-item-header publications-item-header">
<div className="flex items-center justify-between">
<p className="section-item-title publications-item-title">
<strong>{item.title}</strong>
</p>
<p className="section-item-metadata text-right">{item.date}</p>
</div>
<div className="flex items-center justify-between">
<p className="section-item-publisher publications-item-publisher">{item.publisher}</p>
</div>
</div>
<div className="section-item-description publications-item-description">
<TiptapContent content={item.description} />
</div>
<div className="section-item-link publications-item-link">
<PageLink {...item.website} label={item.website.label} />
</div>
</div>
);
}
@@ -0,0 +1,20 @@
import { TiptapContent } from "@/components/input/rich-input";
import type { SectionItem } from "@/schema/resume/data";
import { cn } from "@/utils/style";
type ReferencesItemProps = SectionItem<"references"> & {
className?: string;
};
export function ReferencesItem({ className, ...item }: ReferencesItemProps) {
return (
<div className={cn("references-item", className)}>
<p className="section-item-name references-item-name">
<strong>{item.name}</strong>
</p>
<div className="section-item-description references-item-description">
<TiptapContent content={item.description} />
</div>
</div>
);
}
@@ -0,0 +1,28 @@
import type { SectionItem } from "@/schema/resume/data";
import { cn } from "@/utils/style";
import { PageIcon } from "../page-icon";
import { PageLevel } from "../page-level";
type SkillsItemProps = SectionItem<"skills"> & {
className?: string;
};
export function SkillsItem({ className, ...item }: SkillsItemProps) {
return (
<div className={cn("skills-item", className)}>
<div className="section-item-header flex items-center gap-x-1.5">
<PageIcon icon={item.icon} className="section-item-icon skills-item-icon shrink-0" />
<p className="section-item-name skills-item-name">
<strong>{item.name}</strong>
</p>
</div>
<div>
<p className="section-item-proficiency skills-item-proficiency opacity-80">{item.proficiency}</p>
<small className="section-item-keywords skills-item-keywords">{item.keywords.join(", ")}</small>
</div>
<PageLevel level={item.level} className="section-item-level skills-item-level" />
</div>
);
}
@@ -0,0 +1,32 @@
import { TiptapContent } from "@/components/input/rich-input";
import type { SectionItem } from "@/schema/resume/data";
import { cn } from "@/utils/style";
import { PageLink } from "../page-link";
type VolunteerItemProps = SectionItem<"volunteer"> & {
className?: string;
};
export function VolunteerItem({ className, ...item }: VolunteerItemProps) {
return (
<div className={cn("volunteer-item", className)}>
<div className="section-item-header volunteer-item-header">
<div className="flex items-center justify-between">
<p className="section-item-title volunteer-item-title">
<strong>{item.organization}</strong>
</p>
<p className="section-item-metadata text-right">{item.period}</p>
</div>
<div className="flex items-center justify-between">
<p className="section-item-location volunteer-item-location">{item.location}</p>
</div>
</div>
<div className="section-item-description volunteer-item-description">
<TiptapContent content={item.description} />
</div>
<div className="section-item-link volunteer-item-link">
<PageLink {...item.website} label={item.website.label} />
</div>
</div>
);
}
@@ -0,0 +1,34 @@
import { TiptapContent } from "@/components/input/rich-input";
import { cn } from "@/utils/style";
import { useResumeStore } from "../store/resume";
type PageCustomSectionProps = {
sectionId: string;
className?: string;
};
export function PageCustomSection({ sectionId, className }: PageCustomSectionProps) {
const section = useResumeStore((state) =>
state.resume.data.customSections.find((section) => section.id === sectionId),
);
// biome-ignore lint/complexity/noUselessFragments: render empty fragment, instead of null
if (!section) return <></>;
return (
<section
className={cn(
`page-section page-section-custom page-section-${sectionId} wrap-break-word`,
section.hidden && "hidden",
section.content === "" && "hidden",
className,
)}
>
<h6 className="mb-2 text-(--page-primary-color)">{section.title}</h6>
<div className="section-content">
<TiptapContent style={{ columnCount: section.columns }} content={section.content} />
</div>
</section>
);
}
@@ -0,0 +1,17 @@
import { IconContext } from "@phosphor-icons/react";
import { use } from "react";
import { cn } from "@/utils/style";
import type { ExtendedIconProps } from "../preview";
export function PageIcon({ icon, className }: { icon: string; className?: string }) {
const iconContext = use<ExtendedIconProps>(IconContext);
if (!icon || iconContext.hidden) return null;
return (
<i
className={cn("ph", `ph-${icon}`, className)}
style={{ fontSize: `${iconContext.size}px`, color: iconContext.color }}
/>
);
}
@@ -0,0 +1,12 @@
import { LevelDisplay } from "@/components/level/display";
import { useResumeStore } from "../store/resume";
type Props = React.ComponentProps<"div"> & {
level: number;
};
export function PageLevel({ level, className, ...props }: Props) {
const { icon, type } = useResumeStore((state) => state.resume.data.metadata.design.level);
return <LevelDisplay icon={icon} type={type} level={level} className={className} {...props} />;
}
@@ -0,0 +1,17 @@
import { cn } from "@/utils/style";
type Props = {
url: string;
label?: string;
className?: string;
};
export function PageLink({ url, label, className }: Props) {
if (!url) return null;
return (
<a href={url} target="_blank" rel="noopener noreferrer" className={cn("block truncate", className)}>
{label || url}
</a>
);
}
@@ -0,0 +1,31 @@
import { cn } from "@/utils/style";
import { useResumeStore } from "../store/resume";
export function PagePicture({ className, style }: { className?: string; style?: React.CSSProperties }) {
const name = useResumeStore((state) => state.resume.data.basics.name);
const picture = useResumeStore((state) => state.resume.data.picture);
if (picture.url === "") return null;
return (
<div
className={cn("page-picture shrink-0 overflow-hidden", picture.hidden && "hidden", className)}
style={{
maxWidth: `${picture.size}pt`,
maxHeight: `${picture.size}pt`,
aspectRatio: picture.aspectRatio,
borderRadius: `${picture.borderRadius}%`,
border: picture.borderWidth > 0 ? `${picture.borderWidth}pt solid ${picture.borderColor}` : "none",
boxShadow: picture.shadowWidth > 0 ? `0 0 ${picture.shadowWidth}pt 0 ${picture.shadowColor}` : "none",
...style,
}}
>
<img
alt={name}
src={picture.url}
className="size-full object-cover"
style={{ transform: `rotate(${picture.rotation}deg)` }}
/>
</div>
);
}
@@ -0,0 +1,36 @@
import type { SectionItem, SectionType } from "@/schema/resume/data";
import { getSectionTitle } from "@/utils/resume/section";
import { cn } from "@/utils/style";
import { useResumeStore } from "../store/resume";
type PageSectionProps<T extends SectionType> = {
type: T;
className?: string;
children: (item: SectionItem<T>) => React.ReactNode;
};
export function PageSection<T extends SectionType>({ type, className, children }: PageSectionProps<T>) {
const section = useResumeStore((state) => state.resume.data.sections[type]);
const items = section.items.filter((item) => !item.hidden);
if (section.hidden) return null;
if (items.length === 0) return null;
return (
<section className={cn(`page-section page-section-${type}`, className)}>
<h6 className="mb-1 text-(--page-primary-color)">{section.title || getSectionTitle(type)}</h6>
<ul
className="section-content grid gap-x-(--page-gap-x) gap-y-(--page-gap-y)"
style={{ gridTemplateColumns: `repeat(${section.columns}, 1fr)` }}
>
{items.map((item) => (
<li key={item.id} className={cn(`section-item section-item-${type} wrap-break-word *:space-y-1`)}>
{children(item)}
</li>
))}
</ul>
</section>
);
}
@@ -0,0 +1,29 @@
import { TiptapContent } from "@/components/input/rich-input";
import { getSectionTitle } from "@/utils/resume/section";
import { cn } from "@/utils/style";
import { useResumeStore } from "../store/resume";
type PageSummaryProps = {
className?: string;
};
export function PageSummary({ className }: PageSummaryProps) {
const section = useResumeStore((state) => state.resume.data.summary);
return (
<section
className={cn(
"page-section page-section-summary wrap-break-word",
section.hidden && "hidden",
section.content === "" && "hidden",
className,
)}
>
<h6 className="mb-2 text-(--page-primary-color)">{section.title || getSectionTitle("summary")}</h6>
<div className="section-content">
<TiptapContent style={{ columnCount: section.columns }} content={section.content} />
</div>
</section>
);
}
+62
View File
@@ -0,0 +1,62 @@
import { t } from "@lingui/core/macro";
import { debounce } from "es-toolkit";
import type { WritableDraft } from "immer";
import { current } from "immer";
import { toast } from "sonner";
import { immer } from "zustand/middleware/immer";
import { create } from "zustand/react";
import { orpc, type RouterOutput } from "@/integrations/orpc/client";
import type { ResumeData } from "@/schema/resume/data";
type Resume = Omit<RouterOutput["resume"]["getById"], "hasPassword">;
type ResumeStoreState = {
resume: Resume;
isReady: boolean;
};
type ResumeStoreActions = {
initialize: (resume: Resume | null) => void;
updateResumeData: (fn: (draft: WritableDraft<ResumeData>) => void) => void;
};
type ResumeStore = ResumeStoreState & ResumeStoreActions;
const controller = new AbortController();
const signal = controller.signal;
const _syncResume = async (resume: Resume | null) => {
if (!resume) return;
await orpc.resume.update.call({ id: resume.id, data: resume.data }, { signal });
};
const syncResume = debounce(_syncResume, 500, { signal });
let errorToastId: string | number | undefined;
export const useResumeStore = create<ResumeStore>()(
immer((set) => ({
resume: null as unknown as Resume,
isReady: false,
initialize: (resume) => {
set((state) => {
state.resume = resume as Resume;
state.isReady = resume !== null;
});
},
updateResumeData: (fn) => {
set((state) => {
if (!state.resume) return state;
if (state.resume.isLocked) {
errorToastId = toast.error(t`This resume is locked and cannot be updated.`, { id: errorToastId });
return state;
}
fn(state.resume.data as WritableDraft<ResumeData>);
syncResume(current(state.resume));
});
},
})),
);
+123
View File
@@ -0,0 +1,123 @@
import { EnvelopeIcon, GlobeIcon, MapPinIcon, PhoneIcon } from "@phosphor-icons/react";
import { cn } from "@/utils/style";
import { getSectionComponent } from "../shared/get-section-component";
import { PageIcon } from "../shared/page-icon";
import { PageLink } from "../shared/page-link";
import { PagePicture } from "../shared/page-picture";
import { useResumeStore } from "../store/resume";
import type { TemplateProps } from "./types";
const sectionClassName = cn(
// Section in Sidebar Layout
"group-data-[layout=sidebar]:[&_.section-item-header>div]:flex-col",
"group-data-[layout=sidebar]:[&_.section-item-header>div]:items-start",
// Section in Main Layout
"group-data-[layout=main]:[&>.section-content]:relative",
"group-data-[layout=main]:[&>.section-content]:ml-4",
"group-data-[layout=main]:[&>.section-content]:pl-4",
"group-data-[layout=main]:[&>.section-content]:border-l",
"group-data-[layout=main]:[&>.section-content]:border-(--page-primary-color)",
// Timeline Marker in Main Layout
"group-data-[layout=main]:[&>.section-content]:after:content-['']",
"group-data-[layout=main]:[&>.section-content]:after:absolute",
"group-data-[layout=main]:[&>.section-content]:after:top-5",
"group-data-[layout=main]:[&>.section-content]:after:left-0",
"group-data-[layout=main]:[&>.section-content]:after:size-2.5",
"group-data-[layout=main]:[&>.section-content]:after:translate-x-[-50%]",
"group-data-[layout=main]:[&>.section-content]:after:translate-y-[-50%]",
"group-data-[layout=main]:[&>.section-content]:after:rounded-full",
"group-data-[layout=main]:[&>.section-content]:after:border",
"group-data-[layout=main]:[&>.section-content]:after:border-(--page-primary-color)",
"group-data-[layout=main]:[&>.section-content]:after:bg-(--page-background-color)",
);
/**
* Template: Azurill
*/
export function AzurillTemplate({ pageIndex, pageLayout }: TemplateProps) {
const isFirstPage = pageIndex === 0;
const { main, sidebar, fullWidth } = pageLayout;
return (
<div className="template-azurill page-content space-y-(--page-gap-y) px-(--page-margin-x) py-(--page-margin-y) print:p-0">
{isFirstPage && <Header />}
<div className="flex gap-x-(--page-gap-x)">
{!fullWidth && (
<aside
data-layout="sidebar"
className="group page-sidebar w-(--page-sidebar-width) shrink-0 space-y-(--page-gap-y) overflow-x-hidden"
>
{sidebar.map((section) => {
const Component = getSectionComponent(section, { sectionClassName });
return <Component key={section} id={section} />;
})}
</aside>
)}
<main data-layout="main" className="group page-main grow space-y-(--page-gap-y)">
{main.map((section) => {
const Component = getSectionComponent(section, { sectionClassName });
return <Component key={section} id={section} />;
})}
</main>
</div>
</div>
);
}
function Header() {
const basics = useResumeStore((state) => state.resume.data.basics);
return (
<div className="page-header flex flex-col items-center gap-y-2">
<PagePicture />
<div className="page-basics space-y-2 text-center">
<div className="basics-header">
<h2 className="basics-name">{basics.name}</h2>
<p className="basics-headline">{basics.headline}</p>
</div>
<div className="basics-items flex flex-wrap justify-center gap-x-3 gap-y-1 *:flex *:items-center *:gap-x-1.5">
{basics.email && (
<div className="basics-item-email">
<EnvelopeIcon />
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
</div>
)}
{basics.phone && (
<div className="basics-item-phone">
<PhoneIcon />
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
</div>
)}
{basics.location && (
<div className="basics-item-location">
<MapPinIcon />
<span>{basics.location}</span>
</div>
)}
{basics.website.url && (
<div className="basics-item-website">
<GlobeIcon />
<PageLink {...basics.website} />
</div>
)}
{basics.customFields.map((field) => (
<div key={field.id} className="basics-item-custom">
<PageIcon icon={field.icon} />
<span>{field.text}</span>
</div>
))}
</div>
</div>
</div>
);
}
+102
View File
@@ -0,0 +1,102 @@
import { EnvelopeIcon, GlobeIcon, MapPinIcon, PhoneIcon } from "@phosphor-icons/react";
import { cn } from "@/utils/style";
import { getSectionComponent } from "../shared/get-section-component";
import { PageIcon } from "../shared/page-icon";
import { PageLink } from "../shared/page-link";
import { PagePicture } from "../shared/page-picture";
import { useResumeStore } from "../store/resume";
import type { TemplateProps } from "./types";
const sectionClassName = cn(
// Section Layout
"grid grid-cols-5 border-(--page-primary-color) border-t pt-1",
// Section Content
"[&>.section-content]:col-span-4",
);
/**
* Template: Bronzor
*/
export function BronzorTemplate({ pageIndex, pageLayout }: TemplateProps) {
const isFirstPage = pageIndex === 0;
const { main, sidebar, fullWidth } = pageLayout;
return (
<div className="template-bronzor page-content space-y-(--page-gap-y) px-(--page-margin-x) py-(--page-margin-y) print:p-0">
{isFirstPage && <Header />}
<div className="space-y-(--page-gap-y)">
<main data-layout="main" className="group page-main space-y-(--page-gap-y)">
{main.map((section) => {
const Component = getSectionComponent(section, { sectionClassName });
return <Component key={section} id={section} />;
})}
</main>
{!fullWidth && (
<aside data-layout="sidebar" className="group page-sidebar space-y-(--page-gap-y)">
{sidebar.map((section) => {
const Component = getSectionComponent(section, { sectionClassName });
return <Component key={section} id={section} />;
})}
</aside>
)}
</div>
</div>
);
}
function Header() {
const basics = useResumeStore((state) => state.resume.data.basics);
return (
<div className="page-header flex flex-col items-center gap-y-2">
<PagePicture />
<div className="page-basics space-y-2 text-center">
<div className="basics-header">
<h2 className="basics-name">{basics.name}</h2>
<p className="basics-headline">{basics.headline}</p>
</div>
<div className="basics-items flex flex-wrap justify-center gap-x-3 gap-y-1 text-center *:flex *:items-center *:gap-x-1.5">
{basics.email && (
<div className="basics-item-email">
<EnvelopeIcon />
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
</div>
)}
{basics.phone && (
<div className="basics-item-phone">
<PhoneIcon />
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
</div>
)}
{basics.location && (
<div className="basics-item-location">
<MapPinIcon />
<span>{basics.location}</span>
</div>
)}
{basics.website.url && (
<div className="basics-item-website">
<GlobeIcon />
<PageLink {...basics.website} />
</div>
)}
{basics.customFields.map((field) => (
<div key={field.id} className="basics-item-custom">
<PageIcon icon={field.icon} />
<span>{field.text}</span>
</div>
))}
</div>
</div>
</div>
);
}
@@ -0,0 +1,117 @@
import { EnvelopeIcon, GlobeIcon, MapPinIcon, PhoneIcon } from "@phosphor-icons/react";
import { cn } from "@/utils/style";
import { getSectionComponent } from "../shared/get-section-component";
import { PageIcon } from "../shared/page-icon";
import { PageLink } from "../shared/page-link";
import { PagePicture } from "../shared/page-picture";
import { useResumeStore } from "../store/resume";
import type { TemplateProps } from "./types";
const sectionClassName = cn(
// Section Heading
"[&>h6]:border-(--page-primary-color) [&>h6]:border-b",
// Section Heading in Sidebar Layout
"group-data-[layout=sidebar]:[&>h6]:text-(--page-background-color)",
"group-data-[layout=sidebar]:[&>h6]:border-(--page-background-color)",
// Icon Colors in Sidebar Layout
"group-data-[layout=sidebar]:[&_.section-item_i]:text-(--page-background-color)!",
// Section Item Header in Sidebar Layout
"group-data-[layout=sidebar]:[&_.section-item-header>div]:flex-col",
"group-data-[layout=sidebar]:[&_.section-item-header>div]:items-start",
);
/**
* Template: Chikorita
*/
export function ChikoritaTemplate({ pageIndex, pageLayout }: TemplateProps) {
const isFirstPage = pageIndex === 0;
const { main, sidebar, fullWidth } = pageLayout;
return (
<div className="template-chikorita page-content">
{isFirstPage && <Header />}
<div className="flex">
<main data-layout="main" className="group page-main flex-1 space-y-4 px-(--page-margin-x) py-(--page-margin-y)">
{main.map((section) => {
const Component = getSectionComponent(section, { sectionClassName });
return <Component key={section} id={section} />;
})}
</main>
{!fullWidth && (
<aside
data-layout="sidebar"
className="group page-sidebar w-(--page-sidebar-width) shrink-0 space-y-4 overflow-x-hidden bg-(--page-primary-color) px-(--page-margin-x) pb-(--page-margin-y) text-(--page-background-color)"
>
{sidebar.map((section) => {
const Component = getSectionComponent(section, { sectionClassName });
return <Component key={section} id={section} />;
})}
</aside>
)}
</div>
</div>
);
}
function Header() {
const basics = useResumeStore((state) => state.resume.data.basics);
return (
<div className="page-header relative flex">
<div className="flex items-center pt-(--page-margin-y) pl-(--page-margin-x)">
<PagePicture />
<div className="page-basics space-y-2 px-(--page-margin-x)">
<div>
<h2 className="basics-name">{basics.name}</h2>
<p className="basics-headline">{basics.headline}</p>
</div>
<div className="basics-items flex flex-wrap gap-x-2 gap-y-0.5 *:flex *:items-center *:gap-x-1.5">
{basics.email && (
<div className="basics-item-email">
<EnvelopeIcon />
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
</div>
)}
{basics.phone && (
<div className="basics-item-phone">
<PhoneIcon />
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
</div>
)}
{basics.location && (
<div className="basics-item-location">
<MapPinIcon />
<span>{basics.location}</span>
</div>
)}
{basics.website.url && (
<div className="basics-item-website">
<GlobeIcon />
<PageLink {...basics.website} />
</div>
)}
{basics.customFields.map((field) => (
<div key={field.id} className="basics-item-custom">
<PageIcon icon={field.icon} />
<span>{field.text}</span>
</div>
))}
</div>
</div>
</div>
<div className="w-(--page-sidebar-width) shrink-0 bg-(--page-primary-color)" />
</div>
);
}
@@ -0,0 +1,99 @@
import { cn } from "@/utils/style";
import { getSectionComponent } from "../shared/get-section-component";
import { PageIcon } from "../shared/page-icon";
import { PageLink } from "../shared/page-link";
import { PagePicture } from "../shared/page-picture";
import { useResumeStore } from "../store/resume";
import type { TemplateProps } from "./types";
const sectionClassName = cn(
// Section Heading
"[&>h6]:border-(--page-primary-color) [&>h6]:border-b",
// Icon Colors in Sidebar Layout
"group-data-[layout=sidebar]:[&_.section-item_i]:text-(--page-background-color)!",
// Section Item Header in Sidebar Layout
"group-data-[layout=sidebar]:[&_.section-item-header>div]:flex-col",
"group-data-[layout=sidebar]:[&_.section-item-header>div]:items-start",
);
/**
* Template: Ditgar
*/
export function DitgarTemplate({ pageIndex, pageLayout }: TemplateProps) {
const isFirstPage = pageIndex === 0;
const { main, sidebar, fullWidth } = pageLayout;
return (
<div className="template-ditgar page-content grid min-h-[inherit] grid-cols-3">
<div className={cn("sidebar group flex flex-col", !(isFirstPage || !fullWidth) && "hidden")}>
{isFirstPage && <Header />}
<div className="flex-1 space-y-4 bg-(--page-primary-color)/20 px-(--page-margin-x) py-(--page-margin-y)">
{sidebar.map((section) => {
const Component = getSectionComponent(section, { sectionClassName });
return <Component key={section} id={section} />;
})}
</div>
</div>
<div className={cn("main group", !fullWidth ? "col-span-2" : "col-span-3")}>
{isFirstPage &&
(() => {
const SummaryComponent = getSectionComponent("summary", { sectionClassName });
return <SummaryComponent id="summary" />;
})()}
<div className="space-y-4 px-(--page-margin-x) py-(--page-margin-y)">
{main.map((section) => {
const Component = getSectionComponent(section, { sectionClassName });
return <Component key={section} id={section} />;
})}
</div>
</div>
</div>
);
}
function Header() {
const basics = useResumeStore((state) => state.resume.data.basics);
return (
<div className="page-header space-y-4 bg-(--page-primary-color) px-(--page-margin-x) py-(--page-margin-y) text-(--page-background-color)">
<PagePicture />
<div>
<h2 className="font-bold text-2xl">{basics.name}</h2>
<p>{basics.headline}</p>
</div>
<div className="flex flex-col items-start gap-y-2 text-sm">
{basics.location && (
<div className="flex items-center gap-x-1.5">
<PageIcon icon="map-pin" className="ph-bold" />
<div>{basics.location}</div>
</div>
)}
{basics.phone && (
<div className="flex items-center gap-x-1.5">
<PageIcon icon="phone" className="ph-bold" />
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
</div>
)}
{basics.email && (
<div className="flex items-center gap-x-1.5">
<PageIcon icon="at" className="ph-bold" />
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
</div>
)}
{basics.website.url && (
<div className="flex items-center gap-x-1.5">
<PageIcon icon="globe" className="ph-bold" />
<PageLink {...basics.website} />
</div>
)}
</div>
</div>
);
}
+111
View File
@@ -0,0 +1,111 @@
import { EnvelopeIcon, GlobeIcon, MapPinIcon, PhoneIcon } from "@phosphor-icons/react";
import { cn } from "@/utils/style";
import { getSectionComponent } from "../shared/get-section-component";
import { PageIcon } from "../shared/page-icon";
import { PageLink } from "../shared/page-link";
import { PagePicture } from "../shared/page-picture";
import { useResumeStore } from "../store/resume";
import type { TemplateProps } from "./types";
const sectionClassName = cn(
// Section Item Header in Sidebar Layout
"group-data-[layout=sidebar]:[&_.section-item-header>div]:flex-col",
"group-data-[layout=sidebar]:[&_.section-item-header>div]:items-start",
);
/**
* Template: Ditto
*/
export function DittoTemplate({ pageIndex, pageLayout }: TemplateProps) {
const isFirstPage = pageIndex === 0;
const { main, sidebar, fullWidth } = pageLayout;
return (
<div className="template-ditto page-content">
{isFirstPage && <Header />}
<div className="flex py-(--page-margin-y)">
{!fullWidth && (
<aside
data-layout="sidebar"
className="group page-sidebar w-(--page-sidebar-width) shrink-0 space-y-4 overflow-x-hidden pl-(--page-margin-x)"
>
{sidebar.map((section) => {
const Component = getSectionComponent(section, { sectionClassName });
return <Component key={section} id={section} />;
})}
</aside>
)}
<main data-layout="main" className="group page-main space-y-4 px-(--page-margin-x)">
{main.map((section) => {
const Component = getSectionComponent(section, { sectionClassName });
return <Component key={section} id={section} />;
})}
</main>
</div>
</div>
);
}
function Header() {
const basics = useResumeStore((state) => state.resume.data.basics);
return (
<div className="page-header relative">
<div className="page-basics bg-(--page-primary-color) text-(--page-background-color)">
<div className="basics-header flex items-center">
<div className="flex w-(--page-sidebar-width) shrink-0 justify-center pl-(--page-margin-x)">
<PagePicture className="absolute top-8" />
</div>
<div className="px-(--page-margin-x) py-(--page-margin-y)">
<h2 className="basics-name">{basics.name}</h2>
<p className="basics-headline">{basics.headline}</p>
</div>
</div>
</div>
<div className="flex items-center">
<div className="w-(--page-sidebar-width) shrink-0" />
<div className="basics-items flex flex-wrap gap-x-3 gap-y-1 px-(--page-margin-x) pt-3 *:flex *:items-center *:gap-x-1.5">
{basics.email && (
<div className="basics-item-email">
<EnvelopeIcon />
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
</div>
)}
{basics.phone && (
<div className="basics-item-phone">
<PhoneIcon />
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
</div>
)}
{basics.location && (
<div className="basics-item-location">
<MapPinIcon />
<span>{basics.location}</span>
</div>
)}
{basics.website.url && (
<div className="basics-item-website">
<GlobeIcon />
<PageLink {...basics.website} />
</div>
)}
{basics.customFields.map((field) => (
<div key={field.id} className="basics-item-custom">
<PageIcon icon={field.icon} />
<span>{field.text}</span>
</div>
))}
</div>
</div>
</div>
);
}
+123
View File
@@ -0,0 +1,123 @@
import { cn } from "@/utils/style";
import { getSectionComponent } from "../shared/get-section-component";
import { PageIcon } from "../shared/page-icon";
import { PageLink } from "../shared/page-link";
import { PagePicture } from "../shared/page-picture";
import { PageSummary } from "../shared/page-summary";
import { useResumeStore } from "../store/resume";
import type { TemplateProps } from "./types";
const sectionClassName = cn(
// Section Heading
"[&>h6]:border-(--page-primary-color) [&>h6]:border-b",
// Section Item Header in Sidebar Layout
"group-data-[layout=sidebar]:[&_.section-item-header>div]:flex-col",
"group-data-[layout=sidebar]:[&_.section-item-header>div]:items-start",
);
/**
* Template: Gengar
*/
export function GengarTemplate({ pageIndex, pageLayout }: TemplateProps) {
const isFirstPage = pageIndex === 0;
const { main, sidebar, fullWidth } = pageLayout;
return (
<div className="template-gengar page-content">
<div className="flex">
<div data-layout="sidebar" className="group page-sidebar flex w-(--page-sidebar-width) shrink-0 flex-col">
{isFirstPage && <Header />}
{!fullWidth && (
<aside className="shrink-0 space-y-4 overflow-x-hidden bg-(--page-primary-color)/20 px-(--page-margin-x) pt-4 pb-(--page-margin-y)">
{sidebar
.filter((section) => section !== "summary")
.map((section) => {
const Component = getSectionComponent(section, { sectionClassName });
return <Component key={section} id={section} />;
})}
</aside>
)}
</div>
<main data-layout="main" className="group page-main">
{isFirstPage && (
<PageSummary
className={cn(
sectionClassName,
"bg-(--page-primary-color)/20 px-(--page-margin-x) py-(--page-margin-y) [&>h6]:hidden",
)}
/>
)}
<div className="space-y-4 px-(--page-margin-x) pt-4 pb-(--page-margin-y)">
{main
.filter((section) => section !== "summary")
.map((section) => {
const Component = getSectionComponent(section, { sectionClassName });
return <Component key={section} id={section} />;
})}
</div>
</main>
</div>
</div>
);
}
function Header() {
const basics = useResumeStore((state) => state.resume.data.basics);
return (
<div className="page-header relative flex">
<div className="flex w-full shrink-0 flex-col justify-center gap-y-2 bg-(--page-primary-color) px-(--page-margin-x) py-(--page-margin-y) text-(--page-background-color)">
<PagePicture />
<div>
<h2 className="basics-name">{basics.name}</h2>
<p className="basics-headline">{basics.headline}</p>
</div>
<div
className="basics-items flex flex-col gap-y-1 *:flex *:items-center *:gap-x-1.5"
style={{ "--page-primary-color": "var(--page-background-color)" } as React.CSSProperties}
>
{basics.email && (
<div className="basics-item-email">
<PageIcon icon="envelope" />
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
</div>
)}
{basics.phone && (
<div className="basics-item-phone">
<PageIcon icon="phone" />
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
</div>
)}
{basics.location && (
<div className="basics-item-location">
<PageIcon icon="map-pin" />
<span>{basics.location}</span>
</div>
)}
{basics.website.url && (
<div className="basics-item-website">
<PageIcon icon="globe" />
<PageLink {...basics.website} />
</div>
)}
{basics.customFields.map((field) => (
<div key={field.id} className="basics-item-custom">
<PageIcon icon={field.icon} />
<span>{field.text}</span>
</div>
))}
</div>
</div>
</div>
);
}
+113
View File
@@ -0,0 +1,113 @@
import { EnvelopeIcon, GlobeIcon, MapPinIcon, PhoneIcon } from "@phosphor-icons/react";
import { cn } from "@/utils/style";
import { getSectionComponent } from "../shared/get-section-component";
import { PageIcon } from "../shared/page-icon";
import { PageLink } from "../shared/page-link";
import { PagePicture } from "../shared/page-picture";
import { useResumeStore } from "../store/resume";
import type { TemplateProps } from "./types";
const sectionClassName = cn(
// Section Heading
"[&>h6]:border-(--page-primary-color) [&>h6]:border-b",
// Section Item Header in Sidebar Layout
"group-data-[layout=sidebar]:[&_.section-item-header>div]:flex-col",
"group-data-[layout=sidebar]:[&_.section-item-header>div]:items-start",
);
/**
* Template: Glalie
*/
export function GlalieTemplate({ pageIndex, pageLayout }: TemplateProps) {
const isFirstPage = pageIndex === 0;
const { main, sidebar, fullWidth } = pageLayout;
return (
<div className="template-glalie page-content">
<div className="flex">
<aside
data-layout="sidebar"
className="group page-sidebar flex w-(--page-sidebar-width) shrink-0 flex-col space-y-4 bg-(--page-primary-color)/20 px-(--page-margin-x) py-(--page-margin-y)"
>
{isFirstPage && <Header />}
{!fullWidth && (
<div className="shrink-0 space-y-4 overflow-x-hidden">
{sidebar.map((section) => {
const Component = getSectionComponent(section, { sectionClassName });
return <Component key={section} id={section} />;
})}
</div>
)}
</aside>
<main data-layout="main" className="group page-main">
<div className="space-y-4 px-(--page-margin-x) py-(--page-margin-y)">
{main.map((section) => {
const Component = getSectionComponent(section, { sectionClassName });
return <Component key={section} id={section} />;
})}
</div>
</main>
</div>
</div>
);
}
function Header() {
const basics = useResumeStore((state) => state.resume.data.basics);
return (
<div className="page-header relative flex">
<div className="flex w-full shrink-0 flex-col items-center justify-center gap-y-2">
<PagePicture />
<div className="text-center">
<h2 className="basics-name">{basics.name}</h2>
<p className="basics-headline">{basics.headline}</p>
</div>
<div
style={{ "--box-radius": "calc(var(--picture-border-radius) / 4)" } as React.CSSProperties}
className="basics-items flex w-full flex-col gap-y-1 rounded-(--box-radius) border border-(--page-primary-color) p-3 *:flex *:items-center *:gap-x-1.5"
>
{basics.email && (
<div className="basics-item-email">
<EnvelopeIcon />
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
</div>
)}
{basics.phone && (
<div className="basics-item-phone">
<PhoneIcon />
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
</div>
)}
{basics.location && (
<div className="basics-item-location">
<MapPinIcon />
<span>{basics.location}</span>
</div>
)}
{basics.website.url && (
<div className="basics-item-website">
<GlobeIcon />
<PageLink {...basics.website} />
</div>
)}
{basics.customFields.map((field) => (
<div key={field.id} className="basics-item-custom">
<PageIcon icon={field.icon} />
<span>{field.text}</span>
</div>
))}
</div>
</div>
</div>
);
}
@@ -0,0 +1,97 @@
import { EnvelopeIcon, GlobeIcon, MapPinIcon, PhoneIcon } from "@phosphor-icons/react";
import { cn } from "@/utils/style";
import { getSectionComponent } from "../shared/get-section-component";
import { PageIcon } from "../shared/page-icon";
import { PageLink } from "../shared/page-link";
import { PagePicture } from "../shared/page-picture";
import { useResumeStore } from "../store/resume";
import type { TemplateProps } from "./types";
const sectionClassName = cn(
// Section Heading
"[&>h6]:border-(--page-primary-color) [&>h6]:border-b [&>h6]:pb-0.5 [&>h6]:text-center",
);
/**
* Template: Kakuna
*/
export function KakunaTemplate({ pageIndex, pageLayout }: TemplateProps) {
const isFirstPage = pageIndex === 0;
const { main, sidebar, fullWidth } = pageLayout;
return (
<div className="template-kakuna page-content space-y-4 px-(--page-margin-x) py-(--page-margin-y) print:p-0">
{isFirstPage && <Header />}
<main data-layout="main" className="group page-main space-y-4">
{main.map((section) => {
const Component = getSectionComponent(section, { sectionClassName });
return <Component key={section} id={section} />;
})}
</main>
{!fullWidth && (
<aside data-layout="sidebar" className="group page-sidebar space-y-4">
{sidebar.map((section) => {
const Component = getSectionComponent(section, { sectionClassName });
return <Component key={section} id={section} />;
})}
</aside>
)}
</div>
);
}
function Header() {
const basics = useResumeStore((state) => state.resume.data.basics);
return (
<div className="page-header flex flex-col items-center gap-y-2">
<PagePicture />
<div className="page-basics space-y-2 text-center">
<div>
<h2 className="basics-name">{basics.name}</h2>
<p className="basics-headline">{basics.headline}</p>
</div>
<div className="basics-items flex flex-wrap justify-center gap-x-3 gap-y-0.5 *:flex *:items-center *:gap-x-1.5">
{basics.email && (
<div className="basics-item-email">
<EnvelopeIcon />
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
</div>
)}
{basics.phone && (
<div className="basics-item-phone">
<PhoneIcon />
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
</div>
)}
{basics.location && (
<div className="basics-item-location">
<MapPinIcon />
<span>{basics.location}</span>
</div>
)}
{basics.website.url && (
<div className="basics-item-website">
<GlobeIcon />
<PageLink {...basics.website} />
</div>
)}
{basics.customFields.map((field) => (
<div key={field.id} className="basics-item-custom">
<PageIcon icon={field.icon} />
<span>{field.text}</span>
</div>
))}
</div>
</div>
</div>
);
}
+116
View File
@@ -0,0 +1,116 @@
import { EnvelopeIcon, GlobeIcon, MapPinIcon, PhoneIcon } from "@phosphor-icons/react";
import { cn } from "@/utils/style";
import { getSectionComponent } from "../shared/get-section-component";
import { PageIcon } from "../shared/page-icon";
import { PageLink } from "../shared/page-link";
import { PagePicture } from "../shared/page-picture";
import { useResumeStore } from "../store/resume";
import type { TemplateProps } from "./types";
const sectionClassName = cn(
// Container
"rounded-(--container-border-radius) border border-(--page-text-color)/10 bg-(--page-background-color) p-4 shadow-md",
// Section Heading
"[&>h6]:-mt-(--heading-negative-margin) [&>h6]:max-w-fit [&>h6]:bg-(--page-background-color) [&>h6]:px-4 [&>h6]:pb-0.5",
);
/**
* Template: Lapras
*/
export function LaprasTemplate({ pageIndex, pageLayout }: TemplateProps) {
const isFirstPage = pageIndex === 0;
const { main, sidebar, fullWidth } = pageLayout;
const containerBorderRadius = useResumeStore((state) => Math.min(state.resume.data.picture.borderRadius, 30));
const headingNegativeMargin = useResumeStore((state) => state.resume.data.metadata.typography.heading.fontSize + 6);
return (
<div
style={
{
"--container-border-radius": `${containerBorderRadius}pt`,
"--heading-negative-margin": `${headingNegativeMargin}pt`,
} as React.CSSProperties
}
className="template-lapras page-content space-y-7 px-(--page-margin-x) py-(--page-margin-y) print:p-0"
>
{isFirstPage && <Header />}
<main data-layout="main" className="group page-main space-y-7">
{main.map((section) => {
const Component = getSectionComponent(section, { sectionClassName });
return <Component key={section} id={section} />;
})}
</main>
{!fullWidth && (
<aside data-layout="sidebar" className="group page-sidebar space-y-7">
{sidebar.map((section) => {
const Component = getSectionComponent(section, { sectionClassName });
return <Component key={section} id={section} />;
})}
</aside>
)}
</div>
);
}
function Header() {
const basics = useResumeStore((state) => state.resume.data.basics);
return (
<div
className={cn(
"page-header flex items-center gap-x-4",
"rounded-(--picture-border-radius) border border-(--page-text-color)/10 bg-(--page-background-color) p-4 shadow-md",
)}
>
<PagePicture />
<div className="page-basics space-y-2">
<div>
<h2 className="basics-name">{basics.name}</h2>
<p className="basics-headline">{basics.headline}</p>
</div>
<div className="basics-items flex flex-wrap gap-x-2 gap-y-0.5 *:flex *:items-center *:gap-x-1.5 *:border-(--page-primary-color) *:border-r *:py-0.5 *:pr-2 *:last:border-r-0">
{basics.email && (
<div className="basics-item-email">
<EnvelopeIcon />
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
</div>
)}
{basics.phone && (
<div className="basics-item-phone">
<PhoneIcon />
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
</div>
)}
{basics.location && (
<div className="basics-item-location">
<MapPinIcon />
<span>{basics.location}</span>
</div>
)}
{basics.website.url && (
<div className="basics-item-website">
<GlobeIcon />
<PageLink {...basics.website} />
</div>
)}
{basics.customFields.map((field) => (
<div key={field.id} className="basics-item-custom">
<PageIcon icon={field.icon} />
<span>{field.text}</span>
</div>
))}
</div>
</div>
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
import { EnvelopeIcon, GlobeIcon, MapPinIcon, PhoneIcon } from "@phosphor-icons/react";
import { cn } from "@/utils/style";
import { getSectionComponent } from "../shared/get-section-component";
import { PageIcon } from "../shared/page-icon";
import { PageLink } from "../shared/page-link";
import { PagePicture } from "../shared/page-picture";
import { PageSummary } from "../shared/page-summary";
import { useResumeStore } from "../store/resume";
import type { TemplateProps } from "./types";
const sectionClassName = cn(
// Section Heading
"[&>h6]:border-(--page-primary-color) [&>h6]:border-b",
);
/**
* Template: Leafish
*/
export function LeafishTemplate({ pageIndex, pageLayout }: TemplateProps) {
const isFirstPage = pageIndex === 0;
const { main, sidebar, fullWidth } = pageLayout;
return (
<div className="template-leafish page-content space-y-4">
{isFirstPage && <Header />}
<div className="flex gap-x-(--page-margin-x) px-(--page-margin-x) pb-(--page-margin-y)">
<main data-layout="main" className="group page-main space-y-4">
{main
.filter((section) => section !== "summary")
.map((section) => {
const Component = getSectionComponent(section, { sectionClassName });
return <Component key={section} id={section} />;
})}
</main>
{!fullWidth && (
<aside data-layout="sidebar" className="group page-sidebar w-(--page-sidebar-width) shrink-0 space-y-4">
{sidebar
.filter((section) => section !== "summary")
.map((section) => {
const Component = getSectionComponent(section, { sectionClassName });
return <Component key={section} id={section} />;
})}
</aside>
)}
</div>
</div>
);
}
function Header() {
const basics = useResumeStore((state) => state.resume.data.basics);
return (
<div className="page-header bg-(--page-primary-color)/10">
<div className="flex items-center gap-x-(--page-margin-x) px-(--page-margin-x) py-(--page-margin-y)">
<PagePicture />
<div className="space-y-2">
<div>
<h2 className="basics-name">{basics.name}</h2>
<p className="basics-headline">{basics.headline}</p>
</div>
<PageSummary className="[&>h6]:hidden" />
</div>
</div>
<div className="page-basics bg-(--page-primary-color)/10 px-(--page-margin-x) py-(--page-margin-y)">
<div className="basics-items flex flex-wrap gap-x-4 gap-y-1 *:flex *:items-center *:gap-x-1.5">
{basics.email && (
<div className="basics-item-email">
<EnvelopeIcon />
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
</div>
)}
{basics.phone && (
<div className="basics-item-phone">
<PhoneIcon />
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
</div>
)}
{basics.location && (
<div className="basics-item-location">
<MapPinIcon />
<span>{basics.location}</span>
</div>
)}
{basics.website.url && (
<div className="basics-item-website">
<GlobeIcon />
<PageLink {...basics.website} />
</div>
)}
{basics.customFields.map((field) => (
<div key={field.id} className="basics-item-custom">
<PageIcon icon={field.icon} />
<span>{field.text}</span>
</div>
))}
</div>
</div>
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
import { EnvelopeIcon, GlobeIcon, MapPinIcon, PhoneIcon } from "@phosphor-icons/react";
import { cn } from "@/utils/style";
import { getSectionComponent } from "../shared/get-section-component";
import { PageIcon } from "../shared/page-icon";
import { PageLink } from "../shared/page-link";
import { PagePicture } from "../shared/page-picture";
import { useResumeStore } from "../store/resume";
import type { TemplateProps } from "./types";
const sectionClassName = cn();
/**
* Template: Onyx
*/
export function OnyxTemplate({ pageIndex, pageLayout }: TemplateProps) {
const isFirstPage = pageIndex === 0;
const { main, sidebar, fullWidth } = pageLayout;
return (
<div className="template-onyx page-content space-y-(--page-gap-y) px-(--page-margin-x) py-(--page-margin-y) print:p-0">
{isFirstPage && <Header />}
<main data-layout="main" className="group page-main space-y-(--page-gap-y)">
{main.map((section) => {
const Component = getSectionComponent(section, { sectionClassName });
return <Component key={section} id={section} />;
})}
</main>
{!fullWidth && (
<aside data-layout="sidebar" className="group page-sidebar space-y-(--page-gap-y)">
{sidebar.map((section) => {
const Component = getSectionComponent(section, { sectionClassName });
return <Component key={section} id={section} />;
})}
</aside>
)}
</div>
);
}
function Header() {
const basics = useResumeStore((state) => state.resume.data.basics);
return (
<div className="page-header flex items-center gap-x-4 border-(--page-primary-color) border-b pb-(--page-gap-y)">
<PagePicture />
<div className="page-basics space-y-2">
<div>
<h2 className="basics-name">{basics.name}</h2>
<p className="basics-headline">{basics.headline}</p>
</div>
<div className="basics-items flex flex-wrap gap-x-3 gap-y-0.5 *:flex *:items-center *:gap-x-1.5">
{basics.email && (
<div className="basics-item-email">
<EnvelopeIcon />
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
</div>
)}
{basics.phone && (
<div className="basics-item-phone">
<PhoneIcon />
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
</div>
)}
{basics.location && (
<div className="basics-item-location">
<MapPinIcon />
<span>{basics.location}</span>
</div>
)}
{basics.website.url && (
<div className="basics-item-website">
<GlobeIcon />
<PageLink {...basics.website} />
</div>
)}
{basics.customFields.map((field) => (
<div key={field.id} className="basics-item-custom">
<PageIcon icon={field.icon} />
<span>{field.text}</span>
</div>
))}
</div>
</div>
</div>
);
}
+111
View File
@@ -0,0 +1,111 @@
import { EnvelopeIcon, GlobeIcon, MapPinIcon, PhoneIcon } from "@phosphor-icons/react";
import { cn } from "@/utils/style";
import { getSectionComponent } from "../shared/get-section-component";
import { PageIcon } from "../shared/page-icon";
import { PageLink } from "../shared/page-link";
import { PagePicture } from "../shared/page-picture";
import { useResumeStore } from "../store/resume";
import type { TemplateProps } from "./types";
const sectionClassName = cn(
// Section Heading
"[&>h6]:border-(--page-primary-color) [&>h6]:border-b",
// Section Item Header in Sidebar Layout
"group-data-[layout=sidebar]:[&_.section-item-header>div]:flex-col",
"group-data-[layout=sidebar]:[&_.section-item-header>div]:items-start",
);
/**
* Template: Pikachu
*/
export function PikachuTemplate({ pageIndex, pageLayout }: TemplateProps) {
const isFirstPage = pageIndex === 0;
const { main, sidebar, fullWidth } = pageLayout;
return (
<div className="template-pikachu page-content px-(--page-margin-x) py-(--page-margin-y) print:p-0">
<div className="flex gap-x-(--page-margin-x)">
<aside
data-layout="sidebar"
className="group page-sidebar flex w-(--page-sidebar-width) shrink-0 flex-col space-y-3"
>
{isFirstPage && <PagePicture className="max-h-full! max-w-full!" />}
{!fullWidth && (
<div className="shrink-0 space-y-4 overflow-x-hidden">
{sidebar.map((section) => {
const Component = getSectionComponent(section, { sectionClassName });
return <Component key={section} id={section} />;
})}
</div>
)}
</aside>
<main data-layout="main" className="group page-main space-y-3">
{isFirstPage && <Header />}
<div className="space-y-4 pb-(--page-margin-y)">
{main.map((section) => {
const Component = getSectionComponent(section, { sectionClassName });
return <Component key={section} id={section} />;
})}
</div>
</main>
</div>
</div>
);
}
function Header() {
const basics = useResumeStore((state) => state.resume.data.basics);
return (
<div className="page-header w-full space-y-2 rounded-(--picture-border-radius) bg-(--page-primary-color) px-(--page-margin-x) py-(--page-margin-y) text-(--page-background-color)">
<div className="border-(--page-background-color)/50 border-b pb-2">
<h2 className="basics-name">{basics.name}</h2>
<p className="basics-headline">{basics.headline}</p>
</div>
<div
className="basics-items flex flex-wrap gap-x-3 gap-y-0.5 *:flex *:items-center *:gap-x-1.5"
style={{ "--page-primary-color": "var(--page-background-color)" } as React.CSSProperties}
>
{basics.email && (
<div className="basics-item-email">
<EnvelopeIcon />
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
</div>
)}
{basics.phone && (
<div className="basics-item-phone">
<PhoneIcon />
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
</div>
)}
{basics.location && (
<div className="basics-item-location">
<MapPinIcon />
<span>{basics.location}</span>
</div>
)}
{basics.website.url && (
<div className="basics-item-website">
<GlobeIcon />
<PageLink {...basics.website} />
</div>
)}
{basics.customFields.map((field) => (
<div key={field.id} className="basics-item-custom">
<PageIcon icon={field.icon} />
<span>{field.text}</span>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,97 @@
import { EnvelopeIcon, GlobeIcon, MapPinIcon, PhoneIcon } from "@phosphor-icons/react";
import { cn } from "@/utils/style";
import { getSectionComponent } from "../shared/get-section-component";
import { PageIcon } from "../shared/page-icon";
import { PageLink } from "../shared/page-link";
import { PagePicture } from "../shared/page-picture";
import { useResumeStore } from "../store/resume";
import type { TemplateProps } from "./types";
const sectionClassName = cn(
// Section Heading
"[&>h6]:border-(--page-primary-color) [&>h6]:border-b",
);
/**
* Template: Rhyhorn
*/
export function RhyhornTemplate({ pageIndex, pageLayout }: TemplateProps) {
const isFirstPage = pageIndex === 0;
const { main, sidebar, fullWidth } = pageLayout;
return (
<div className="template-rhyhorn page-content space-y-4 px-(--page-margin-x) py-(--page-margin-y) print:p-0">
{isFirstPage && <Header />}
<main data-layout="main" className="group page-main space-y-4">
{main.map((section) => {
const Component = getSectionComponent(section, { sectionClassName });
return <Component key={section} id={section} />;
})}
</main>
{!fullWidth && (
<aside data-layout="sidebar" className="group page-sidebar space-y-4">
{sidebar.map((section) => {
const Component = getSectionComponent(section, { sectionClassName });
return <Component key={section} id={section} />;
})}
</aside>
)}
</div>
);
}
function Header() {
const basics = useResumeStore((state) => state.resume.data.basics);
return (
<div className="page-header flex items-center gap-x-4">
<div className="page-basics grow space-y-2">
<div>
<h2 className="basics-name">{basics.name}</h2>
<p className="basics-headline">{basics.headline}</p>
</div>
<div className="basics-items flex flex-wrap gap-x-2 gap-y-0.5 *:flex *:items-center *:gap-x-1.5 *:border-(--page-primary-color) *:border-r *:py-0.5 *:pr-2 *:last:border-r-0">
{basics.email && (
<div className="basics-item-email">
<EnvelopeIcon />
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
</div>
)}
{basics.phone && (
<div className="basics-item-phone">
<PhoneIcon />
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
</div>
)}
{basics.location && (
<div className="basics-item-location">
<MapPinIcon />
<span>{basics.location}</span>
</div>
)}
{basics.website.url && (
<div className="basics-item-website">
<GlobeIcon />
<PageLink {...basics.website} />
</div>
)}
{basics.customFields.map((field) => (
<div key={field.id} className="basics-item-custom">
<PageIcon icon={field.icon} />
<span>{field.text}</span>
</div>
))}
</div>
</div>
<PagePicture />
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
import type z from "zod";
import type { pageLayoutSchema } from "@/schema/resume/data";
export type TemplateProps = {
pageIndex: number;
pageLayout: z.infer<typeof pageLayoutSchema>;
};