mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-27 18:34:51 +10:00
initial commit of v5
This commit is contained in:
@@ -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]);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
},
|
||||
})),
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
};
|
||||
Reference in New Issue
Block a user