mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-25 09:24:54 +10:00
v5.1.0 (#2970)
* chore(release): v5.1.0 * feat: implement resume thumbnails * fix: remove unused mcp tools * docs: fix formatting of docs
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { ReactNode } from "react";
|
||||
import type { SectionTitleResolver } from "./section-title";
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
type RenderContextValue = ResumeData & {
|
||||
resolveSectionTitle?: SectionTitleResolver | undefined;
|
||||
};
|
||||
|
||||
const RenderContext = createContext<RenderContextValue | null>(null);
|
||||
|
||||
export type RenderProviderProps = {
|
||||
data: ResumeData;
|
||||
resolveSectionTitle?: SectionTitleResolver | undefined;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const RenderProvider = ({ data, resolveSectionTitle, children }: RenderProviderProps) => {
|
||||
return <RenderContext.Provider value={{ ...data, resolveSectionTitle }}>{children}</RenderContext.Provider>;
|
||||
};
|
||||
|
||||
export const useRender = (): RenderContextValue => {
|
||||
const context = useContext(RenderContext);
|
||||
|
||||
if (!context) throw new Error("useRender must be called inside a <RenderProvider>.");
|
||||
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { LayoutPage, ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { Template } from "@reactive-resume/schema/templates";
|
||||
import type { ComponentType } from "react";
|
||||
import type { SectionTitleResolver } from "./section-title";
|
||||
import { Document } from "@react-pdf/renderer";
|
||||
import { RenderProvider } from "./context";
|
||||
import { registerFonts } from "./hooks/use-register-fonts";
|
||||
import { getTemplatePage } from "./templates";
|
||||
|
||||
export type TemplatePageProps = {
|
||||
page: LayoutPage;
|
||||
pageIndex: number;
|
||||
};
|
||||
|
||||
export type TemplatePage = ComponentType<TemplatePageProps>;
|
||||
|
||||
export type ResumeDocumentProps = {
|
||||
data: ResumeData;
|
||||
template: Template;
|
||||
resolveSectionTitle?: SectionTitleResolver | undefined;
|
||||
};
|
||||
|
||||
export const ResumeDocument = ({ data, template, resolveSectionTitle }: ResumeDocumentProps) => {
|
||||
const TemplatePageComponent = getTemplatePage(template);
|
||||
registerFonts(data.metadata.typography);
|
||||
|
||||
return (
|
||||
<RenderProvider data={data} resolveSectionTitle={resolveSectionTitle}>
|
||||
<Document title={`${data.basics.name} Resume`} author={data.basics.name} subject={data.basics.headline}>
|
||||
{data.metadata.layout.pages.map((page, index) => (
|
||||
<TemplatePageComponent key={index} page={page} pageIndex={index} />
|
||||
))}
|
||||
</Document>
|
||||
</RenderProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { FontWeight } from "@reactive-resume/fonts";
|
||||
import type { Typography } from "@reactive-resume/schema/resume/data";
|
||||
import { Font } from "@react-pdf/renderer";
|
||||
import { getWebFontSource, isStandardPdfFontFamily } from "@reactive-resume/fonts";
|
||||
|
||||
type FontWeightRange = {
|
||||
lowest: number;
|
||||
highest: number;
|
||||
};
|
||||
|
||||
const registeredFontVariants = new Set<string>();
|
||||
|
||||
const getFontWeightRange = (fontWeights: string[]): FontWeightRange => {
|
||||
const numericWeights = fontWeights.map(Number).filter((weight) => Number.isFinite(weight));
|
||||
if (numericWeights.length === 0) return { lowest: 400, highest: 700 };
|
||||
|
||||
const lowest = Math.min(...numericWeights);
|
||||
const rawHighest = Math.max(...numericWeights);
|
||||
const highest = rawHighest <= lowest ? 700 : rawHighest;
|
||||
|
||||
return { lowest, highest };
|
||||
};
|
||||
|
||||
const toFontWeight = (weight: number): FontWeight => {
|
||||
if (weight <= 100) return "100";
|
||||
if (weight <= 200) return "200";
|
||||
if (weight <= 300) return "300";
|
||||
if (weight <= 400) return "400";
|
||||
if (weight <= 500) return "500";
|
||||
if (weight <= 600) return "600";
|
||||
if (weight <= 700) return "700";
|
||||
if (weight <= 800) return "800";
|
||||
return "900";
|
||||
};
|
||||
|
||||
export const registerFonts = (typography: Typography) => {
|
||||
Font.registerHyphenationCallback((word) => [word]);
|
||||
|
||||
const bodyFontFamily = typography.body.fontFamily;
|
||||
const headingFontFamily = typography.heading.fontFamily;
|
||||
const bodyRange = getFontWeightRange(typography.body.fontWeights);
|
||||
const headingRange = getFontWeightRange(typography.heading.fontWeights);
|
||||
|
||||
const registerFont = (family: string, weight: number) => {
|
||||
if (isStandardPdfFontFamily(family)) return;
|
||||
|
||||
const normalizedWeight = toFontWeight(weight);
|
||||
const key = `${family}:${normalizedWeight}`;
|
||||
if (registeredFontVariants.has(key)) return;
|
||||
|
||||
const source = getWebFontSource(family, normalizedWeight);
|
||||
if (!source) return;
|
||||
|
||||
Font.register({ family, src: source, fontWeight: Number(normalizedWeight) });
|
||||
registeredFontVariants.add(key);
|
||||
};
|
||||
|
||||
registerFont(bodyFontFamily, bodyRange.lowest);
|
||||
registerFont(bodyFontFamily, bodyRange.highest);
|
||||
registerFont(headingFontFamily, headingRange.lowest);
|
||||
registerFont(headingFontFamily, headingRange.highest);
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { ResumeData, SectionType } from "@reactive-resume/schema/resume/data";
|
||||
|
||||
export type SectionTitleResolverInput = {
|
||||
sectionId: string;
|
||||
locale: string;
|
||||
sectionKind: "summary" | "builtin" | "custom";
|
||||
customSectionType?: string;
|
||||
defaultEnglishTitle?: string | undefined;
|
||||
};
|
||||
|
||||
export type SectionTitleResolver = (input: SectionTitleResolverInput) => string;
|
||||
|
||||
const defaultEnglishSectionTitles: Record<"summary" | SectionType, string> = {
|
||||
summary: "Summary",
|
||||
profiles: "Profiles",
|
||||
experience: "Experience",
|
||||
education: "Education",
|
||||
projects: "Projects",
|
||||
skills: "Skills",
|
||||
languages: "Languages",
|
||||
interests: "Interests",
|
||||
awards: "Awards",
|
||||
certifications: "Certifications",
|
||||
publications: "Publications",
|
||||
volunteer: "Volunteer",
|
||||
references: "References",
|
||||
};
|
||||
|
||||
const defaultEnglishCustomSectionTitles: Record<string, string> = {
|
||||
"cover-letter": "Cover Letter",
|
||||
};
|
||||
|
||||
export const resolveSectionTitle = (
|
||||
title: string,
|
||||
input: SectionTitleResolverInput,
|
||||
resolver?: SectionTitleResolver,
|
||||
legacyFallback?: string,
|
||||
) => {
|
||||
if (title.trim()) return title;
|
||||
|
||||
const resolvedTitle = resolver?.(input);
|
||||
if (resolvedTitle?.trim()) return resolvedTitle;
|
||||
|
||||
if (legacyFallback !== undefined) return legacyFallback;
|
||||
|
||||
return input.defaultEnglishTitle ?? input.sectionId;
|
||||
};
|
||||
|
||||
type RenderData = ResumeData & {
|
||||
resolveSectionTitle?: SectionTitleResolver | undefined;
|
||||
};
|
||||
|
||||
export const getResumeSectionTitle = (data: RenderData, sectionId: string, legacyFallback?: string) => {
|
||||
const locale = data.metadata.page.locale;
|
||||
|
||||
if (sectionId === "summary") {
|
||||
const defaultEnglishTitle = defaultEnglishSectionTitles.summary;
|
||||
|
||||
return resolveSectionTitle(
|
||||
data.summary.title,
|
||||
{ sectionId, locale, sectionKind: "summary", defaultEnglishTitle },
|
||||
data.resolveSectionTitle,
|
||||
legacyFallback,
|
||||
);
|
||||
}
|
||||
|
||||
if (sectionId in data.sections) {
|
||||
const sectionType = sectionId as SectionType;
|
||||
const defaultEnglishTitle = defaultEnglishSectionTitles[sectionType];
|
||||
|
||||
return resolveSectionTitle(
|
||||
data.sections[sectionType].title,
|
||||
{ sectionId, locale, sectionKind: "builtin", defaultEnglishTitle },
|
||||
data.resolveSectionTitle,
|
||||
legacyFallback,
|
||||
);
|
||||
}
|
||||
|
||||
const customSection = data.customSections.find((section) => section.id === sectionId);
|
||||
|
||||
if (customSection) {
|
||||
const defaultEnglishTitle =
|
||||
customSection.type in data.sections
|
||||
? defaultEnglishSectionTitles[customSection.type as SectionType]
|
||||
: (defaultEnglishCustomSectionTitles[customSection.type] ?? customSection.type);
|
||||
|
||||
return resolveSectionTitle(
|
||||
customSection.title,
|
||||
{ sectionId, locale, sectionKind: "custom", customSectionType: customSection.type, defaultEnglishTitle },
|
||||
data.resolveSectionTitle,
|
||||
legacyFallback,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
data.resolveSectionTitle?.({ sectionId, locale, sectionKind: "custom", defaultEnglishTitle: sectionId }) ??
|
||||
legacyFallback ??
|
||||
sectionId
|
||||
);
|
||||
};
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
declare module "*.svg?raw" {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { IconName } from "phosphor-icons-react-pdf/dynamic";
|
||||
import type { TemplatePageProps } from "../../document";
|
||||
import type {
|
||||
TemplateColorRoles,
|
||||
TemplateFeatureStyleSlots,
|
||||
TemplateFeatures,
|
||||
TemplateStyleContext,
|
||||
TemplateStyleSlots,
|
||||
} from "../shared/types";
|
||||
import { Image, Page, StyleSheet, View } from "@react-pdf/renderer";
|
||||
import { Fragment, useMemo } from "react";
|
||||
import { rgbaStringToHex } from "@reactive-resume/utils/color";
|
||||
import { useRender } from "../../context";
|
||||
import { TemplateProvider } from "../shared/context";
|
||||
import { filterSections } from "../shared/filtering";
|
||||
import { getTemplateMetrics } from "../shared/metrics";
|
||||
import { getTemplatePageSize } from "../shared/page-size";
|
||||
import { hasTemplatePicture } from "../shared/picture";
|
||||
import { Heading, Icon, Link, Text } from "../shared/primitives";
|
||||
import { Section } from "../shared/sections";
|
||||
import { composeStyles, resolvePlacementColor } from "../shared/styles";
|
||||
|
||||
type AzurillStyles = Omit<TemplateStyleSlots, "page"> & {
|
||||
page: Style;
|
||||
contentRow: Style;
|
||||
sidebarColumn: Style;
|
||||
mainColumn: Style;
|
||||
header: Style;
|
||||
picture: Style;
|
||||
headerTitle: Style;
|
||||
headerIdentity: Style;
|
||||
headerName: Style;
|
||||
headerContactRow: Style;
|
||||
headerContactItem: Style;
|
||||
};
|
||||
|
||||
type AzurillTemplate = {
|
||||
colors: TemplateColorRoles;
|
||||
styles: AzurillStyles;
|
||||
featureStyles: TemplateFeatureStyleSlots;
|
||||
};
|
||||
|
||||
const azurillFeatures = {
|
||||
sectionTimeline: true,
|
||||
} satisfies TemplateFeatures;
|
||||
|
||||
export const AzurillPage = ({ page, pageIndex }: TemplatePageProps) => {
|
||||
const data = useRender();
|
||||
const { metadata } = data;
|
||||
const { colors, styles, featureStyles } = useAzurillTemplate();
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
const pageSize = getTemplatePageSize(metadata.page.format);
|
||||
const showHeader = pageIndex === 0;
|
||||
const sidebarSections = filterSections(page.sidebar, data);
|
||||
const mainSections = filterSections(page.main, data);
|
||||
|
||||
return (
|
||||
<Page size={pageSize} style={styles.page}>
|
||||
<TemplateProvider styles={styles} featureStyles={featureStyles} colors={colors} features={azurillFeatures}>
|
||||
{showHeader && <Header styles={styles} />}
|
||||
|
||||
<View style={composeStyles(styles.contentRow, { columnGap: metrics.columnGap })}>
|
||||
<View
|
||||
style={composeStyles(styles.sidebarColumn, {
|
||||
flexBasis: `${metadata.layout.sidebarWidth}%`,
|
||||
display: page.fullWidth ? "none" : "flex",
|
||||
rowGap: metrics.sectionGap,
|
||||
})}
|
||||
>
|
||||
{sidebarSections.map((section, index) => (
|
||||
<Fragment key={index}>
|
||||
<Section section={section} placement="sidebar" />
|
||||
</Fragment>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={composeStyles(styles.mainColumn, { rowGap: metrics.sectionGap })}>
|
||||
{mainSections.map((section, index) => (
|
||||
<Section key={index} section={section} placement="main" />
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</TemplateProvider>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
const Header = ({ styles }: { styles: AzurillStyles }) => {
|
||||
const { basics, picture } = useRender();
|
||||
const hasPicture = hasTemplatePicture(picture);
|
||||
|
||||
return (
|
||||
<View style={styles.header}>
|
||||
{hasPicture && <Image src={picture.url} style={styles.picture} />}
|
||||
|
||||
<View style={styles.headerTitle}>
|
||||
<View style={styles.headerIdentity}>
|
||||
<Heading style={styles.headerName}>{basics.name}</Heading>
|
||||
<Text>{basics.headline}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.headerContactRow}>
|
||||
{basics.email && (
|
||||
<Link src={`mailto:${basics.email}`} style={styles.headerContactItem}>
|
||||
<Icon name="envelope" />
|
||||
<Text>{basics.email}</Text>
|
||||
</Link>
|
||||
)}
|
||||
{basics.phone && (
|
||||
<Link src={`tel:${basics.phone}`} style={styles.headerContactItem}>
|
||||
<Icon name="phone" />
|
||||
<Text>{basics.phone}</Text>
|
||||
</Link>
|
||||
)}
|
||||
{basics.location && (
|
||||
<View style={styles.headerContactItem}>
|
||||
<Icon name="map-pin" />
|
||||
<Text>{basics.location}</Text>
|
||||
</View>
|
||||
)}
|
||||
{basics.website.url && (
|
||||
<Link src={basics.website.url} style={styles.headerContactItem}>
|
||||
<Icon name="globe" />
|
||||
<Text>{basics.website.label}</Text>
|
||||
</Link>
|
||||
)}
|
||||
{basics.customFields.map((field) => (
|
||||
<Link key={field.id} src={field.link} style={styles.headerContactItem}>
|
||||
<Icon name={field.icon as IconName} />
|
||||
<Text>{field.text}</Text>
|
||||
</Link>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const useAzurillTemplate = (): AzurillTemplate => {
|
||||
const { picture, metadata } = useRender();
|
||||
|
||||
return useMemo(() => {
|
||||
const foreground = rgbaStringToHex(metadata.design.colors.text);
|
||||
const background = rgbaStringToHex(metadata.design.colors.background);
|
||||
const primary = rgbaStringToHex(metadata.design.colors.primary);
|
||||
const colors: TemplateColorRoles = { foreground, background, primary };
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
|
||||
const bodyText = {
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
fontSize: metadata.typography.body.fontSize,
|
||||
fontWeight: metadata.typography.body.fontWeights[0] ?? "400",
|
||||
lineHeight: metadata.typography.body.lineHeight,
|
||||
color: foreground,
|
||||
} satisfies Style;
|
||||
|
||||
const baseStyles = StyleSheet.create({
|
||||
page: {
|
||||
flexDirection: "column",
|
||||
rowGap: metrics.headerGap,
|
||||
columnGap: metrics.columnGap,
|
||||
color: foreground,
|
||||
backgroundColor: background,
|
||||
paddingHorizontal: metrics.page.paddingHorizontal,
|
||||
paddingVertical: metrics.page.paddingVertical,
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
fontSize: metadata.typography.body.fontSize,
|
||||
lineHeight: metadata.typography.body.lineHeight,
|
||||
},
|
||||
text: bodyText,
|
||||
heading: {
|
||||
fontFamily: metadata.typography.heading.fontFamily,
|
||||
fontSize: metadata.typography.heading.fontSize,
|
||||
fontWeight: metadata.typography.heading.fontWeights.at(-1) ?? "600",
|
||||
lineHeight: metadata.typography.heading.lineHeight,
|
||||
color: foreground,
|
||||
},
|
||||
div: {
|
||||
rowGap: metrics.gapY(0.125),
|
||||
columnGap: metrics.gapX(1 / 3),
|
||||
},
|
||||
inline: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
columnGap: metrics.gapX(1 / 3),
|
||||
},
|
||||
link: {
|
||||
textDecoration: "none",
|
||||
color: foreground,
|
||||
},
|
||||
small: {
|
||||
fontSize: metadata.typography.body.fontSize * 0.875,
|
||||
},
|
||||
bold: {
|
||||
fontWeight: metadata.typography.body.fontWeights.at(-1) ?? "600",
|
||||
},
|
||||
richParagraph: {
|
||||
margin: 0,
|
||||
...bodyText,
|
||||
},
|
||||
richListItemRow: {
|
||||
flexDirection: "row",
|
||||
columnGap: metrics.gapX(1 / 3),
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
richListItemMarker: {
|
||||
width: metadata.typography.body.fontSize,
|
||||
textAlign: "right",
|
||||
...bodyText,
|
||||
},
|
||||
richListItemContent: {
|
||||
flex: 1,
|
||||
...bodyText,
|
||||
},
|
||||
splitRow: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
columnGap: metrics.gapX(2 / 3),
|
||||
},
|
||||
alignRight: {
|
||||
textAlign: "right",
|
||||
minWidth: 0,
|
||||
maxWidth: "100%",
|
||||
flexShrink: 1,
|
||||
},
|
||||
sectionHeading: {
|
||||
color: primary,
|
||||
},
|
||||
contentRow: {
|
||||
flexDirection: "row",
|
||||
},
|
||||
sidebarColumn: {},
|
||||
mainColumn: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
alignItems: "center",
|
||||
rowGap: metrics.gapY(0.5),
|
||||
},
|
||||
picture: {
|
||||
width: picture.size,
|
||||
height: picture.size,
|
||||
objectFit: "cover",
|
||||
aspectRatio: picture.aspectRatio,
|
||||
borderRadius: picture.borderRadius,
|
||||
borderColor: rgbaStringToHex(picture.borderColor),
|
||||
borderWidth: picture.borderWidth,
|
||||
shadowColor: rgbaStringToHex(picture.shadowColor),
|
||||
shadowWidth: picture.shadowWidth,
|
||||
transform: `rotate(${picture.rotation}deg)`,
|
||||
},
|
||||
headerTitle: {
|
||||
alignItems: "center",
|
||||
textAlign: "center",
|
||||
},
|
||||
headerIdentity: {
|
||||
alignItems: "center",
|
||||
textAlign: "center",
|
||||
rowGap: metrics.gapY(0.35),
|
||||
},
|
||||
headerName: {
|
||||
fontSize: metadata.typography.heading.fontSize * 1.5,
|
||||
lineHeight: 1,
|
||||
},
|
||||
headerContactRow: {
|
||||
justifyContent: "center",
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
rowGap: metrics.gapY(0.125),
|
||||
columnGap: metrics.gapX(0.5),
|
||||
},
|
||||
headerContactItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
columnGap: metrics.gapX(1 / 6),
|
||||
},
|
||||
});
|
||||
|
||||
const sectionTimelineStyles = StyleSheet.create({
|
||||
items: {
|
||||
position: "relative",
|
||||
},
|
||||
line: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 7.5,
|
||||
width: 1,
|
||||
backgroundColor: primary,
|
||||
},
|
||||
item: {
|
||||
flexDirection: "row",
|
||||
columnGap: metrics.gapX(1 / 2),
|
||||
position: "relative",
|
||||
},
|
||||
marker: {
|
||||
width: 16,
|
||||
alignItems: "center",
|
||||
},
|
||||
dot: {
|
||||
width: 9,
|
||||
height: 9,
|
||||
marginTop: 10,
|
||||
borderRadius: 999,
|
||||
borderWidth: 1,
|
||||
borderColor: primary,
|
||||
backgroundColor: background,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const foregroundFor = ({ placement, colors }: TemplateStyleContext) =>
|
||||
resolvePlacementColor({
|
||||
placement,
|
||||
defaultForeground: colors.foreground,
|
||||
sidebarForeground: colors.sidebarForeground,
|
||||
});
|
||||
|
||||
const accentFor = ({ placement, colors }: TemplateStyleContext) =>
|
||||
resolvePlacementColor({
|
||||
placement,
|
||||
defaultForeground: colors.primary,
|
||||
sidebarForeground: colors.sidebarForeground,
|
||||
});
|
||||
|
||||
const featureStyles = {
|
||||
sectionTimeline: {
|
||||
...sectionTimelineStyles,
|
||||
line: (context) => ({
|
||||
...sectionTimelineStyles.line,
|
||||
backgroundColor: accentFor(context),
|
||||
}),
|
||||
dot: (context) => ({
|
||||
...sectionTimelineStyles.dot,
|
||||
borderColor: accentFor(context),
|
||||
backgroundColor: context.colors.background,
|
||||
}),
|
||||
},
|
||||
} satisfies TemplateFeatureStyleSlots;
|
||||
|
||||
return {
|
||||
colors,
|
||||
featureStyles,
|
||||
styles: {
|
||||
...baseStyles,
|
||||
text: (context) => ({ ...baseStyles.text, color: foregroundFor(context) }),
|
||||
heading: (context) => ({ ...baseStyles.heading, color: foregroundFor(context) }),
|
||||
link: (context) => ({ ...baseStyles.link, color: foregroundFor(context) }),
|
||||
richParagraph: (context) => ({ ...baseStyles.richParagraph, color: foregroundFor(context) }),
|
||||
richListItemMarker: (context) => ({ ...baseStyles.richListItemMarker, color: foregroundFor(context) }),
|
||||
richListItemContent: (context) => ({ ...baseStyles.richListItemContent, color: foregroundFor(context) }),
|
||||
sectionHeading: (context) => ({ ...baseStyles.sectionHeading, color: accentFor(context) }),
|
||||
icon: (context) => ({
|
||||
display: metadata.page.hideIcons ? "none" : "flex",
|
||||
size: metadata.typography.body.fontSize,
|
||||
color: accentFor(context),
|
||||
}),
|
||||
} satisfies AzurillStyles,
|
||||
};
|
||||
}, [picture, metadata]);
|
||||
};
|
||||
@@ -0,0 +1,285 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { IconName } from "phosphor-icons-react-pdf/dynamic";
|
||||
import type { TemplatePageProps } from "../../document";
|
||||
import type { TemplateColorRoles, TemplateStyleSlots } from "../shared/types";
|
||||
import { Image, Page, StyleSheet, View } from "@react-pdf/renderer";
|
||||
import { useMemo } from "react";
|
||||
import { rgbaStringToHex } from "@reactive-resume/utils/color";
|
||||
import { useRender } from "../../context";
|
||||
import { TemplateProvider } from "../shared/context";
|
||||
import { filterSections } from "../shared/filtering";
|
||||
import { getTemplateMetrics } from "../shared/metrics";
|
||||
import { getTemplatePageSize } from "../shared/page-size";
|
||||
import { hasTemplatePicture } from "../shared/picture";
|
||||
import { Heading, Icon, Link, Text } from "../shared/primitives";
|
||||
import { Section } from "../shared/sections";
|
||||
import { composeStyles } from "../shared/styles";
|
||||
|
||||
type BronzorStyles = Omit<TemplateStyleSlots, "page"> & {
|
||||
page: Style;
|
||||
header: Style;
|
||||
picture: Style;
|
||||
headerTitle: Style;
|
||||
headerIdentity: Style;
|
||||
headerName: Style;
|
||||
headerContactRow: Style;
|
||||
headerContactItem: Style;
|
||||
sections: Style;
|
||||
};
|
||||
|
||||
type BronzorTemplate = {
|
||||
colors: TemplateColorRoles;
|
||||
styles: BronzorStyles;
|
||||
};
|
||||
|
||||
const getBronzorSections = ({
|
||||
mainSections,
|
||||
sidebarSections,
|
||||
fullWidth,
|
||||
}: {
|
||||
mainSections: string[];
|
||||
sidebarSections: string[];
|
||||
fullWidth: boolean;
|
||||
}) => {
|
||||
if (fullWidth) return mainSections;
|
||||
|
||||
return Array.from({ length: Math.max(mainSections.length, sidebarSections.length) }).flatMap((_, index) =>
|
||||
[sidebarSections[index], mainSections[index]].filter((section): section is string => Boolean(section)),
|
||||
);
|
||||
};
|
||||
|
||||
export const BronzorPage = ({ page, pageIndex }: TemplatePageProps) => {
|
||||
const data = useRender();
|
||||
const { metadata } = data;
|
||||
const { styles, colors } = useBronzorTemplate();
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
const pageSize = getTemplatePageSize(metadata.page.format);
|
||||
const showHeader = pageIndex === 0;
|
||||
const sidebarSections = filterSections(page.sidebar, data);
|
||||
const mainSections = filterSections(page.main, data);
|
||||
const sections = getBronzorSections({ mainSections, sidebarSections, fullWidth: page.fullWidth });
|
||||
|
||||
return (
|
||||
<Page size={pageSize} style={styles.page}>
|
||||
<TemplateProvider styles={styles} colors={colors}>
|
||||
{showHeader && <Header styles={styles} />}
|
||||
|
||||
<View style={composeStyles(styles.sections, { rowGap: metrics.sectionGap })}>
|
||||
{sections.map((section) => (
|
||||
<Section key={section} section={section} placement="main" />
|
||||
))}
|
||||
</View>
|
||||
</TemplateProvider>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
const Header = ({ styles }: { styles: BronzorStyles }) => {
|
||||
const { basics, picture } = useRender();
|
||||
const hasPicture = hasTemplatePicture(picture);
|
||||
|
||||
return (
|
||||
<View style={styles.header}>
|
||||
{hasPicture && <Image src={picture.url} style={styles.picture} />}
|
||||
|
||||
<View style={styles.headerTitle}>
|
||||
<View style={styles.headerIdentity}>
|
||||
<Heading style={styles.headerName}>{basics.name}</Heading>
|
||||
<Text>{basics.headline}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.headerContactRow}>
|
||||
{basics.email && (
|
||||
<Link src={`mailto:${basics.email}`} style={styles.headerContactItem}>
|
||||
<Icon name="envelope" />
|
||||
<Text>{basics.email}</Text>
|
||||
</Link>
|
||||
)}
|
||||
{basics.phone && (
|
||||
<Link src={`tel:${basics.phone}`} style={styles.headerContactItem}>
|
||||
<Icon name="phone" />
|
||||
<Text>{basics.phone}</Text>
|
||||
</Link>
|
||||
)}
|
||||
{basics.location && (
|
||||
<View style={styles.headerContactItem}>
|
||||
<Icon name="map-pin" />
|
||||
<Text>{basics.location}</Text>
|
||||
</View>
|
||||
)}
|
||||
{basics.website.url && (
|
||||
<Link src={basics.website.url} style={styles.headerContactItem}>
|
||||
<Icon name="globe" />
|
||||
<Text>{basics.website.label}</Text>
|
||||
</Link>
|
||||
)}
|
||||
{basics.customFields.map((field) => (
|
||||
<Link key={field.id} src={field.link} style={styles.headerContactItem}>
|
||||
<Icon name={field.icon as IconName} />
|
||||
<Text>{field.text}</Text>
|
||||
</Link>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const useBronzorTemplate = (): BronzorTemplate => {
|
||||
const { picture, metadata } = useRender();
|
||||
|
||||
return useMemo(() => {
|
||||
const foreground = rgbaStringToHex(metadata.design.colors.text);
|
||||
const background = rgbaStringToHex(metadata.design.colors.background);
|
||||
const primary = rgbaStringToHex(metadata.design.colors.primary);
|
||||
const colors: TemplateColorRoles = { foreground, background, primary };
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
|
||||
const bodyText = {
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
fontSize: metadata.typography.body.fontSize,
|
||||
fontWeight: metadata.typography.body.fontWeights[0] ?? "400",
|
||||
lineHeight: metadata.typography.body.lineHeight,
|
||||
color: foreground,
|
||||
} satisfies Style;
|
||||
|
||||
const baseStyles = StyleSheet.create({
|
||||
page: {
|
||||
flexDirection: "column",
|
||||
rowGap: metrics.headerGap,
|
||||
color: foreground,
|
||||
backgroundColor: background,
|
||||
paddingHorizontal: metrics.page.paddingHorizontal,
|
||||
paddingVertical: metrics.page.paddingVertical,
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
fontSize: metadata.typography.body.fontSize,
|
||||
lineHeight: metadata.typography.body.lineHeight,
|
||||
},
|
||||
text: bodyText,
|
||||
heading: {
|
||||
fontFamily: metadata.typography.heading.fontFamily,
|
||||
fontSize: metadata.typography.heading.fontSize,
|
||||
fontWeight: metadata.typography.heading.fontWeights[0] ?? "500",
|
||||
lineHeight: metadata.typography.heading.lineHeight,
|
||||
color: foreground,
|
||||
},
|
||||
div: {
|
||||
rowGap: metrics.gapY(0.125),
|
||||
columnGap: metrics.gapX(1 / 3),
|
||||
},
|
||||
inline: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
columnGap: metrics.gapX(1 / 3),
|
||||
},
|
||||
link: {
|
||||
textDecoration: "none",
|
||||
color: foreground,
|
||||
},
|
||||
small: {
|
||||
fontSize: metadata.typography.body.fontSize * 0.875,
|
||||
},
|
||||
bold: {
|
||||
fontWeight: metadata.typography.body.fontWeights.at(-1) ?? "600",
|
||||
},
|
||||
richParagraph: {
|
||||
margin: 0,
|
||||
...bodyText,
|
||||
},
|
||||
richListItemRow: {
|
||||
flexDirection: "row",
|
||||
columnGap: metrics.gapX(1 / 3),
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
richListItemMarker: {
|
||||
width: metadata.typography.body.fontSize,
|
||||
textAlign: "right",
|
||||
...bodyText,
|
||||
},
|
||||
richListItemContent: {
|
||||
flex: 1,
|
||||
...bodyText,
|
||||
},
|
||||
splitRow: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
columnGap: metrics.gapX(2 / 3),
|
||||
},
|
||||
alignRight: {
|
||||
textAlign: "right",
|
||||
minWidth: 0,
|
||||
maxWidth: "100%",
|
||||
flexShrink: 1,
|
||||
},
|
||||
section: {
|
||||
flexDirection: "row",
|
||||
columnGap: metrics.columnGap,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: primary,
|
||||
paddingTop: metrics.gapY(0.5),
|
||||
},
|
||||
sectionHeading: {
|
||||
width: `${metadata.layout.sidebarWidth}%`,
|
||||
flexShrink: 0,
|
||||
fontSize: metadata.typography.heading.fontSize * 0.75,
|
||||
color: primary,
|
||||
},
|
||||
sectionItems: {
|
||||
flex: 1,
|
||||
},
|
||||
sections: {
|
||||
flexDirection: "column",
|
||||
},
|
||||
header: {
|
||||
alignItems: "center",
|
||||
rowGap: metrics.gapY(0.5),
|
||||
},
|
||||
picture: {
|
||||
width: picture.size,
|
||||
height: picture.size,
|
||||
objectFit: "cover",
|
||||
aspectRatio: picture.aspectRatio,
|
||||
borderRadius: picture.borderRadius,
|
||||
borderColor: rgbaStringToHex(picture.borderColor),
|
||||
borderWidth: picture.borderWidth,
|
||||
shadowColor: rgbaStringToHex(picture.shadowColor),
|
||||
shadowWidth: picture.shadowWidth,
|
||||
transform: `rotate(${picture.rotation}deg)`,
|
||||
},
|
||||
headerTitle: {
|
||||
alignItems: "center",
|
||||
textAlign: "center",
|
||||
},
|
||||
headerIdentity: {
|
||||
alignItems: "center",
|
||||
textAlign: "center",
|
||||
rowGap: metrics.gapY(0.35),
|
||||
},
|
||||
headerName: {
|
||||
fontSize: metadata.typography.heading.fontSize * 1.5,
|
||||
lineHeight: 1,
|
||||
},
|
||||
headerContactRow: {
|
||||
justifyContent: "center",
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
rowGap: metrics.gapY(0.125),
|
||||
columnGap: metrics.gapX(5 / 6),
|
||||
},
|
||||
headerContactItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
columnGap: metrics.gapX(0.25),
|
||||
},
|
||||
icon: {
|
||||
display: metadata.page.hideIcons ? "none" : "flex",
|
||||
size: metadata.typography.body.fontSize,
|
||||
color: primary,
|
||||
},
|
||||
});
|
||||
|
||||
return { colors, styles: baseStyles satisfies BronzorStyles };
|
||||
}, [picture, metadata]);
|
||||
};
|
||||
@@ -0,0 +1,359 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { IconName } from "phosphor-icons-react-pdf/dynamic";
|
||||
import type { TemplatePageProps } from "../../document";
|
||||
import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
|
||||
import { Image, Page, StyleSheet, View } from "@react-pdf/renderer";
|
||||
import { Fragment, useMemo } from "react";
|
||||
import { rgbaStringToHex } from "@reactive-resume/utils/color";
|
||||
import { useRender } from "../../context";
|
||||
import { TemplateProvider } from "../shared/context";
|
||||
import { filterSections } from "../shared/filtering";
|
||||
import { getTemplateMetrics } from "../shared/metrics";
|
||||
import { getTemplatePageSize } from "../shared/page-size";
|
||||
import { hasTemplatePicture } from "../shared/picture";
|
||||
import { Heading, Icon, Link, Text } from "../shared/primitives";
|
||||
import { Section } from "../shared/sections";
|
||||
import { composeStyles, resolvePlacementColor } from "../shared/styles";
|
||||
|
||||
type ChikoritaStyles = Omit<TemplateStyleSlots, "page"> & {
|
||||
page: Style;
|
||||
mainColumn: Style;
|
||||
sidebarColumn: Style;
|
||||
header: Style;
|
||||
picture: Style;
|
||||
headerTitle: Style;
|
||||
headerIdentity: Style;
|
||||
headerName: Style;
|
||||
headerContactList: Style;
|
||||
headerContactRow: Style;
|
||||
headerContactItem: Style;
|
||||
};
|
||||
|
||||
type ChikoritaTemplate = {
|
||||
colors: TemplateColorRoles;
|
||||
styles: ChikoritaStyles;
|
||||
};
|
||||
|
||||
export const ChikoritaPage = ({ page, pageIndex }: TemplatePageProps) => {
|
||||
const data = useRender();
|
||||
const { metadata, picture } = data;
|
||||
const { colors, styles } = useChikoritaTemplate();
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
const pageSize = getTemplatePageSize(metadata.page.format);
|
||||
const hasPicture = hasTemplatePicture(picture);
|
||||
const showHeader = pageIndex === 0;
|
||||
const sidebarSections = filterSections(page.sidebar, data);
|
||||
const mainSections = filterSections(page.main, data);
|
||||
|
||||
return (
|
||||
<Page size={pageSize} style={styles.page}>
|
||||
<TemplateProvider styles={styles} colors={colors}>
|
||||
<View
|
||||
style={composeStyles(styles.mainColumn, {
|
||||
paddingTop: metrics.page.paddingVertical,
|
||||
paddingRight: page.fullWidth ? metrics.page.paddingHorizontal : metrics.columnGap,
|
||||
paddingBottom: metrics.page.paddingVertical,
|
||||
paddingLeft: metrics.page.paddingHorizontal,
|
||||
rowGap: metrics.sectionGap,
|
||||
})}
|
||||
>
|
||||
{showHeader && <Header styles={styles} />}
|
||||
|
||||
{mainSections.map((section, index) => (
|
||||
<Section key={index} section={section} placement="main" />
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={composeStyles(styles.sidebarColumn, {
|
||||
display: page.fullWidth ? "none" : "flex",
|
||||
flexBasis: `${metadata.layout.sidebarWidth}%`,
|
||||
paddingTop:
|
||||
showHeader && hasPicture
|
||||
? metrics.page.paddingVertical + picture.size + metrics.itemGapY * 3
|
||||
: metrics.page.paddingVertical,
|
||||
paddingRight: metrics.page.paddingHorizontal,
|
||||
paddingBottom: metrics.page.paddingVertical,
|
||||
paddingLeft: metrics.columnGap,
|
||||
rowGap: metrics.sectionGap,
|
||||
})}
|
||||
>
|
||||
{sidebarSections.map((section, index) => (
|
||||
<Fragment key={index}>
|
||||
<Section section={section} placement="sidebar" />
|
||||
</Fragment>
|
||||
))}
|
||||
</View>
|
||||
</TemplateProvider>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
const Header = ({ styles }: { styles: ChikoritaStyles }) => {
|
||||
const { basics, picture } = useRender();
|
||||
const hasPicture = hasTemplatePicture(picture);
|
||||
|
||||
return (
|
||||
<View style={styles.header}>
|
||||
{hasPicture && <Image src={picture.url} style={styles.picture} />}
|
||||
|
||||
<View style={styles.headerTitle}>
|
||||
<View style={styles.headerIdentity}>
|
||||
<Heading style={styles.headerName}>{basics.name}</Heading>
|
||||
<Text>{basics.headline}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.headerContactList}>
|
||||
<View style={styles.headerContactRow}>
|
||||
{basics.email && (
|
||||
<Link src={`mailto:${basics.email}`} style={styles.headerContactItem}>
|
||||
<Icon name="envelope" />
|
||||
<Text>{basics.email}</Text>
|
||||
</Link>
|
||||
)}
|
||||
{basics.phone && (
|
||||
<Link src={`tel:${basics.phone}`} style={styles.headerContactItem}>
|
||||
<Icon name="phone" />
|
||||
<Text>{basics.phone}</Text>
|
||||
</Link>
|
||||
)}
|
||||
{basics.location && (
|
||||
<View style={styles.headerContactItem}>
|
||||
<Icon name="map-pin" />
|
||||
<Text>{basics.location}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.headerContactRow}>
|
||||
{basics.website.url && (
|
||||
<Link src={basics.website.url} style={styles.headerContactItem}>
|
||||
<Icon name="globe" />
|
||||
<Text>{basics.website.label}</Text>
|
||||
</Link>
|
||||
)}
|
||||
{basics.customFields.map((field) => (
|
||||
<Link key={field.id} src={field.link} style={styles.headerContactItem}>
|
||||
<Icon name={field.icon as IconName} />
|
||||
<Text>{field.text}</Text>
|
||||
</Link>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const useChikoritaTemplate = (): ChikoritaTemplate => {
|
||||
const { picture, metadata } = useRender();
|
||||
|
||||
return useMemo(() => {
|
||||
const foreground = rgbaStringToHex(metadata.design.colors.text);
|
||||
const background = rgbaStringToHex(metadata.design.colors.background);
|
||||
const primary = rgbaStringToHex(metadata.design.colors.primary);
|
||||
const colors: TemplateColorRoles = {
|
||||
foreground,
|
||||
background,
|
||||
primary,
|
||||
sidebarForeground: background,
|
||||
sidebarBackground: primary,
|
||||
};
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
|
||||
const bodyText = {
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
fontSize: metadata.typography.body.fontSize,
|
||||
fontWeight: metadata.typography.body.fontWeights[0] ?? "400",
|
||||
lineHeight: metadata.typography.body.lineHeight,
|
||||
color: foreground,
|
||||
} satisfies Style;
|
||||
|
||||
const baseStyles = StyleSheet.create({
|
||||
page: {
|
||||
flexDirection: "row",
|
||||
color: foreground,
|
||||
backgroundColor: background,
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
fontSize: metadata.typography.body.fontSize,
|
||||
lineHeight: metadata.typography.body.lineHeight,
|
||||
},
|
||||
text: bodyText,
|
||||
heading: {
|
||||
fontFamily: metadata.typography.heading.fontFamily,
|
||||
fontSize: metadata.typography.heading.fontSize,
|
||||
fontWeight: metadata.typography.heading.fontWeights.at(-1) ?? "600",
|
||||
lineHeight: metadata.typography.heading.lineHeight,
|
||||
color: foreground,
|
||||
},
|
||||
div: {
|
||||
rowGap: metrics.gapY(0.125),
|
||||
columnGap: metrics.gapX(1 / 3),
|
||||
},
|
||||
inline: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
columnGap: metrics.gapX(0.25),
|
||||
},
|
||||
link: {
|
||||
textDecoration: "none",
|
||||
color: foreground,
|
||||
},
|
||||
small: {
|
||||
fontSize: metadata.typography.body.fontSize * 0.875,
|
||||
},
|
||||
bold: {
|
||||
fontWeight: metadata.typography.body.fontWeights.at(-1) ?? "600",
|
||||
},
|
||||
richParagraph: {
|
||||
margin: 0,
|
||||
...bodyText,
|
||||
},
|
||||
richListItemRow: {
|
||||
flexDirection: "row",
|
||||
columnGap: metrics.gapX(1 / 3),
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
richListItemMarker: {
|
||||
width: metadata.typography.body.fontSize,
|
||||
textAlign: "right",
|
||||
...bodyText,
|
||||
},
|
||||
richListItemContent: {
|
||||
flex: 1,
|
||||
...bodyText,
|
||||
},
|
||||
splitRow: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
columnGap: metrics.gapX(2 / 3),
|
||||
},
|
||||
alignRight: {
|
||||
textAlign: "right",
|
||||
minWidth: 0,
|
||||
maxWidth: "100%",
|
||||
flexShrink: 1,
|
||||
},
|
||||
section: {
|
||||
flexDirection: "column",
|
||||
rowGap: metrics.gapY(0.25),
|
||||
},
|
||||
sectionHeading: {
|
||||
fontSize: metadata.typography.heading.fontSize * 0.85,
|
||||
color: primary,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: primary,
|
||||
paddingBottom: metrics.gapY(0.125),
|
||||
},
|
||||
sectionItems: {
|
||||
paddingTop: metrics.gapY(0.375),
|
||||
},
|
||||
item: {
|
||||
rowGap: metrics.gapY(0.125),
|
||||
},
|
||||
levelContainer: {
|
||||
width: "70%",
|
||||
},
|
||||
mainColumn: {
|
||||
flex: 1,
|
||||
},
|
||||
sidebarColumn: {
|
||||
backgroundColor: primary,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
columnGap: metrics.gapX(0.5),
|
||||
},
|
||||
picture: {
|
||||
width: picture.size,
|
||||
height: picture.size,
|
||||
objectFit: "cover",
|
||||
aspectRatio: picture.aspectRatio,
|
||||
borderRadius: picture.borderRadius,
|
||||
borderColor: rgbaStringToHex(picture.borderColor),
|
||||
borderWidth: picture.borderWidth,
|
||||
shadowColor: rgbaStringToHex(picture.shadowColor),
|
||||
shadowWidth: picture.shadowWidth,
|
||||
transform: `rotate(${picture.rotation}deg)`,
|
||||
},
|
||||
headerTitle: {
|
||||
flex: 1,
|
||||
rowGap: metrics.gapY(0.5),
|
||||
},
|
||||
headerIdentity: {
|
||||
textAlign: "left",
|
||||
alignItems: "flex-start",
|
||||
rowGap: metrics.gapY(0.35),
|
||||
},
|
||||
headerName: {
|
||||
fontSize: metadata.typography.heading.fontSize * 1.5,
|
||||
lineHeight: 1,
|
||||
},
|
||||
headerContactList: {
|
||||
rowGap: metrics.gapY(0.125),
|
||||
},
|
||||
headerContactRow: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
columnGap: metrics.gapX(2 / 3),
|
||||
rowGap: metrics.gapY(0.125),
|
||||
},
|
||||
headerContactItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
columnGap: metrics.gapX(1 / 6),
|
||||
},
|
||||
});
|
||||
|
||||
const foregroundFor = ({ placement, colors }: TemplateStyleContext) =>
|
||||
resolvePlacementColor({
|
||||
placement,
|
||||
defaultForeground: colors.foreground,
|
||||
sidebarForeground: colors.sidebarForeground,
|
||||
});
|
||||
const accentFor = ({ placement, colors }: TemplateStyleContext) =>
|
||||
resolvePlacementColor({
|
||||
placement,
|
||||
defaultForeground: colors.primary,
|
||||
sidebarForeground: colors.sidebarForeground,
|
||||
});
|
||||
|
||||
return {
|
||||
colors,
|
||||
styles: {
|
||||
...baseStyles,
|
||||
text: (context) => ({ ...baseStyles.text, color: foregroundFor(context) }),
|
||||
heading: (context) => ({ ...baseStyles.heading, color: foregroundFor(context) }),
|
||||
link: (context) => ({ ...baseStyles.link, color: foregroundFor(context) }),
|
||||
richParagraph: (context) => ({ ...baseStyles.richParagraph, color: foregroundFor(context) }),
|
||||
richListItemMarker: (context) => ({ ...baseStyles.richListItemMarker, color: foregroundFor(context) }),
|
||||
richListItemContent: (context) => ({ ...baseStyles.richListItemContent, color: foregroundFor(context) }),
|
||||
splitRow: (context) => ({
|
||||
...baseStyles.splitRow,
|
||||
...(context.placement === "sidebar"
|
||||
? { flexDirection: "column", alignItems: "flex-start", justifyContent: "flex-start" }
|
||||
: {}),
|
||||
}),
|
||||
alignRight: (context) => ({
|
||||
...baseStyles.alignRight,
|
||||
...(context.placement === "sidebar" ? { textAlign: "left" } : {}),
|
||||
}),
|
||||
sectionHeading: (context) => ({
|
||||
...baseStyles.sectionHeading,
|
||||
color: accentFor(context),
|
||||
borderBottomColor: accentFor(context),
|
||||
}),
|
||||
levelItem: (context) => ({ borderColor: accentFor(context) }),
|
||||
levelItemActive: (context) => ({ backgroundColor: accentFor(context) }),
|
||||
icon: (context) => ({
|
||||
display: metadata.page.hideIcons ? "none" : "flex",
|
||||
size: metadata.typography.body.fontSize,
|
||||
color: accentFor(context),
|
||||
}),
|
||||
} satisfies ChikoritaStyles,
|
||||
};
|
||||
}, [picture, metadata]);
|
||||
};
|
||||
@@ -0,0 +1,337 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { IconName } from "phosphor-icons-react-pdf/dynamic";
|
||||
import type { TemplatePageProps } from "../../document";
|
||||
import type { TemplateColorRoles, TemplateFeatures, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
|
||||
import { Image, Page, StyleSheet, View } from "@react-pdf/renderer";
|
||||
import { useMemo } from "react";
|
||||
import { parseColorString, rgbaStringToHex } from "@reactive-resume/utils/color";
|
||||
import { useRender } from "../../context";
|
||||
import { TemplateProvider } from "../shared/context";
|
||||
import { filterSections } from "../shared/filtering";
|
||||
import { getTemplateMetrics } from "../shared/metrics";
|
||||
import { getTemplatePageSize } from "../shared/page-size";
|
||||
import { hasTemplatePicture } from "../shared/picture";
|
||||
import { Heading, Icon, Link, Text } from "../shared/primitives";
|
||||
import { Section } from "../shared/sections";
|
||||
import { composeStyles, resolvePlacementColor } from "../shared/styles";
|
||||
|
||||
type DitgarStyles = Omit<TemplateStyleSlots, "page"> & {
|
||||
page: Style;
|
||||
sidebarColumn: Style;
|
||||
sidebarContent: Style;
|
||||
mainColumn: Style;
|
||||
mainContent: Style;
|
||||
specialContainer: Style;
|
||||
header: Style;
|
||||
picture: Style;
|
||||
headerTitle: Style;
|
||||
headerIdentity: Style;
|
||||
headerName: Style;
|
||||
headerText: Style;
|
||||
contactList: Style;
|
||||
contactItem: Style;
|
||||
};
|
||||
|
||||
type DitgarTemplate = {
|
||||
colors: TemplateColorRoles;
|
||||
styles: DitgarStyles;
|
||||
};
|
||||
|
||||
const ditgarFeatures = {
|
||||
stackSidebarItemHeader: true,
|
||||
mainItemHeaderBorder: true,
|
||||
} satisfies TemplateFeatures;
|
||||
|
||||
export const DitgarPage = ({ page, pageIndex }: TemplatePageProps) => {
|
||||
const data = useRender();
|
||||
const { metadata } = data;
|
||||
const { colors, styles } = useDitgarTemplate();
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
const pageSize = getTemplatePageSize(metadata.page.format);
|
||||
const showHeader = pageIndex === 0;
|
||||
const showSidebar = !page.fullWidth || showHeader;
|
||||
const sidebarSections = filterSections(page.sidebar, data);
|
||||
const mainSections = filterSections(page.main, data);
|
||||
const specialMainSection = showHeader ? mainSections[0] : undefined;
|
||||
const regularMainSections = showHeader ? mainSections.slice(1) : mainSections;
|
||||
|
||||
return (
|
||||
<Page size={pageSize} style={styles.page}>
|
||||
<TemplateProvider styles={styles} colors={colors} features={ditgarFeatures}>
|
||||
{showSidebar && (
|
||||
<View
|
||||
style={composeStyles(styles.sidebarColumn, {
|
||||
width: `${metadata.layout.sidebarWidth}%`,
|
||||
})}
|
||||
>
|
||||
{showHeader && <Header styles={styles} colors={colors} />}
|
||||
|
||||
{!page.fullWidth && (
|
||||
<View style={composeStyles(styles.sidebarContent, { rowGap: metrics.sectionGap })}>
|
||||
{sidebarSections.map((section, index) => (
|
||||
<Section key={index} section={section} placement="sidebar" />
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.mainColumn}>
|
||||
{specialMainSection && (
|
||||
<View style={styles.specialContainer}>
|
||||
<Section section={specialMainSection} placement="main" showHeading={specialMainSection !== "summary"} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={composeStyles(styles.mainContent, { rowGap: metrics.sectionGap })}>
|
||||
{regularMainSections.map((section, index) => (
|
||||
<Section key={index} section={section} placement="main" />
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</TemplateProvider>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
const Header = ({ styles, colors }: { styles: DitgarStyles; colors: TemplateColorRoles }) => {
|
||||
const { basics, picture } = useRender();
|
||||
const hasPicture = hasTemplatePicture(picture);
|
||||
|
||||
return (
|
||||
<View style={styles.header}>
|
||||
{hasPicture && <Image src={picture.url} style={styles.picture} />}
|
||||
|
||||
<View style={styles.headerTitle}>
|
||||
<View style={styles.headerIdentity}>
|
||||
<Heading style={styles.headerName}>{basics.name}</Heading>
|
||||
<Text style={styles.headerText}>{basics.headline}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.contactList}>
|
||||
{basics.email && (
|
||||
<Link src={`mailto:${basics.email}`} style={styles.contactItem}>
|
||||
<Icon name="at" color={colors.background} />
|
||||
<Text style={styles.headerText}>{basics.email}</Text>
|
||||
</Link>
|
||||
)}
|
||||
{basics.phone && (
|
||||
<Link src={`tel:${basics.phone}`} style={styles.contactItem}>
|
||||
<Icon name="phone" color={colors.background} />
|
||||
<Text style={styles.headerText}>{basics.phone}</Text>
|
||||
</Link>
|
||||
)}
|
||||
{basics.location && (
|
||||
<View style={styles.contactItem}>
|
||||
<Icon name="map-pin" color={colors.background} />
|
||||
<Text style={styles.headerText}>{basics.location}</Text>
|
||||
</View>
|
||||
)}
|
||||
{basics.website.url && (
|
||||
<Link src={basics.website.url} style={styles.contactItem}>
|
||||
<Icon name="globe" color={colors.background} />
|
||||
<Text style={styles.headerText}>{basics.website.label}</Text>
|
||||
</Link>
|
||||
)}
|
||||
{basics.customFields.map((field) => (
|
||||
<Link key={field.id} src={field.link} style={styles.contactItem}>
|
||||
<Icon name={field.icon as IconName} color={colors.background} />
|
||||
<Text style={styles.headerText}>{field.text}</Text>
|
||||
</Link>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const getPrimaryTint = (primaryColor: string, opacity: number): string => {
|
||||
const primary = parseColorString(primaryColor);
|
||||
|
||||
if (!primary) return rgbaStringToHex(primaryColor);
|
||||
|
||||
const alpha = Math.max(0, Math.min(1, primary.a * opacity));
|
||||
|
||||
return `rgba(${primary.r}, ${primary.g}, ${primary.b}, ${alpha})`;
|
||||
};
|
||||
|
||||
const useDitgarTemplate = (): DitgarTemplate => {
|
||||
const { picture, metadata } = useRender();
|
||||
|
||||
return useMemo(() => {
|
||||
const foreground = rgbaStringToHex(metadata.design.colors.text);
|
||||
const background = rgbaStringToHex(metadata.design.colors.background);
|
||||
const primary = rgbaStringToHex(metadata.design.colors.primary);
|
||||
const primaryTint = getPrimaryTint(metadata.design.colors.primary, 0.2);
|
||||
const colors: TemplateColorRoles = {
|
||||
foreground,
|
||||
background,
|
||||
primary,
|
||||
sidebarForeground: foreground,
|
||||
sidebarBackground: primaryTint,
|
||||
};
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
|
||||
const bodyText = {
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
fontSize: metadata.typography.body.fontSize,
|
||||
fontWeight: metadata.typography.body.fontWeights[0] ?? "400",
|
||||
lineHeight: metadata.typography.body.lineHeight,
|
||||
color: foreground,
|
||||
} satisfies Style;
|
||||
|
||||
const baseStyles = StyleSheet.create({
|
||||
page: {
|
||||
flexDirection: "row",
|
||||
color: foreground,
|
||||
backgroundColor: background,
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
fontSize: metadata.typography.body.fontSize,
|
||||
lineHeight: metadata.typography.body.lineHeight,
|
||||
},
|
||||
text: bodyText,
|
||||
heading: {
|
||||
fontFamily: metadata.typography.heading.fontFamily,
|
||||
fontSize: metadata.typography.heading.fontSize,
|
||||
fontWeight: metadata.typography.heading.fontWeights.at(-1) ?? "600",
|
||||
lineHeight: metadata.typography.heading.lineHeight,
|
||||
color: foreground,
|
||||
},
|
||||
div: { rowGap: metrics.gapY(0.125), columnGap: metrics.gapX(1 / 3) },
|
||||
inline: { flexDirection: "row", alignItems: "center", columnGap: metrics.gapX(1 / 3) },
|
||||
link: { textDecoration: "none", color: foreground },
|
||||
small: { fontSize: metadata.typography.body.fontSize * 0.875 },
|
||||
bold: { fontWeight: metadata.typography.body.fontWeights.at(-1) ?? "600" },
|
||||
richParagraph: { margin: 0, ...bodyText },
|
||||
richListItemRow: { flexDirection: "row", columnGap: metrics.gapX(1 / 3), alignItems: "flex-start" },
|
||||
richListItemMarker: { width: metadata.typography.body.fontSize, textAlign: "right", ...bodyText },
|
||||
richListItemContent: { flex: 1, ...bodyText },
|
||||
splitRow: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
columnGap: metrics.gapX(2 / 3),
|
||||
},
|
||||
alignRight: { textAlign: "right", minWidth: 0, maxWidth: "100%", flexShrink: 1 },
|
||||
section: { flexDirection: "column", rowGap: metrics.gapY(0.25) },
|
||||
sectionHeading: {
|
||||
fontSize: metadata.typography.heading.fontSize * 0.9,
|
||||
color: primary,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: primary,
|
||||
paddingBottom: metrics.gapY(0.125),
|
||||
},
|
||||
item: { rowGap: metrics.gapY(0.125) },
|
||||
levelContainer: { width: "70%" },
|
||||
levelItem: { borderColor: primary },
|
||||
levelItemActive: { backgroundColor: primary },
|
||||
sidebarColumn: {
|
||||
flexShrink: 0,
|
||||
backgroundColor: primaryTint,
|
||||
},
|
||||
sidebarContent: {
|
||||
paddingHorizontal: metrics.page.paddingHorizontal,
|
||||
paddingTop: metrics.page.paddingVertical,
|
||||
paddingBottom: metrics.page.paddingVertical,
|
||||
},
|
||||
mainColumn: { flex: 1 },
|
||||
mainContent: {
|
||||
paddingHorizontal: metrics.page.paddingHorizontal,
|
||||
paddingTop: metrics.page.paddingVertical,
|
||||
paddingBottom: metrics.page.paddingVertical,
|
||||
},
|
||||
specialContainer: {
|
||||
backgroundColor: primaryTint,
|
||||
paddingHorizontal: metrics.page.paddingHorizontal,
|
||||
paddingVertical: metrics.page.paddingVertical,
|
||||
},
|
||||
header: {
|
||||
backgroundColor: primary,
|
||||
color: background,
|
||||
paddingHorizontal: metrics.page.paddingHorizontal,
|
||||
paddingVertical: metrics.page.paddingVertical,
|
||||
rowGap: metrics.gapY(0.5),
|
||||
},
|
||||
picture: {
|
||||
width: picture.size,
|
||||
height: picture.size,
|
||||
objectFit: "cover",
|
||||
aspectRatio: picture.aspectRatio,
|
||||
borderRadius: picture.borderRadius,
|
||||
borderColor: rgbaStringToHex(picture.borderColor),
|
||||
borderWidth: picture.borderWidth,
|
||||
shadowColor: rgbaStringToHex(picture.shadowColor),
|
||||
shadowWidth: picture.shadowWidth,
|
||||
transform: `rotate(${picture.rotation}deg)`,
|
||||
},
|
||||
headerTitle: {},
|
||||
headerIdentity: {
|
||||
textAlign: "left",
|
||||
alignItems: "flex-start",
|
||||
rowGap: metrics.gapY(0.35),
|
||||
},
|
||||
headerName: {
|
||||
fontSize: metadata.typography.heading.fontSize * 1.5,
|
||||
lineHeight: 1,
|
||||
color: background,
|
||||
},
|
||||
headerText: { color: background },
|
||||
contactList: { rowGap: metrics.gapY(0.125) },
|
||||
contactItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
columnGap: metrics.gapX(1 / 6),
|
||||
},
|
||||
});
|
||||
|
||||
const foregroundFor = ({ placement, colors }: TemplateStyleContext) =>
|
||||
resolvePlacementColor({
|
||||
placement,
|
||||
defaultForeground: colors.foreground,
|
||||
sidebarForeground: colors.sidebarForeground,
|
||||
});
|
||||
|
||||
const accentFor = ({ colors }: TemplateStyleContext) => colors.primary;
|
||||
|
||||
return {
|
||||
colors,
|
||||
styles: {
|
||||
...baseStyles,
|
||||
text: (context) => ({ ...baseStyles.text, color: foregroundFor(context) }),
|
||||
heading: (context) => ({ ...baseStyles.heading, color: foregroundFor(context) }),
|
||||
link: (context) => ({ ...baseStyles.link, color: foregroundFor(context) }),
|
||||
richParagraph: (context) => ({ ...baseStyles.richParagraph, color: foregroundFor(context) }),
|
||||
richListItemMarker: (context) => ({ ...baseStyles.richListItemMarker, color: foregroundFor(context) }),
|
||||
richListItemContent: (context) => ({ ...baseStyles.richListItemContent, color: foregroundFor(context) }),
|
||||
alignRight: (context) => ({
|
||||
...baseStyles.alignRight,
|
||||
...(context.placement === "sidebar" ? { textAlign: "left" } : {}),
|
||||
}),
|
||||
sectionHeading: (context) => ({
|
||||
...baseStyles.sectionHeading,
|
||||
color: accentFor(context),
|
||||
borderBottomColor: accentFor(context),
|
||||
}),
|
||||
sectionItemHeader: (context) => ({
|
||||
...(context.placement === "main"
|
||||
? {
|
||||
borderLeftWidth: 2,
|
||||
borderLeftColor: accentFor(context),
|
||||
paddingLeft: metrics.gapX(0.5),
|
||||
paddingVertical: metrics.gapY(0.125),
|
||||
marginLeft: -metrics.gapX(0.625),
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
levelItem: (context) => ({ borderColor: accentFor(context) }),
|
||||
levelItemActive: (context) => ({ backgroundColor: accentFor(context) }),
|
||||
icon: (context) => ({
|
||||
display: metadata.page.hideIcons ? "none" : "flex",
|
||||
size: metadata.typography.body.fontSize,
|
||||
color: context.placement === "sidebar" ? foreground : primary,
|
||||
}),
|
||||
} satisfies DitgarStyles,
|
||||
};
|
||||
}, [picture, metadata]);
|
||||
};
|
||||
@@ -0,0 +1,372 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { IconName } from "phosphor-icons-react-pdf/dynamic";
|
||||
import type { TemplatePageProps } from "../../document";
|
||||
import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
|
||||
import { Image, Page, StyleSheet, View } from "@react-pdf/renderer";
|
||||
import { Fragment, useMemo } from "react";
|
||||
import { rgbaStringToHex } from "@reactive-resume/utils/color";
|
||||
import { useRender } from "../../context";
|
||||
import { TemplateProvider } from "../shared/context";
|
||||
import { filterSections } from "../shared/filtering";
|
||||
import { getTemplateMetrics } from "../shared/metrics";
|
||||
import { getTemplatePageSize } from "../shared/page-size";
|
||||
import { hasTemplatePicture } from "../shared/picture";
|
||||
import { Heading, Icon, Link, Text } from "../shared/primitives";
|
||||
import { Section } from "../shared/sections";
|
||||
import { composeStyles } from "../shared/styles";
|
||||
|
||||
type DittoStyles = Omit<TemplateStyleSlots, "page"> & {
|
||||
page: Style;
|
||||
header: Style;
|
||||
headerBand: Style;
|
||||
pictureAnchor: Style;
|
||||
picture: Style;
|
||||
headerTitle: Style;
|
||||
headerIdentity: Style;
|
||||
headerName: Style;
|
||||
headerHeadline: Style;
|
||||
contactRow: Style;
|
||||
contactOffset: Style;
|
||||
contactList: Style;
|
||||
contactItem: Style;
|
||||
contentRow: Style;
|
||||
sidebarColumn: Style;
|
||||
mainColumn: Style;
|
||||
};
|
||||
|
||||
type DittoTemplate = {
|
||||
colors: TemplateColorRoles;
|
||||
styles: DittoStyles;
|
||||
};
|
||||
|
||||
export const DittoPage = ({ page, pageIndex }: TemplatePageProps) => {
|
||||
const data = useRender();
|
||||
const { metadata, picture } = data;
|
||||
const { colors, styles } = useDittoTemplate();
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
const pageSize = getTemplatePageSize(metadata.page.format);
|
||||
const hasPicture = hasTemplatePicture(picture);
|
||||
const showHeader = pageIndex === 0;
|
||||
const sidebarSections = filterSections(page.sidebar, data);
|
||||
const mainSections = filterSections(page.main, data);
|
||||
|
||||
return (
|
||||
<Page size={pageSize} style={styles.page}>
|
||||
<TemplateProvider styles={styles} colors={colors}>
|
||||
{showHeader && <Header styles={styles} />}
|
||||
|
||||
<View style={composeStyles(styles.contentRow, { paddingTop: metrics.headerGap })}>
|
||||
<View
|
||||
style={composeStyles(styles.sidebarColumn, {
|
||||
display: page.fullWidth ? "none" : "flex",
|
||||
width: `${metadata.layout.sidebarWidth}%`,
|
||||
paddingLeft: metrics.page.paddingHorizontal,
|
||||
paddingTop: showHeader && hasPicture ? metrics.page.paddingVertical : 0,
|
||||
rowGap: metrics.sectionGap,
|
||||
})}
|
||||
>
|
||||
{sidebarSections.map((section, index) => (
|
||||
<Fragment key={index}>
|
||||
<Section section={section} placement="sidebar" />
|
||||
</Fragment>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={composeStyles(styles.mainColumn, {
|
||||
paddingLeft: metrics.columnGap,
|
||||
paddingRight: metrics.page.paddingHorizontal,
|
||||
rowGap: metrics.sectionGap,
|
||||
})}
|
||||
>
|
||||
{mainSections.map((section, index) => (
|
||||
<Section key={index} section={section} placement="main" />
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</TemplateProvider>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
const Header = ({ styles }: { styles: DittoStyles }) => {
|
||||
const { basics, picture } = useRender();
|
||||
const hasPicture = hasTemplatePicture(picture);
|
||||
|
||||
return (
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerBand}>
|
||||
<View style={styles.pictureAnchor}>{hasPicture && <Image src={picture.url} style={styles.picture} />}</View>
|
||||
|
||||
<View style={styles.headerTitle}>
|
||||
<View style={styles.headerIdentity}>
|
||||
<Heading style={styles.headerName}>{basics.name}</Heading>
|
||||
<Text style={styles.headerHeadline}>{basics.headline}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.contactRow}>
|
||||
<View style={styles.contactOffset} />
|
||||
|
||||
<View style={styles.contactList}>
|
||||
{basics.email && (
|
||||
<Link src={`mailto:${basics.email}`} style={styles.contactItem}>
|
||||
<Icon name="envelope" />
|
||||
<Text>{basics.email}</Text>
|
||||
</Link>
|
||||
)}
|
||||
{basics.phone && (
|
||||
<Link src={`tel:${basics.phone}`} style={styles.contactItem}>
|
||||
<Icon name="phone" />
|
||||
<Text>{basics.phone}</Text>
|
||||
</Link>
|
||||
)}
|
||||
{basics.location && (
|
||||
<View style={styles.contactItem}>
|
||||
<Icon name="map-pin" />
|
||||
<Text>{basics.location}</Text>
|
||||
</View>
|
||||
)}
|
||||
{basics.website.url && (
|
||||
<Link src={basics.website.url} style={styles.contactItem}>
|
||||
<Icon name="globe" />
|
||||
<Text>{basics.website.label}</Text>
|
||||
</Link>
|
||||
)}
|
||||
{basics.customFields.map((field) => (
|
||||
<Link key={field.id} src={field.link} style={styles.contactItem}>
|
||||
<Icon name={field.icon as IconName} />
|
||||
<Text>{field.text}</Text>
|
||||
</Link>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const useDittoTemplate = (): DittoTemplate => {
|
||||
const { picture, metadata } = useRender();
|
||||
|
||||
return useMemo(() => {
|
||||
const foreground = rgbaStringToHex(metadata.design.colors.text);
|
||||
const background = rgbaStringToHex(metadata.design.colors.background);
|
||||
const primary = rgbaStringToHex(metadata.design.colors.primary);
|
||||
const colors: TemplateColorRoles = { foreground, background, primary };
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
const hasPicture = hasTemplatePicture(picture);
|
||||
|
||||
const bodyText = {
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
fontSize: metadata.typography.body.fontSize,
|
||||
fontWeight: metadata.typography.body.fontWeights[0] ?? "400",
|
||||
lineHeight: metadata.typography.body.lineHeight,
|
||||
color: foreground,
|
||||
} satisfies Style;
|
||||
|
||||
const baseStyles = StyleSheet.create({
|
||||
page: {
|
||||
flexDirection: "column",
|
||||
color: foreground,
|
||||
backgroundColor: background,
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
fontSize: metadata.typography.body.fontSize,
|
||||
lineHeight: metadata.typography.body.lineHeight,
|
||||
},
|
||||
text: bodyText,
|
||||
heading: {
|
||||
fontFamily: metadata.typography.heading.fontFamily,
|
||||
fontSize: metadata.typography.heading.fontSize,
|
||||
fontWeight: metadata.typography.heading.fontWeights.at(-1) ?? "600",
|
||||
lineHeight: metadata.typography.heading.lineHeight,
|
||||
color: foreground,
|
||||
},
|
||||
div: {
|
||||
rowGap: metrics.gapY(0.125),
|
||||
columnGap: metrics.gapX(1 / 3),
|
||||
},
|
||||
inline: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
columnGap: metrics.gapX(1 / 3),
|
||||
},
|
||||
link: {
|
||||
textDecoration: "none",
|
||||
color: foreground,
|
||||
},
|
||||
small: {
|
||||
fontSize: metadata.typography.body.fontSize * 0.875,
|
||||
},
|
||||
bold: {
|
||||
fontWeight: metadata.typography.body.fontWeights.at(-1) ?? "600",
|
||||
},
|
||||
richParagraph: {
|
||||
margin: 0,
|
||||
...bodyText,
|
||||
},
|
||||
richListItemRow: {
|
||||
flexDirection: "row",
|
||||
columnGap: metrics.gapX(1 / 3),
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
richListItemMarker: {
|
||||
width: metadata.typography.body.fontSize,
|
||||
textAlign: "right",
|
||||
...bodyText,
|
||||
},
|
||||
richListItemContent: {
|
||||
flex: 1,
|
||||
...bodyText,
|
||||
},
|
||||
splitRow: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
columnGap: metrics.gapX(2 / 3),
|
||||
},
|
||||
alignRight: {
|
||||
textAlign: "right",
|
||||
minWidth: 0,
|
||||
maxWidth: "100%",
|
||||
flexShrink: 1,
|
||||
},
|
||||
section: {
|
||||
flexDirection: "column",
|
||||
rowGap: metrics.gapY(0.25),
|
||||
},
|
||||
sectionHeading: {
|
||||
color: primary,
|
||||
},
|
||||
item: {
|
||||
rowGap: metrics.gapY(0.125),
|
||||
},
|
||||
levelContainer: {
|
||||
width: "100%",
|
||||
},
|
||||
levelItem: {
|
||||
borderColor: primary,
|
||||
},
|
||||
levelItemActive: {
|
||||
backgroundColor: primary,
|
||||
},
|
||||
header: {
|
||||
position: "relative",
|
||||
},
|
||||
headerBand: {
|
||||
backgroundColor: primary,
|
||||
color: background,
|
||||
flexDirection: "row",
|
||||
...(hasPicture ? { minHeight: picture.size * 0.6 } : {}),
|
||||
},
|
||||
pictureAnchor: {
|
||||
width: `${metadata.layout.sidebarWidth}%`,
|
||||
flexShrink: 0,
|
||||
position: "relative",
|
||||
},
|
||||
picture: {
|
||||
position: "absolute",
|
||||
top: metrics.page.paddingVertical * 0.75,
|
||||
left: "50%",
|
||||
marginLeft: -picture.size / 2,
|
||||
width: picture.size,
|
||||
height: picture.size,
|
||||
objectFit: "cover",
|
||||
aspectRatio: picture.aspectRatio,
|
||||
borderRadius: picture.borderRadius,
|
||||
borderColor: rgbaStringToHex(picture.borderColor),
|
||||
borderWidth: picture.borderWidth,
|
||||
shadowColor: rgbaStringToHex(picture.shadowColor),
|
||||
shadowWidth: picture.shadowWidth,
|
||||
transform: `rotate(${picture.rotation}deg)`,
|
||||
},
|
||||
headerTitle: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
rowGap: metrics.gapY(0.125),
|
||||
paddingLeft: metrics.page.paddingHorizontal,
|
||||
paddingTop: metrics.page.paddingVertical,
|
||||
paddingRight: metrics.page.paddingHorizontal,
|
||||
paddingBottom: metrics.page.paddingVertical,
|
||||
color: background,
|
||||
},
|
||||
headerIdentity: {
|
||||
textAlign: "left",
|
||||
alignItems: "flex-start",
|
||||
rowGap: metrics.gapY(0.35),
|
||||
},
|
||||
headerName: {
|
||||
fontSize: metadata.typography.heading.fontSize * 1.5,
|
||||
color: background,
|
||||
lineHeight: 1,
|
||||
},
|
||||
headerHeadline: {
|
||||
color: background,
|
||||
},
|
||||
contactRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
contactOffset: {
|
||||
width: `${metadata.layout.sidebarWidth}%`,
|
||||
flexShrink: 0,
|
||||
},
|
||||
contactList: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
columnGap: metrics.gapX(2 / 3),
|
||||
rowGap: metrics.gapY(0.125),
|
||||
paddingLeft: metrics.page.paddingHorizontal,
|
||||
paddingTop: metrics.page.paddingVertical,
|
||||
paddingRight: metrics.page.paddingHorizontal,
|
||||
paddingBottom: 0,
|
||||
},
|
||||
contactItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
columnGap: metrics.gapX(1 / 6),
|
||||
},
|
||||
contentRow: {
|
||||
flexDirection: "row",
|
||||
},
|
||||
sidebarColumn: {
|
||||
flexShrink: 0,
|
||||
},
|
||||
mainColumn: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const foregroundFor = ({ colors }: TemplateStyleContext) => colors.foreground;
|
||||
|
||||
return {
|
||||
colors,
|
||||
styles: {
|
||||
...baseStyles,
|
||||
text: (context) => ({ ...baseStyles.text, color: foregroundFor(context) }),
|
||||
heading: (context) => ({ ...baseStyles.heading, color: foregroundFor(context) }),
|
||||
link: (context) => ({ ...baseStyles.link, color: foregroundFor(context) }),
|
||||
richParagraph: (context) => ({ ...baseStyles.richParagraph, color: foregroundFor(context) }),
|
||||
richListItemMarker: (context) => ({ ...baseStyles.richListItemMarker, color: foregroundFor(context) }),
|
||||
richListItemContent: (context) => ({ ...baseStyles.richListItemContent, color: foregroundFor(context) }),
|
||||
splitRow: (context) => ({
|
||||
...baseStyles.splitRow,
|
||||
...(context.placement === "sidebar"
|
||||
? { flexDirection: "column", alignItems: "flex-start", justifyContent: "flex-start" }
|
||||
: {}),
|
||||
}),
|
||||
alignRight: (context) => ({
|
||||
...baseStyles.alignRight,
|
||||
...(context.placement === "sidebar" ? { textAlign: "left" } : {}),
|
||||
}),
|
||||
icon: {
|
||||
display: metadata.page.hideIcons ? "none" : "flex",
|
||||
size: metadata.typography.body.fontSize,
|
||||
color: primary,
|
||||
},
|
||||
} satisfies DittoStyles,
|
||||
};
|
||||
}, [picture, metadata]);
|
||||
};
|
||||
@@ -0,0 +1,380 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { IconName } from "phosphor-icons-react-pdf/dynamic";
|
||||
import type { TemplatePageProps } from "../../document";
|
||||
import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
|
||||
import { Image, Page, StyleSheet, View } from "@react-pdf/renderer";
|
||||
import { Fragment, useMemo } from "react";
|
||||
import { parseColorString, rgbaStringToHex } from "@reactive-resume/utils/color";
|
||||
import { useRender } from "../../context";
|
||||
import { TemplateProvider } from "../shared/context";
|
||||
import { filterSections } from "../shared/filtering";
|
||||
import { getTemplateMetrics } from "../shared/metrics";
|
||||
import { getTemplatePageSize } from "../shared/page-size";
|
||||
import { hasTemplatePicture } from "../shared/picture";
|
||||
import { Heading, Icon, Link, Text } from "../shared/primitives";
|
||||
import { Section } from "../shared/sections";
|
||||
import { composeStyles, resolvePlacementColor } from "../shared/styles";
|
||||
|
||||
type GengarStyles = Omit<TemplateStyleSlots, "page"> & {
|
||||
page: Style;
|
||||
sidebarColumn: Style;
|
||||
sidebarContent: Style;
|
||||
mainColumn: Style;
|
||||
mainContent: Style;
|
||||
specialContainer: Style;
|
||||
header: Style;
|
||||
picture: Style;
|
||||
headerTitle: Style;
|
||||
headerIdentity: Style;
|
||||
headerName: Style;
|
||||
headerText: Style;
|
||||
contactList: Style;
|
||||
contactItem: Style;
|
||||
};
|
||||
|
||||
type GengarTemplate = {
|
||||
colors: TemplateColorRoles;
|
||||
styles: GengarStyles;
|
||||
};
|
||||
|
||||
export const GengarPage = ({ page, pageIndex }: TemplatePageProps) => {
|
||||
const data = useRender();
|
||||
const { metadata } = data;
|
||||
const { colors, styles } = useGengarTemplate();
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
const pageSize = getTemplatePageSize(metadata.page.format);
|
||||
const showHeader = pageIndex === 0;
|
||||
const showSidebar = !page.fullWidth || showHeader;
|
||||
const sidebarSections = filterSections(page.sidebar, data);
|
||||
const mainSections = filterSections(page.main, data);
|
||||
const specialMainSection = showHeader ? mainSections[0] : undefined;
|
||||
const regularMainSections = showHeader ? mainSections.slice(1) : mainSections;
|
||||
|
||||
return (
|
||||
<Page size={pageSize} style={styles.page}>
|
||||
<TemplateProvider styles={styles} colors={colors}>
|
||||
{showSidebar && (
|
||||
<View
|
||||
style={composeStyles(styles.sidebarColumn, {
|
||||
width: `${metadata.layout.sidebarWidth}%`,
|
||||
})}
|
||||
>
|
||||
{showHeader && <Header styles={styles} colors={colors} />}
|
||||
|
||||
{!page.fullWidth && (
|
||||
<View style={styles.sidebarContent}>
|
||||
{sidebarSections.map((section, index) => (
|
||||
<Fragment key={index}>
|
||||
<Section section={section} placement="sidebar" />
|
||||
</Fragment>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.mainColumn}>
|
||||
{specialMainSection && (
|
||||
<View style={styles.specialContainer}>
|
||||
<Section section={specialMainSection} placement="main" showHeading={specialMainSection !== "summary"} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={composeStyles(styles.mainContent, { rowGap: metrics.sectionGap })}>
|
||||
{regularMainSections.map((section, index) => (
|
||||
<Section key={index} section={section} placement="main" />
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</TemplateProvider>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
const Header = ({ styles, colors }: { styles: GengarStyles; colors: TemplateColorRoles }) => {
|
||||
const { basics, picture } = useRender();
|
||||
const hasPicture = hasTemplatePicture(picture);
|
||||
|
||||
return (
|
||||
<View style={styles.header}>
|
||||
{hasPicture && <Image src={picture.url} style={styles.picture} />}
|
||||
|
||||
<View style={styles.headerTitle}>
|
||||
<View style={styles.headerIdentity}>
|
||||
<Heading style={styles.headerName}>{basics.name}</Heading>
|
||||
<Text style={styles.headerText}>{basics.headline}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.contactList}>
|
||||
{basics.email && (
|
||||
<Link src={`mailto:${basics.email}`} style={styles.contactItem}>
|
||||
<Icon name="envelope" color={colors.background} />
|
||||
<Text style={styles.headerText}>{basics.email}</Text>
|
||||
</Link>
|
||||
)}
|
||||
{basics.phone && (
|
||||
<Link src={`tel:${basics.phone}`} style={styles.contactItem}>
|
||||
<Icon name="phone" color={colors.background} />
|
||||
<Text style={styles.headerText}>{basics.phone}</Text>
|
||||
</Link>
|
||||
)}
|
||||
{basics.location && (
|
||||
<View style={styles.contactItem}>
|
||||
<Icon name="map-pin" color={colors.background} />
|
||||
<Text style={styles.headerText}>{basics.location}</Text>
|
||||
</View>
|
||||
)}
|
||||
{basics.website.url && (
|
||||
<Link src={basics.website.url} style={styles.contactItem}>
|
||||
<Icon name="globe" color={colors.background} />
|
||||
<Text style={styles.headerText}>{basics.website.label}</Text>
|
||||
</Link>
|
||||
)}
|
||||
{basics.customFields.map((field) => (
|
||||
<Link key={field.id} src={field.link} style={styles.contactItem}>
|
||||
<Icon name={field.icon as IconName} color={colors.background} />
|
||||
<Text style={styles.headerText}>{field.text}</Text>
|
||||
</Link>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const getPrimaryTint = (primaryColor: string, opacity: number): string => {
|
||||
const primary = parseColorString(primaryColor);
|
||||
|
||||
if (!primary) return rgbaStringToHex(primaryColor);
|
||||
|
||||
const alpha = Math.max(0, Math.min(1, primary.a * opacity));
|
||||
|
||||
return `rgba(${primary.r}, ${primary.g}, ${primary.b}, ${alpha})`;
|
||||
};
|
||||
|
||||
const useGengarTemplate = (): GengarTemplate => {
|
||||
const { picture, metadata } = useRender();
|
||||
|
||||
return useMemo(() => {
|
||||
const foreground = rgbaStringToHex(metadata.design.colors.text);
|
||||
const background = rgbaStringToHex(metadata.design.colors.background);
|
||||
const primary = rgbaStringToHex(metadata.design.colors.primary);
|
||||
const primaryTint = getPrimaryTint(metadata.design.colors.primary, 0.2);
|
||||
const colors: TemplateColorRoles = {
|
||||
foreground,
|
||||
background,
|
||||
primary,
|
||||
sidebarForeground: foreground,
|
||||
sidebarBackground: primaryTint,
|
||||
};
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
|
||||
const bodyText = {
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
fontSize: metadata.typography.body.fontSize,
|
||||
fontWeight: metadata.typography.body.fontWeights[0] ?? "400",
|
||||
lineHeight: metadata.typography.body.lineHeight,
|
||||
color: foreground,
|
||||
} satisfies Style;
|
||||
|
||||
const baseStyles = StyleSheet.create({
|
||||
page: {
|
||||
flexDirection: "row",
|
||||
color: foreground,
|
||||
backgroundColor: background,
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
fontSize: metadata.typography.body.fontSize,
|
||||
lineHeight: metadata.typography.body.lineHeight,
|
||||
},
|
||||
text: bodyText,
|
||||
heading: {
|
||||
fontFamily: metadata.typography.heading.fontFamily,
|
||||
fontSize: metadata.typography.heading.fontSize,
|
||||
fontWeight: metadata.typography.heading.fontWeights.at(-1) ?? "600",
|
||||
lineHeight: metadata.typography.heading.lineHeight,
|
||||
color: foreground,
|
||||
},
|
||||
div: {
|
||||
rowGap: metrics.gapY(0.125),
|
||||
columnGap: metrics.gapX(1 / 3),
|
||||
},
|
||||
inline: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
columnGap: metrics.gapX(1 / 3),
|
||||
},
|
||||
link: {
|
||||
textDecoration: "none",
|
||||
color: foreground,
|
||||
},
|
||||
small: {
|
||||
fontSize: metadata.typography.body.fontSize * 0.875,
|
||||
},
|
||||
bold: {
|
||||
fontWeight: metadata.typography.body.fontWeights.at(-1) ?? "600",
|
||||
},
|
||||
richParagraph: {
|
||||
margin: 0,
|
||||
...bodyText,
|
||||
},
|
||||
richListItemRow: {
|
||||
flexDirection: "row",
|
||||
columnGap: metrics.gapX(1 / 3),
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
richListItemMarker: {
|
||||
width: metadata.typography.body.fontSize,
|
||||
textAlign: "right",
|
||||
...bodyText,
|
||||
},
|
||||
richListItemContent: {
|
||||
flex: 1,
|
||||
...bodyText,
|
||||
},
|
||||
splitRow: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
columnGap: metrics.gapX(2 / 3),
|
||||
},
|
||||
alignRight: {
|
||||
textAlign: "right",
|
||||
minWidth: 0,
|
||||
maxWidth: "100%",
|
||||
flexShrink: 1,
|
||||
},
|
||||
section: {
|
||||
flexDirection: "column",
|
||||
rowGap: metrics.gapY(0.25),
|
||||
},
|
||||
sectionHeading: {
|
||||
fontSize: metadata.typography.heading.fontSize * 0.9,
|
||||
color: primary,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: primary,
|
||||
paddingBottom: metrics.gapY(0.125),
|
||||
},
|
||||
item: {
|
||||
rowGap: metrics.gapY(0.125),
|
||||
},
|
||||
levelContainer: {
|
||||
width: "70%",
|
||||
},
|
||||
levelItem: {
|
||||
borderColor: primary,
|
||||
},
|
||||
levelItemActive: {
|
||||
backgroundColor: primary,
|
||||
},
|
||||
sidebarColumn: {
|
||||
flexShrink: 0,
|
||||
backgroundColor: primaryTint,
|
||||
},
|
||||
sidebarContent: {
|
||||
paddingHorizontal: metrics.page.paddingHorizontal,
|
||||
paddingTop: metrics.page.paddingVertical,
|
||||
paddingBottom: metrics.page.paddingVertical,
|
||||
rowGap: metrics.sectionGap,
|
||||
},
|
||||
mainColumn: {
|
||||
flex: 1,
|
||||
},
|
||||
mainContent: {
|
||||
paddingHorizontal: metrics.page.paddingHorizontal,
|
||||
paddingTop: metrics.page.paddingVertical,
|
||||
paddingBottom: metrics.page.paddingVertical,
|
||||
},
|
||||
specialContainer: {
|
||||
backgroundColor: primaryTint,
|
||||
paddingHorizontal: metrics.page.paddingHorizontal,
|
||||
paddingVertical: metrics.page.paddingVertical,
|
||||
},
|
||||
header: {
|
||||
backgroundColor: primary,
|
||||
color: background,
|
||||
paddingHorizontal: metrics.page.paddingHorizontal,
|
||||
paddingVertical: metrics.page.paddingVertical,
|
||||
rowGap: metrics.gapY(0.5),
|
||||
},
|
||||
picture: {
|
||||
width: picture.size,
|
||||
height: picture.size,
|
||||
objectFit: "cover",
|
||||
aspectRatio: picture.aspectRatio,
|
||||
borderRadius: picture.borderRadius,
|
||||
borderColor: rgbaStringToHex(picture.borderColor),
|
||||
borderWidth: picture.borderWidth,
|
||||
shadowColor: rgbaStringToHex(picture.shadowColor),
|
||||
shadowWidth: picture.shadowWidth,
|
||||
transform: `rotate(${picture.rotation}deg)`,
|
||||
},
|
||||
headerTitle: {},
|
||||
headerIdentity: {
|
||||
textAlign: "left",
|
||||
alignItems: "flex-start",
|
||||
rowGap: metrics.gapY(0.35),
|
||||
},
|
||||
headerName: {
|
||||
fontSize: metadata.typography.heading.fontSize * 1.5,
|
||||
color: background,
|
||||
lineHeight: 1,
|
||||
},
|
||||
headerText: {
|
||||
color: background,
|
||||
},
|
||||
contactList: {
|
||||
rowGap: metrics.gapY(0.25),
|
||||
},
|
||||
contactItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
columnGap: metrics.gapX(1 / 6),
|
||||
},
|
||||
});
|
||||
|
||||
const foregroundFor = ({ placement, colors }: TemplateStyleContext) =>
|
||||
resolvePlacementColor({
|
||||
placement,
|
||||
defaultForeground: colors.foreground,
|
||||
sidebarForeground: colors.sidebarForeground,
|
||||
});
|
||||
|
||||
const accentFor = ({ colors }: TemplateStyleContext) => colors.primary;
|
||||
|
||||
return {
|
||||
colors,
|
||||
styles: {
|
||||
...baseStyles,
|
||||
text: (context) => ({ ...baseStyles.text, color: foregroundFor(context) }),
|
||||
heading: (context) => ({ ...baseStyles.heading, color: foregroundFor(context) }),
|
||||
link: (context) => ({ ...baseStyles.link, color: foregroundFor(context) }),
|
||||
richParagraph: (context) => ({ ...baseStyles.richParagraph, color: foregroundFor(context) }),
|
||||
richListItemMarker: (context) => ({ ...baseStyles.richListItemMarker, color: foregroundFor(context) }),
|
||||
richListItemContent: (context) => ({ ...baseStyles.richListItemContent, color: foregroundFor(context) }),
|
||||
splitRow: (context) => ({
|
||||
...baseStyles.splitRow,
|
||||
...(context.placement === "sidebar"
|
||||
? { flexDirection: "column", alignItems: "flex-start", justifyContent: "flex-start" }
|
||||
: {}),
|
||||
}),
|
||||
alignRight: (context) => ({
|
||||
...baseStyles.alignRight,
|
||||
...(context.placement === "sidebar" ? { textAlign: "left" } : {}),
|
||||
}),
|
||||
sectionHeading: (context) => ({
|
||||
...baseStyles.sectionHeading,
|
||||
color: accentFor(context),
|
||||
borderBottomColor: accentFor(context),
|
||||
}),
|
||||
levelItem: (context) => ({ borderColor: accentFor(context) }),
|
||||
levelItemActive: (context) => ({ backgroundColor: accentFor(context) }),
|
||||
icon: (context) => ({
|
||||
display: metadata.page.hideIcons ? "none" : "flex",
|
||||
size: metadata.typography.body.fontSize,
|
||||
color: context.placement === "sidebar" ? foreground : primary,
|
||||
}),
|
||||
} satisfies GengarStyles,
|
||||
};
|
||||
}, [picture, metadata]);
|
||||
};
|
||||
@@ -0,0 +1,295 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { IconName } from "phosphor-icons-react-pdf/dynamic";
|
||||
import type { TemplatePageProps } from "../../document";
|
||||
import type { TemplateColorRoles, TemplateFeatures, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
|
||||
import { Image, Page, StyleSheet, View } from "@react-pdf/renderer";
|
||||
import { useMemo } from "react";
|
||||
import { parseColorString, rgbaStringToHex } from "@reactive-resume/utils/color";
|
||||
import { useRender } from "../../context";
|
||||
import { TemplateProvider } from "../shared/context";
|
||||
import { filterSections } from "../shared/filtering";
|
||||
import { getTemplateMetrics } from "../shared/metrics";
|
||||
import { getTemplatePageSize } from "../shared/page-size";
|
||||
import { hasTemplatePicture } from "../shared/picture";
|
||||
import { Heading, Icon, Link, Text } from "../shared/primitives";
|
||||
import { Section } from "../shared/sections";
|
||||
import { composeStyles, resolvePlacementColor } from "../shared/styles";
|
||||
|
||||
type GlalieStyles = Omit<TemplateStyleSlots, "page"> & {
|
||||
page: Style;
|
||||
layout: Style;
|
||||
sidebarBackground: Style;
|
||||
sidebarColumn: Style;
|
||||
sidebarContent: Style;
|
||||
mainColumn: Style;
|
||||
mainContent: Style;
|
||||
header: Style;
|
||||
picture: Style;
|
||||
headerTitle: Style;
|
||||
headerIdentity: Style;
|
||||
headerName: Style;
|
||||
contactList: Style;
|
||||
contactItem: Style;
|
||||
};
|
||||
|
||||
type GlalieTemplate = {
|
||||
colors: TemplateColorRoles;
|
||||
styles: GlalieStyles;
|
||||
};
|
||||
|
||||
const glalieFeatures = {
|
||||
stackSidebarItemHeader: true,
|
||||
} satisfies TemplateFeatures;
|
||||
|
||||
export const GlaliePage = ({ page, pageIndex }: TemplatePageProps) => {
|
||||
const data = useRender();
|
||||
const { metadata } = data;
|
||||
const { colors, styles } = useGlalieTemplate();
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
const pageSize = getTemplatePageSize(metadata.page.format);
|
||||
const showHeader = pageIndex === 0;
|
||||
const showSidebar = !page.fullWidth || showHeader;
|
||||
const mainSections = filterSections(page.main, data);
|
||||
const sidebarSections = filterSections(page.sidebar, data);
|
||||
|
||||
return (
|
||||
<Page size={pageSize} style={styles.page}>
|
||||
<TemplateProvider styles={styles} colors={colors} features={glalieFeatures}>
|
||||
{showSidebar && <View style={styles.sidebarBackground} />}
|
||||
|
||||
<View style={styles.layout}>
|
||||
{showSidebar && (
|
||||
<View
|
||||
style={composeStyles(styles.sidebarColumn, {
|
||||
width: `${metadata.layout.sidebarWidth}%`,
|
||||
})}
|
||||
>
|
||||
{showHeader && <Header styles={styles} />}
|
||||
|
||||
{!page.fullWidth && (
|
||||
<View style={composeStyles(styles.sidebarContent, { rowGap: metrics.sectionGap })}>
|
||||
{sidebarSections.map((section, index) => (
|
||||
<Section key={index} section={section} placement="sidebar" />
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.mainColumn}>
|
||||
<View style={composeStyles(styles.mainContent, { rowGap: metrics.sectionGap })}>
|
||||
{mainSections.map((section, index) => (
|
||||
<Section key={index} section={section} placement="main" />
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</TemplateProvider>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
const Header = ({ styles }: { styles: GlalieStyles }) => {
|
||||
const { basics, picture } = useRender();
|
||||
const hasPicture = hasTemplatePicture(picture);
|
||||
|
||||
return (
|
||||
<View style={styles.header}>
|
||||
{hasPicture && <Image src={picture.url} style={styles.picture} />}
|
||||
|
||||
<View style={styles.headerTitle}>
|
||||
<View style={styles.headerIdentity}>
|
||||
<Heading style={styles.headerName}>{basics.name}</Heading>
|
||||
<Text>{basics.headline}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.contactList}>
|
||||
{basics.email && (
|
||||
<Link src={`mailto:${basics.email}`} style={styles.contactItem}>
|
||||
<Icon name="envelope" />
|
||||
<Text>{basics.email}</Text>
|
||||
</Link>
|
||||
)}
|
||||
{basics.phone && (
|
||||
<Link src={`tel:${basics.phone}`} style={styles.contactItem}>
|
||||
<Icon name="phone" />
|
||||
<Text>{basics.phone}</Text>
|
||||
</Link>
|
||||
)}
|
||||
{basics.location && (
|
||||
<View style={styles.contactItem}>
|
||||
<Icon name="map-pin" />
|
||||
<Text>{basics.location}</Text>
|
||||
</View>
|
||||
)}
|
||||
{basics.website.url && (
|
||||
<Link src={basics.website.url} style={styles.contactItem}>
|
||||
<Icon name="globe" />
|
||||
<Text>{basics.website.label}</Text>
|
||||
</Link>
|
||||
)}
|
||||
{basics.customFields.map((field) => (
|
||||
<Link key={field.id} src={field.link} style={styles.contactItem}>
|
||||
<Icon name={field.icon as IconName} />
|
||||
<Text>{field.text}</Text>
|
||||
</Link>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const getPrimaryTint = (primaryColor: string, opacity: number): string => {
|
||||
const primary = parseColorString(primaryColor);
|
||||
|
||||
if (!primary) return rgbaStringToHex(primaryColor);
|
||||
|
||||
const alpha = Math.max(0, Math.min(1, primary.a * opacity));
|
||||
|
||||
return `rgba(${primary.r}, ${primary.g}, ${primary.b}, ${alpha})`;
|
||||
};
|
||||
|
||||
const useGlalieTemplate = (): GlalieTemplate => {
|
||||
const { picture, metadata } = useRender();
|
||||
|
||||
return useMemo(() => {
|
||||
const foreground = rgbaStringToHex(metadata.design.colors.text);
|
||||
const background = rgbaStringToHex(metadata.design.colors.background);
|
||||
const primary = rgbaStringToHex(metadata.design.colors.primary);
|
||||
const primaryTint = getPrimaryTint(metadata.design.colors.primary, 0.2);
|
||||
const colors: TemplateColorRoles = {
|
||||
foreground,
|
||||
background,
|
||||
primary,
|
||||
sidebarForeground: foreground,
|
||||
sidebarBackground: primaryTint,
|
||||
};
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
|
||||
const bodyText = {
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
fontSize: metadata.typography.body.fontSize,
|
||||
fontWeight: metadata.typography.body.fontWeights[0] ?? "400",
|
||||
lineHeight: metadata.typography.body.lineHeight,
|
||||
color: foreground,
|
||||
} satisfies Style;
|
||||
|
||||
const baseStyles = StyleSheet.create({
|
||||
page: {
|
||||
color: foreground,
|
||||
backgroundColor: background,
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
fontSize: metadata.typography.body.fontSize,
|
||||
lineHeight: metadata.typography.body.lineHeight,
|
||||
},
|
||||
text: bodyText,
|
||||
heading: {
|
||||
fontFamily: metadata.typography.heading.fontFamily,
|
||||
fontSize: metadata.typography.heading.fontSize,
|
||||
fontWeight: metadata.typography.heading.fontWeights.at(-1) ?? "600",
|
||||
lineHeight: metadata.typography.heading.lineHeight,
|
||||
color: foreground,
|
||||
},
|
||||
div: { rowGap: metrics.gapY(0.125), columnGap: metrics.gapX(1 / 3) },
|
||||
inline: { flexDirection: "row", alignItems: "center", columnGap: metrics.gapX(1 / 3) },
|
||||
link: { textDecoration: "none", color: foreground },
|
||||
small: { fontSize: metadata.typography.body.fontSize * 0.875 },
|
||||
bold: { fontWeight: metadata.typography.body.fontWeights.at(-1) ?? "600" },
|
||||
richParagraph: { margin: 0, ...bodyText },
|
||||
richListItemRow: { flexDirection: "row", columnGap: metrics.gapX(1 / 3), alignItems: "flex-start" },
|
||||
richListItemMarker: { width: metadata.typography.body.fontSize, textAlign: "right", ...bodyText },
|
||||
richListItemContent: { flex: 1, ...bodyText },
|
||||
splitRow: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
columnGap: metrics.gapX(2 / 3),
|
||||
},
|
||||
alignRight: { textAlign: "right", minWidth: 0, maxWidth: "100%", flexShrink: 1 },
|
||||
section: { flexDirection: "column", rowGap: metrics.gapY(0.25) },
|
||||
sectionHeading: { borderBottomWidth: 1, borderBottomColor: primary },
|
||||
item: { rowGap: metrics.gapY(0.125) },
|
||||
levelContainer: { width: "100%" },
|
||||
levelItem: { borderColor: primary },
|
||||
levelItemActive: { backgroundColor: primary },
|
||||
sidebarBackground: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
width: `${metadata.layout.sidebarWidth}%`,
|
||||
backgroundColor: primaryTint,
|
||||
},
|
||||
layout: { flexDirection: "row", minHeight: "100%" },
|
||||
sidebarColumn: {
|
||||
zIndex: 1,
|
||||
backgroundColor: primaryTint,
|
||||
paddingHorizontal: metrics.page.paddingHorizontal,
|
||||
paddingTop: metrics.page.paddingVertical,
|
||||
rowGap: metrics.sectionGap,
|
||||
},
|
||||
sidebarContent: { overflow: "hidden" },
|
||||
mainColumn: { flex: 1, zIndex: 1 },
|
||||
mainContent: {
|
||||
paddingHorizontal: metrics.page.paddingHorizontal,
|
||||
paddingTop: metrics.page.paddingVertical,
|
||||
},
|
||||
header: {
|
||||
alignItems: "center",
|
||||
rowGap: metrics.gapY(0.5),
|
||||
},
|
||||
picture: {
|
||||
width: picture.size,
|
||||
height: picture.size,
|
||||
objectFit: "cover",
|
||||
aspectRatio: picture.aspectRatio,
|
||||
borderRadius: picture.borderRadius,
|
||||
borderColor: rgbaStringToHex(picture.borderColor),
|
||||
borderWidth: picture.borderWidth,
|
||||
shadowColor: rgbaStringToHex(picture.shadowColor),
|
||||
shadowWidth: picture.shadowWidth,
|
||||
transform: `rotate(${picture.rotation}deg)`,
|
||||
},
|
||||
headerTitle: { alignItems: "center", textAlign: "center" },
|
||||
headerIdentity: { alignItems: "center", textAlign: "center", rowGap: metrics.gapY(0.35) },
|
||||
headerName: { fontSize: metadata.typography.heading.fontSize * 1.5, lineHeight: 1 },
|
||||
contactList: {
|
||||
width: "100%",
|
||||
borderWidth: 1,
|
||||
borderColor: primary,
|
||||
borderRadius: picture.borderRadius / 4,
|
||||
padding: metrics.gapX(0.75),
|
||||
rowGap: metrics.gapY(0.125),
|
||||
},
|
||||
contactItem: { flexDirection: "row", alignItems: "center", columnGap: metrics.gapX(1 / 6) },
|
||||
});
|
||||
|
||||
const accentFor = ({ colors }: TemplateStyleContext) => colors.primary;
|
||||
const foregroundFor = (context: TemplateStyleContext) =>
|
||||
resolvePlacementColor({
|
||||
placement: context.placement,
|
||||
defaultForeground: colors.foreground,
|
||||
sidebarForeground: colors.sidebarForeground,
|
||||
});
|
||||
|
||||
return {
|
||||
colors,
|
||||
styles: {
|
||||
...baseStyles,
|
||||
text: (context) => ({ ...bodyText, color: foregroundFor(context) }),
|
||||
heading: (context) => ({ ...baseStyles.heading, color: foregroundFor(context) }),
|
||||
link: (context) => ({ ...baseStyles.link, color: foregroundFor(context) }),
|
||||
sectionHeading: (context) => ({ ...baseStyles.sectionHeading, color: accentFor(context) }),
|
||||
levelItem: (context) => ({ borderColor: accentFor(context) }),
|
||||
levelItemActive: (context) => ({ backgroundColor: accentFor(context) }),
|
||||
icon: (context) => ({
|
||||
display: metadata.page.hideIcons ? "none" : "flex",
|
||||
size: metadata.typography.body.fontSize,
|
||||
color: accentFor(context),
|
||||
}),
|
||||
} satisfies GlalieStyles,
|
||||
};
|
||||
}, [picture, metadata]);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Template } from "@reactive-resume/schema/templates";
|
||||
import type { TemplatePage } from "../document";
|
||||
import { AzurillPage } from "./azurill/AzurillPage";
|
||||
import { BronzorPage } from "./bronzor/BronzorPage";
|
||||
import { ChikoritaPage } from "./chikorita/ChikoritaPage";
|
||||
import { DitgarPage } from "./ditgar/DitgarPage";
|
||||
import { DittoPage } from "./ditto/DittoPage";
|
||||
import { GengarPage } from "./gengar/GengarPage";
|
||||
import { GlaliePage } from "./glalie/GlaliePage";
|
||||
import { KakunaPage } from "./kakuna/KakunaPage";
|
||||
import { LaprasPage } from "./lapras/LaprasPage";
|
||||
import { LeafishPage } from "./leafish/LeafishPage";
|
||||
import { MeowthPage } from "./meowth/MeowthPage";
|
||||
import { OnyxPage } from "./onyx/OnyxPage";
|
||||
import { PikachuPage } from "./pikachu/PikachuPage";
|
||||
import { RhyhornPage } from "./rhyhorn/RhyhornPage";
|
||||
|
||||
export const templatePages: Partial<Record<Template, TemplatePage>> = {
|
||||
azurill: AzurillPage,
|
||||
bronzor: BronzorPage,
|
||||
chikorita: ChikoritaPage,
|
||||
ditgar: DitgarPage,
|
||||
ditto: DittoPage,
|
||||
gengar: GengarPage,
|
||||
glalie: GlaliePage,
|
||||
kakuna: KakunaPage,
|
||||
lapras: LaprasPage,
|
||||
leafish: LeafishPage,
|
||||
meowth: MeowthPage,
|
||||
onyx: OnyxPage,
|
||||
pikachu: PikachuPage,
|
||||
rhyhorn: RhyhornPage,
|
||||
};
|
||||
|
||||
export const defaultTemplatePage = AzurillPage;
|
||||
|
||||
export const getTemplatePage = (template: Template): TemplatePage => templatePages[template] ?? defaultTemplatePage;
|
||||
|
||||
export {
|
||||
AzurillPage,
|
||||
BronzorPage,
|
||||
ChikoritaPage,
|
||||
DitgarPage,
|
||||
DittoPage,
|
||||
GengarPage,
|
||||
GlaliePage,
|
||||
KakunaPage,
|
||||
LaprasPage,
|
||||
LeafishPage,
|
||||
MeowthPage,
|
||||
OnyxPage,
|
||||
PikachuPage,
|
||||
RhyhornPage,
|
||||
};
|
||||
@@ -0,0 +1,306 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { IconName } from "phosphor-icons-react-pdf/dynamic";
|
||||
import type { TemplatePageProps } from "../../document";
|
||||
import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
|
||||
import { Image, Page, StyleSheet, View } from "@react-pdf/renderer";
|
||||
import { useMemo } from "react";
|
||||
import { rgbaStringToHex } from "@reactive-resume/utils/color";
|
||||
import { useRender } from "../../context";
|
||||
import { TemplateProvider } from "../shared/context";
|
||||
import { filterSections } from "../shared/filtering";
|
||||
import { getTemplateMetrics } from "../shared/metrics";
|
||||
import { getTemplatePageSize } from "../shared/page-size";
|
||||
import { hasTemplatePicture } from "../shared/picture";
|
||||
import { Heading, Icon, Link, Text } from "../shared/primitives";
|
||||
import { Section } from "../shared/sections";
|
||||
import { composeStyles } from "../shared/styles";
|
||||
|
||||
type KakunaStyles = Omit<TemplateStyleSlots, "page"> & {
|
||||
page: Style;
|
||||
header: Style;
|
||||
picture: Style;
|
||||
headerTitle: Style;
|
||||
headerCopy: Style;
|
||||
headerName: Style;
|
||||
headerText: Style;
|
||||
contactList: Style;
|
||||
contactItem: Style;
|
||||
sectionGroup: Style;
|
||||
};
|
||||
|
||||
type KakunaTemplate = {
|
||||
colors: TemplateColorRoles;
|
||||
styles: KakunaStyles;
|
||||
};
|
||||
|
||||
export const KakunaPage = ({ page, pageIndex }: TemplatePageProps) => {
|
||||
const data = useRender();
|
||||
const { metadata } = data;
|
||||
const { colors, styles } = useKakunaTemplate();
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
const pageSize = getTemplatePageSize(metadata.page.format);
|
||||
const showHeader = pageIndex === 0;
|
||||
const mainSections = filterSections(page.main, data);
|
||||
const sidebarSections = filterSections(page.sidebar, data);
|
||||
|
||||
return (
|
||||
<Page size={pageSize} style={styles.page}>
|
||||
<TemplateProvider styles={styles} colors={colors}>
|
||||
{showHeader && <Header styles={styles} />}
|
||||
|
||||
<View style={composeStyles(styles.sectionGroup, { rowGap: metrics.sectionGap })}>
|
||||
{mainSections.map((section, index) => (
|
||||
<Section key={index} section={section} placement="main" />
|
||||
))}
|
||||
</View>
|
||||
|
||||
{!page.fullWidth && (
|
||||
<View style={composeStyles(styles.sectionGroup, { rowGap: metrics.sectionGap })}>
|
||||
{sidebarSections.map((section, index) => (
|
||||
<Section key={index} section={section} placement="sidebar" />
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</TemplateProvider>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
const Header = ({ styles }: { styles: KakunaStyles }) => {
|
||||
const { basics, picture } = useRender();
|
||||
const hasPicture = hasTemplatePicture(picture);
|
||||
|
||||
return (
|
||||
<View style={styles.header}>
|
||||
{hasPicture && <Image src={picture.url} style={styles.picture} />}
|
||||
|
||||
<View style={styles.headerTitle}>
|
||||
<View style={styles.headerCopy}>
|
||||
<Heading style={styles.headerName}>{basics.name}</Heading>
|
||||
<Text style={styles.headerText}>{basics.headline}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.contactList}>
|
||||
{basics.email && (
|
||||
<Link src={`mailto:${basics.email}`} style={styles.contactItem}>
|
||||
<Icon name="envelope" />
|
||||
<Text>{basics.email}</Text>
|
||||
</Link>
|
||||
)}
|
||||
{basics.phone && (
|
||||
<Link src={`tel:${basics.phone}`} style={styles.contactItem}>
|
||||
<Icon name="phone" />
|
||||
<Text>{basics.phone}</Text>
|
||||
</Link>
|
||||
)}
|
||||
{basics.location && (
|
||||
<View style={styles.contactItem}>
|
||||
<Icon name="map-pin" />
|
||||
<Text>{basics.location}</Text>
|
||||
</View>
|
||||
)}
|
||||
{basics.website.url && (
|
||||
<Link src={basics.website.url} style={styles.contactItem}>
|
||||
<Icon name="globe" />
|
||||
<Text>{basics.website.label}</Text>
|
||||
</Link>
|
||||
)}
|
||||
{basics.customFields.map((field) => (
|
||||
<Link key={field.id} src={field.link} style={styles.contactItem}>
|
||||
<Icon name={field.icon as IconName} />
|
||||
<Text>{field.text}</Text>
|
||||
</Link>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const useKakunaTemplate = (): KakunaTemplate => {
|
||||
const { picture, metadata } = useRender();
|
||||
|
||||
return useMemo(() => {
|
||||
const foreground = rgbaStringToHex(metadata.design.colors.text);
|
||||
const background = rgbaStringToHex(metadata.design.colors.background);
|
||||
const primary = rgbaStringToHex(metadata.design.colors.primary);
|
||||
const colors: TemplateColorRoles = { foreground, background, primary };
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
|
||||
const bodyText = {
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
fontSize: metadata.typography.body.fontSize,
|
||||
fontWeight: metadata.typography.body.fontWeights[0] ?? "400",
|
||||
lineHeight: metadata.typography.body.lineHeight,
|
||||
color: foreground,
|
||||
} satisfies Style;
|
||||
|
||||
const baseStyles = StyleSheet.create({
|
||||
page: {
|
||||
color: foreground,
|
||||
backgroundColor: background,
|
||||
paddingHorizontal: metrics.page.paddingHorizontal,
|
||||
paddingVertical: metrics.page.paddingVertical,
|
||||
rowGap: metrics.sectionGap,
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
fontSize: metadata.typography.body.fontSize,
|
||||
lineHeight: metadata.typography.body.lineHeight,
|
||||
},
|
||||
text: bodyText,
|
||||
heading: {
|
||||
fontFamily: metadata.typography.heading.fontFamily,
|
||||
fontSize: metadata.typography.heading.fontSize,
|
||||
fontWeight: metadata.typography.heading.fontWeights.at(-1) ?? "600",
|
||||
lineHeight: metadata.typography.heading.lineHeight,
|
||||
color: foreground,
|
||||
},
|
||||
div: {
|
||||
rowGap: metrics.gapY(0.125),
|
||||
columnGap: metrics.gapX(1 / 3),
|
||||
},
|
||||
inline: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
columnGap: metrics.gapX(1 / 3),
|
||||
},
|
||||
link: {
|
||||
textDecoration: "none",
|
||||
color: foreground,
|
||||
},
|
||||
small: {
|
||||
fontSize: metadata.typography.body.fontSize * 0.875,
|
||||
},
|
||||
bold: {
|
||||
fontWeight: metadata.typography.body.fontWeights.at(-1) ?? "600",
|
||||
},
|
||||
richParagraph: {
|
||||
margin: 0,
|
||||
...bodyText,
|
||||
},
|
||||
richListItemRow: {
|
||||
flexDirection: "row",
|
||||
columnGap: metrics.gapX(1 / 3),
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
richListItemMarker: {
|
||||
width: metadata.typography.body.fontSize,
|
||||
textAlign: "right",
|
||||
...bodyText,
|
||||
},
|
||||
richListItemContent: {
|
||||
flex: 1,
|
||||
...bodyText,
|
||||
},
|
||||
splitRow: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
columnGap: metrics.gapX(2 / 3),
|
||||
},
|
||||
alignRight: {
|
||||
textAlign: "right",
|
||||
minWidth: 0,
|
||||
maxWidth: "100%",
|
||||
flexShrink: 1,
|
||||
},
|
||||
section: {
|
||||
flexDirection: "column",
|
||||
rowGap: metrics.gapY(0.25),
|
||||
},
|
||||
sectionHeading: {
|
||||
color: primary,
|
||||
textAlign: "center",
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: primary,
|
||||
paddingBottom: metrics.gapY(0.125),
|
||||
},
|
||||
item: {
|
||||
rowGap: metrics.gapY(0.125),
|
||||
},
|
||||
levelContainer: {
|
||||
width: "100%",
|
||||
},
|
||||
levelItem: {
|
||||
borderColor: primary,
|
||||
},
|
||||
levelItemActive: {
|
||||
backgroundColor: primary,
|
||||
},
|
||||
header: {
|
||||
width: "100%",
|
||||
alignItems: "center",
|
||||
rowGap: metrics.gapY(0.5),
|
||||
},
|
||||
picture: {
|
||||
width: picture.size,
|
||||
height: picture.size,
|
||||
objectFit: "cover",
|
||||
aspectRatio: picture.aspectRatio,
|
||||
borderRadius: picture.borderRadius,
|
||||
borderColor: rgbaStringToHex(picture.borderColor),
|
||||
borderWidth: picture.borderWidth,
|
||||
shadowColor: rgbaStringToHex(picture.shadowColor),
|
||||
shadowWidth: picture.shadowWidth,
|
||||
transform: `rotate(${picture.rotation}deg)`,
|
||||
},
|
||||
headerTitle: {
|
||||
width: "100%",
|
||||
textAlign: "center",
|
||||
alignItems: "center",
|
||||
rowGap: metrics.gapY(0.5),
|
||||
},
|
||||
headerCopy: {
|
||||
alignItems: "center",
|
||||
textAlign: "center",
|
||||
width: "100%",
|
||||
rowGap: metrics.gapY(0.35),
|
||||
},
|
||||
headerName: {
|
||||
width: "100%",
|
||||
fontSize: metadata.typography.heading.fontSize * 1.5,
|
||||
lineHeight: 1,
|
||||
textAlign: "center",
|
||||
},
|
||||
headerText: {
|
||||
width: "100%",
|
||||
textAlign: "center",
|
||||
},
|
||||
contactList: {
|
||||
width: "100%",
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "center",
|
||||
rowGap: metrics.gapY(0.125),
|
||||
columnGap: metrics.gapX(0.75),
|
||||
},
|
||||
contactItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
columnGap: metrics.gapX(1 / 6),
|
||||
},
|
||||
sectionGroup: {},
|
||||
});
|
||||
|
||||
const accentFor = ({ colors }: TemplateStyleContext) => colors.primary;
|
||||
|
||||
return {
|
||||
colors,
|
||||
styles: {
|
||||
...baseStyles,
|
||||
sectionHeading: (context) => ({
|
||||
...baseStyles.sectionHeading,
|
||||
color: accentFor(context),
|
||||
borderBottomColor: accentFor(context),
|
||||
}),
|
||||
levelItem: (context) => ({ borderColor: accentFor(context) }),
|
||||
levelItemActive: (context) => ({ backgroundColor: accentFor(context) }),
|
||||
icon: (context) => ({
|
||||
display: metadata.page.hideIcons ? "none" : "flex",
|
||||
size: metadata.typography.body.fontSize,
|
||||
color: accentFor(context),
|
||||
}),
|
||||
} satisfies KakunaStyles,
|
||||
};
|
||||
}, [picture, metadata]);
|
||||
};
|
||||
@@ -0,0 +1,254 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { IconName } from "phosphor-icons-react-pdf/dynamic";
|
||||
import type { TemplatePageProps } from "../../document";
|
||||
import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
|
||||
import { Image, Page, StyleSheet, View } from "@react-pdf/renderer";
|
||||
import { useMemo } from "react";
|
||||
import { parseColorString, rgbaStringToHex } from "@reactive-resume/utils/color";
|
||||
import { useRender } from "../../context";
|
||||
import { TemplateProvider } from "../shared/context";
|
||||
import { filterSections } from "../shared/filtering";
|
||||
import { getTemplateMetrics } from "../shared/metrics";
|
||||
import { getTemplatePageSize } from "../shared/page-size";
|
||||
import { hasTemplatePicture } from "../shared/picture";
|
||||
import { Heading, Icon, Link, Text } from "../shared/primitives";
|
||||
import { Section } from "../shared/sections";
|
||||
import { composeStyles } from "../shared/styles";
|
||||
|
||||
type LaprasStyles = Omit<TemplateStyleSlots, "page"> & {
|
||||
page: Style;
|
||||
header: Style;
|
||||
picture: Style;
|
||||
headerTitle: Style;
|
||||
headerIdentity: Style;
|
||||
headerName: Style;
|
||||
contactList: Style;
|
||||
contactItem: Style;
|
||||
sectionGroup: Style;
|
||||
};
|
||||
|
||||
type LaprasTemplate = {
|
||||
colors: TemplateColorRoles;
|
||||
styles: LaprasStyles;
|
||||
};
|
||||
|
||||
export const LaprasPage = ({ page, pageIndex }: TemplatePageProps) => {
|
||||
const data = useRender();
|
||||
const { metadata } = data;
|
||||
const { colors, styles } = useLaprasTemplate();
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
const pageSize = getTemplatePageSize(metadata.page.format);
|
||||
const showHeader = pageIndex === 0;
|
||||
const mainSections = filterSections(page.main, data);
|
||||
const sidebarSections = filterSections(page.sidebar, data);
|
||||
|
||||
return (
|
||||
<Page size={pageSize} style={styles.page}>
|
||||
<TemplateProvider styles={styles} colors={colors}>
|
||||
{showHeader && <Header styles={styles} />}
|
||||
|
||||
<View style={composeStyles(styles.sectionGroup, { rowGap: metrics.gapY(1.5) })}>
|
||||
{mainSections.map((section, index) => (
|
||||
<Section key={index} section={section} placement="main" />
|
||||
))}
|
||||
</View>
|
||||
|
||||
{!page.fullWidth && (
|
||||
<View style={composeStyles(styles.sectionGroup, { rowGap: metrics.gapY(1.5) })}>
|
||||
{sidebarSections.map((section, index) => (
|
||||
<Section key={index} section={section} placement="sidebar" />
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</TemplateProvider>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
const Header = ({ styles }: { styles: LaprasStyles }) => {
|
||||
const { basics, picture } = useRender();
|
||||
const hasPicture = hasTemplatePicture(picture);
|
||||
|
||||
return (
|
||||
<View style={styles.header}>
|
||||
{hasPicture && <Image src={picture.url} style={styles.picture} />}
|
||||
|
||||
<View style={styles.headerTitle}>
|
||||
<View style={styles.headerIdentity}>
|
||||
<Heading style={styles.headerName}>{basics.name}</Heading>
|
||||
<Text>{basics.headline}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.contactList}>
|
||||
{basics.email && (
|
||||
<Link src={`mailto:${basics.email}`} style={styles.contactItem}>
|
||||
<Icon name="envelope" />
|
||||
<Text>{basics.email}</Text>
|
||||
</Link>
|
||||
)}
|
||||
{basics.phone && (
|
||||
<Link src={`tel:${basics.phone}`} style={styles.contactItem}>
|
||||
<Icon name="phone" />
|
||||
<Text>{basics.phone}</Text>
|
||||
</Link>
|
||||
)}
|
||||
{basics.location && (
|
||||
<View style={styles.contactItem}>
|
||||
<Icon name="map-pin" />
|
||||
<Text>{basics.location}</Text>
|
||||
</View>
|
||||
)}
|
||||
{basics.website.url && (
|
||||
<Link src={basics.website.url} style={styles.contactItem}>
|
||||
<Icon name="globe" />
|
||||
<Text>{basics.website.label}</Text>
|
||||
</Link>
|
||||
)}
|
||||
{basics.customFields.map((field) => (
|
||||
<Link key={field.id} src={field.link} style={styles.contactItem}>
|
||||
<Icon name={field.icon as IconName} />
|
||||
<Text>{field.text}</Text>
|
||||
</Link>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const getAlphaColor = (color: string, alpha: number): string => {
|
||||
const parsed = parseColorString(color);
|
||||
|
||||
if (!parsed) return rgbaStringToHex(color);
|
||||
|
||||
return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${Math.max(0, Math.min(1, alpha))})`;
|
||||
};
|
||||
|
||||
const useLaprasTemplate = (): LaprasTemplate => {
|
||||
const { picture, metadata } = useRender();
|
||||
|
||||
return useMemo(() => {
|
||||
const foreground = rgbaStringToHex(metadata.design.colors.text);
|
||||
const background = rgbaStringToHex(metadata.design.colors.background);
|
||||
const primary = rgbaStringToHex(metadata.design.colors.primary);
|
||||
const borderColor = getAlphaColor(metadata.design.colors.text, 0.1);
|
||||
const pictureBorderRadius = Math.min(picture.borderRadius, 30);
|
||||
const headingNegativeMargin = metadata.typography.heading.fontSize + 6;
|
||||
const colors: TemplateColorRoles = { foreground, background, primary };
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
|
||||
const bodyText = {
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
fontSize: metadata.typography.body.fontSize,
|
||||
fontWeight: metadata.typography.body.fontWeights[0] ?? "400",
|
||||
lineHeight: metadata.typography.body.lineHeight,
|
||||
color: foreground,
|
||||
} satisfies Style;
|
||||
|
||||
const baseStyles = StyleSheet.create({
|
||||
page: {
|
||||
color: foreground,
|
||||
backgroundColor: background,
|
||||
paddingHorizontal: metrics.page.paddingHorizontal,
|
||||
paddingVertical: metrics.page.paddingVertical,
|
||||
rowGap: metrics.gapY(1.5),
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
fontSize: metadata.typography.body.fontSize,
|
||||
lineHeight: metadata.typography.body.lineHeight,
|
||||
},
|
||||
text: bodyText,
|
||||
heading: {
|
||||
fontFamily: metadata.typography.heading.fontFamily,
|
||||
fontSize: metadata.typography.heading.fontSize,
|
||||
fontWeight: metadata.typography.heading.fontWeights.at(-1) ?? "600",
|
||||
lineHeight: metadata.typography.heading.lineHeight,
|
||||
color: foreground,
|
||||
},
|
||||
div: { rowGap: metrics.gapY(0.125), columnGap: metrics.gapX(1 / 3) },
|
||||
inline: { flexDirection: "row", alignItems: "center", columnGap: metrics.gapX(1 / 3) },
|
||||
link: { textDecoration: "none", color: foreground },
|
||||
small: { fontSize: metadata.typography.body.fontSize * 0.875 },
|
||||
bold: { fontWeight: metadata.typography.body.fontWeights.at(-1) ?? "600" },
|
||||
richParagraph: { margin: 0, ...bodyText },
|
||||
richListItemRow: { flexDirection: "row", columnGap: metrics.gapX(1 / 3), alignItems: "flex-start" },
|
||||
richListItemMarker: { width: metadata.typography.body.fontSize, textAlign: "right", ...bodyText },
|
||||
richListItemContent: { flex: 1, ...bodyText },
|
||||
splitRow: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
columnGap: metrics.gapX(2 / 3),
|
||||
},
|
||||
alignRight: { textAlign: "right", minWidth: 0, maxWidth: "100%", flexShrink: 1 },
|
||||
section: {
|
||||
flexDirection: "column",
|
||||
rowGap: metrics.gapY(0.25),
|
||||
borderWidth: 1,
|
||||
borderColor: borderColor,
|
||||
borderRadius: pictureBorderRadius,
|
||||
backgroundColor: background,
|
||||
padding: metrics.gapX(1),
|
||||
},
|
||||
sectionHeading: {
|
||||
alignSelf: "flex-start",
|
||||
marginTop: -headingNegativeMargin,
|
||||
backgroundColor: background,
|
||||
paddingHorizontal: metrics.gapX(1),
|
||||
},
|
||||
item: { rowGap: metrics.gapY(0.125) },
|
||||
levelContainer: { width: "100%" },
|
||||
levelItem: { borderColor: primary },
|
||||
levelItemActive: { backgroundColor: primary },
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
columnGap: metrics.gapX(1),
|
||||
borderWidth: 1,
|
||||
borderColor: borderColor,
|
||||
borderRadius: pictureBorderRadius,
|
||||
backgroundColor: background,
|
||||
padding: metrics.gapX(1),
|
||||
},
|
||||
picture: {
|
||||
width: picture.size,
|
||||
height: picture.size,
|
||||
objectFit: "cover",
|
||||
aspectRatio: picture.aspectRatio,
|
||||
borderRadius: picture.borderRadius,
|
||||
borderColor: rgbaStringToHex(picture.borderColor),
|
||||
borderWidth: picture.borderWidth,
|
||||
shadowColor: rgbaStringToHex(picture.shadowColor),
|
||||
shadowWidth: picture.shadowWidth,
|
||||
transform: `rotate(${picture.rotation}deg)`,
|
||||
},
|
||||
headerTitle: { rowGap: metrics.gapY(0.5) },
|
||||
headerIdentity: { textAlign: "left", alignItems: "flex-start", rowGap: metrics.gapY(0.35) },
|
||||
headerName: { fontSize: metadata.typography.heading.fontSize * 1.5, lineHeight: 1 },
|
||||
contactList: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
rowGap: metrics.gapY(0.125),
|
||||
columnGap: metrics.gapX(0.5),
|
||||
},
|
||||
contactItem: { flexDirection: "row", alignItems: "center", columnGap: metrics.gapX(1 / 6) },
|
||||
sectionGroup: {},
|
||||
});
|
||||
|
||||
const accentFor = ({ colors }: TemplateStyleContext) => colors.primary;
|
||||
|
||||
return {
|
||||
colors,
|
||||
styles: {
|
||||
...baseStyles,
|
||||
levelItem: (context) => ({ borderColor: accentFor(context) }),
|
||||
levelItemActive: (context) => ({ backgroundColor: accentFor(context) }),
|
||||
icon: (context) => ({
|
||||
display: metadata.page.hideIcons ? "none" : "flex",
|
||||
size: metadata.typography.body.fontSize,
|
||||
color: accentFor(context),
|
||||
}),
|
||||
} satisfies LaprasStyles,
|
||||
};
|
||||
}, [picture, metadata]);
|
||||
};
|
||||
@@ -0,0 +1,280 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { IconName } from "phosphor-icons-react-pdf/dynamic";
|
||||
import type { TemplatePageProps } from "../../document";
|
||||
import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
|
||||
import { Image, Page, StyleSheet, View } from "@react-pdf/renderer";
|
||||
import { useMemo } from "react";
|
||||
import { parseColorString, rgbaStringToHex } from "@reactive-resume/utils/color";
|
||||
import { useRender } from "../../context";
|
||||
import { TemplateProvider } from "../shared/context";
|
||||
import { filterSections } from "../shared/filtering";
|
||||
import { getTemplateMetrics } from "../shared/metrics";
|
||||
import { getTemplatePageSize } from "../shared/page-size";
|
||||
import { hasTemplatePicture } from "../shared/picture";
|
||||
import { Heading, Icon, Link, Text } from "../shared/primitives";
|
||||
import { Section } from "../shared/sections";
|
||||
import { composeStyles } from "../shared/styles";
|
||||
|
||||
type LeafishStyles = Omit<TemplateStyleSlots, "page"> & {
|
||||
page: Style;
|
||||
header: Style;
|
||||
headerIntro: Style;
|
||||
headerBody: Style;
|
||||
headerTitle: Style;
|
||||
headerIdentity: Style;
|
||||
headerName: Style;
|
||||
headerContactBand: Style;
|
||||
contactList: Style;
|
||||
contactItem: Style;
|
||||
picture: Style;
|
||||
body: Style;
|
||||
mainColumn: Style;
|
||||
sidebarColumn: Style;
|
||||
};
|
||||
|
||||
type LeafishTemplate = {
|
||||
colors: TemplateColorRoles;
|
||||
styles: LeafishStyles;
|
||||
};
|
||||
|
||||
export const LeafishPage = ({ page, pageIndex }: TemplatePageProps) => {
|
||||
const data = useRender();
|
||||
const { metadata } = data;
|
||||
const { colors, styles } = useLeafishTemplate();
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
const pageSize = getTemplatePageSize(metadata.page.format);
|
||||
const showHeader = pageIndex === 0;
|
||||
const mainSections = filterSections(page.main, data).filter((section) => section !== "summary");
|
||||
const sidebarSections = filterSections(page.sidebar, data).filter((section) => section !== "summary");
|
||||
|
||||
return (
|
||||
<Page size={pageSize} style={styles.page}>
|
||||
<TemplateProvider styles={styles} colors={colors}>
|
||||
{showHeader && <Header styles={styles} />}
|
||||
|
||||
<View style={styles.body}>
|
||||
<View style={composeStyles(styles.mainColumn, { rowGap: metrics.sectionGap })}>
|
||||
{mainSections.map((section, index) => (
|
||||
<Section key={index} section={section} placement="main" />
|
||||
))}
|
||||
</View>
|
||||
|
||||
{!page.fullWidth && (
|
||||
<View
|
||||
style={composeStyles(styles.sidebarColumn, {
|
||||
width: `${metadata.layout.sidebarWidth}%`,
|
||||
rowGap: metrics.sectionGap,
|
||||
})}
|
||||
>
|
||||
{sidebarSections.map((section, index) => (
|
||||
<Section key={index} section={section} placement="sidebar" />
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</TemplateProvider>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
const Header = ({ styles }: { styles: LeafishStyles }) => {
|
||||
const { basics, picture } = useRender();
|
||||
const hasPicture = hasTemplatePicture(picture);
|
||||
|
||||
return (
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerIntro}>
|
||||
<View style={styles.headerBody}>
|
||||
{hasPicture && <Image src={picture.url} style={styles.picture} />}
|
||||
|
||||
<View style={styles.headerTitle}>
|
||||
<View style={styles.headerIdentity}>
|
||||
<Heading style={styles.headerName}>{basics.name}</Heading>
|
||||
<Text>{basics.headline}</Text>
|
||||
</View>
|
||||
|
||||
<Section section="summary" placement="main" showHeading={false} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.headerContactBand}>
|
||||
<View style={styles.contactList}>
|
||||
{basics.email && (
|
||||
<Link src={`mailto:${basics.email}`} style={styles.contactItem}>
|
||||
<Icon name="envelope" />
|
||||
<Text>{basics.email}</Text>
|
||||
</Link>
|
||||
)}
|
||||
{basics.phone && (
|
||||
<Link src={`tel:${basics.phone}`} style={styles.contactItem}>
|
||||
<Icon name="phone" />
|
||||
<Text>{basics.phone}</Text>
|
||||
</Link>
|
||||
)}
|
||||
{basics.location && (
|
||||
<View style={styles.contactItem}>
|
||||
<Icon name="map-pin" />
|
||||
<Text>{basics.location}</Text>
|
||||
</View>
|
||||
)}
|
||||
{basics.website.url && (
|
||||
<Link src={basics.website.url} style={styles.contactItem}>
|
||||
<Icon name="globe" />
|
||||
<Text>{basics.website.label}</Text>
|
||||
</Link>
|
||||
)}
|
||||
{basics.customFields.map((field) => (
|
||||
<Link key={field.id} src={field.link} style={styles.contactItem}>
|
||||
<Icon name={field.icon as IconName} />
|
||||
<Text>{field.text}</Text>
|
||||
</Link>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const getPrimaryAlpha = (primaryColor: string, opacity: number): string => {
|
||||
const primary = parseColorString(primaryColor);
|
||||
|
||||
if (!primary) return rgbaStringToHex(primaryColor);
|
||||
|
||||
const alpha = Math.max(0, Math.min(1, primary.a * opacity));
|
||||
|
||||
return `rgba(${primary.r}, ${primary.g}, ${primary.b}, ${alpha})`;
|
||||
};
|
||||
|
||||
const useLeafishTemplate = (): LeafishTemplate => {
|
||||
const { picture, metadata } = useRender();
|
||||
|
||||
return useMemo(() => {
|
||||
const foreground = rgbaStringToHex(metadata.design.colors.text);
|
||||
const background = rgbaStringToHex(metadata.design.colors.background);
|
||||
const primary = rgbaStringToHex(metadata.design.colors.primary);
|
||||
const primaryTintLight = getPrimaryAlpha(metadata.design.colors.primary, 0.1);
|
||||
const primaryTintDark = getPrimaryAlpha(metadata.design.colors.primary, 0.2);
|
||||
const colors: TemplateColorRoles = { foreground, background, primary };
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
|
||||
const bodyText = {
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
fontSize: metadata.typography.body.fontSize,
|
||||
fontWeight: metadata.typography.body.fontWeights[0] ?? "400",
|
||||
lineHeight: metadata.typography.body.lineHeight,
|
||||
color: foreground,
|
||||
} satisfies Style;
|
||||
|
||||
const baseStyles = StyleSheet.create({
|
||||
page: {
|
||||
color: foreground,
|
||||
backgroundColor: background,
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
fontSize: metadata.typography.body.fontSize,
|
||||
lineHeight: metadata.typography.body.lineHeight,
|
||||
},
|
||||
text: bodyText,
|
||||
heading: {
|
||||
fontFamily: metadata.typography.heading.fontFamily,
|
||||
fontSize: metadata.typography.heading.fontSize,
|
||||
fontWeight: metadata.typography.heading.fontWeights.at(-1) ?? "600",
|
||||
lineHeight: metadata.typography.heading.lineHeight,
|
||||
color: foreground,
|
||||
},
|
||||
div: { rowGap: metrics.gapY(0.125), columnGap: metrics.gapX(1 / 3) },
|
||||
inline: { flexDirection: "row", alignItems: "center", columnGap: metrics.gapX(1 / 3) },
|
||||
link: { textDecoration: "none", color: foreground },
|
||||
small: { fontSize: metadata.typography.body.fontSize * 0.875 },
|
||||
bold: { fontWeight: metadata.typography.body.fontWeights.at(-1) ?? "600" },
|
||||
richParagraph: { margin: 0, ...bodyText },
|
||||
richListItemRow: { flexDirection: "row", columnGap: metrics.gapX(1 / 3), alignItems: "flex-start" },
|
||||
richListItemMarker: { width: metadata.typography.body.fontSize, textAlign: "right", ...bodyText },
|
||||
richListItemContent: { flex: 1, ...bodyText },
|
||||
splitRow: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
columnGap: metrics.gapX(2 / 3),
|
||||
},
|
||||
alignRight: { textAlign: "right", minWidth: 0, maxWidth: "100%", flexShrink: 1 },
|
||||
section: { flexDirection: "column", rowGap: metrics.gapY(0.25) },
|
||||
sectionHeading: { borderBottomWidth: 1, borderBottomColor: primary },
|
||||
item: { rowGap: metrics.gapY(0.125) },
|
||||
levelContainer: { width: "100%" },
|
||||
levelItem: { borderColor: primary },
|
||||
levelItemActive: { backgroundColor: primary },
|
||||
header: {},
|
||||
headerIntro: {
|
||||
backgroundColor: primaryTintLight,
|
||||
paddingHorizontal: metrics.page.paddingHorizontal,
|
||||
paddingVertical: metrics.page.paddingVertical,
|
||||
},
|
||||
headerBody: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
columnGap: metrics.gapX(1),
|
||||
},
|
||||
headerTitle: {
|
||||
flex: 1,
|
||||
rowGap: metrics.gapY(0.5),
|
||||
},
|
||||
headerIdentity: { textAlign: "left", alignItems: "flex-start", rowGap: metrics.gapY(0.35) },
|
||||
headerName: { fontSize: metadata.typography.heading.fontSize * 1.5, lineHeight: 1 },
|
||||
headerContactBand: {
|
||||
backgroundColor: primaryTintDark,
|
||||
paddingHorizontal: metrics.page.paddingHorizontal,
|
||||
paddingVertical: metrics.page.paddingVertical,
|
||||
},
|
||||
contactList: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
rowGap: metrics.gapY(0.125),
|
||||
columnGap: metrics.gapX(1),
|
||||
},
|
||||
contactItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
columnGap: metrics.gapX(1 / 6),
|
||||
},
|
||||
picture: {
|
||||
width: picture.size,
|
||||
height: picture.size,
|
||||
objectFit: "cover",
|
||||
aspectRatio: picture.aspectRatio,
|
||||
borderRadius: picture.borderRadius,
|
||||
borderColor: rgbaStringToHex(picture.borderColor),
|
||||
borderWidth: picture.borderWidth,
|
||||
shadowColor: rgbaStringToHex(picture.shadowColor),
|
||||
shadowWidth: picture.shadowWidth,
|
||||
transform: `rotate(${picture.rotation}deg)`,
|
||||
},
|
||||
body: {
|
||||
flexDirection: "row",
|
||||
columnGap: metrics.columnGap,
|
||||
paddingHorizontal: metrics.page.paddingHorizontal,
|
||||
paddingTop: metrics.page.paddingVertical,
|
||||
},
|
||||
mainColumn: { flex: 1 },
|
||||
sidebarColumn: { flexShrink: 0 },
|
||||
});
|
||||
|
||||
const accentFor = ({ colors }: TemplateStyleContext) => colors.primary;
|
||||
|
||||
return {
|
||||
colors,
|
||||
styles: {
|
||||
...baseStyles,
|
||||
sectionHeading: (context) => ({ ...baseStyles.sectionHeading, color: accentFor(context) }),
|
||||
levelItem: (context) => ({ borderColor: accentFor(context) }),
|
||||
levelItemActive: (context) => ({ backgroundColor: accentFor(context) }),
|
||||
icon: (context) => ({
|
||||
display: metadata.page.hideIcons ? "none" : "flex",
|
||||
size: metadata.typography.body.fontSize,
|
||||
color: accentFor(context),
|
||||
}),
|
||||
} satisfies LeafishStyles,
|
||||
};
|
||||
}, [picture, metadata]);
|
||||
};
|
||||
@@ -0,0 +1,242 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { IconName } from "phosphor-icons-react-pdf/dynamic";
|
||||
import type { TemplatePageProps } from "../../document";
|
||||
import type { TemplateColorRoles, TemplateFeatures, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
|
||||
import { Image, Page, StyleSheet, View } from "@react-pdf/renderer";
|
||||
import { useMemo } from "react";
|
||||
import { rgbaStringToHex } from "@reactive-resume/utils/color";
|
||||
import { useRender } from "../../context";
|
||||
import { TemplateProvider } from "../shared/context";
|
||||
import { filterSections } from "../shared/filtering";
|
||||
import { getTemplateMetrics } from "../shared/metrics";
|
||||
import { getTemplatePageSize } from "../shared/page-size";
|
||||
import { hasTemplatePicture } from "../shared/picture";
|
||||
import { Heading, Icon, Link, Text } from "../shared/primitives";
|
||||
import { Section } from "../shared/sections";
|
||||
import { composeStyles } from "../shared/styles";
|
||||
|
||||
type MeowthStyles = Omit<TemplateStyleSlots, "page"> & {
|
||||
page: Style;
|
||||
header: Style;
|
||||
headerTitle: Style;
|
||||
headerIdentity: Style;
|
||||
headerName: Style;
|
||||
headerHeadline: Style;
|
||||
contactList: Style;
|
||||
contactItem: Style;
|
||||
picture: Style;
|
||||
sectionGroup: Style;
|
||||
};
|
||||
|
||||
type MeowthTemplate = {
|
||||
colors: TemplateColorRoles;
|
||||
styles: MeowthStyles;
|
||||
};
|
||||
|
||||
const meowthFeatures = {
|
||||
inlineItemHeader: true,
|
||||
} satisfies TemplateFeatures;
|
||||
|
||||
export const MeowthPage = ({ page, pageIndex }: TemplatePageProps) => {
|
||||
const data = useRender();
|
||||
const { metadata } = data;
|
||||
const { colors, styles } = useMeowthTemplate();
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
const pageSize = getTemplatePageSize(metadata.page.format);
|
||||
const showHeader = pageIndex === 0;
|
||||
const mainSections = filterSections(page.main, data);
|
||||
const sidebarSections = filterSections(page.sidebar, data);
|
||||
|
||||
return (
|
||||
<Page size={pageSize} style={styles.page}>
|
||||
<TemplateProvider styles={styles} colors={colors} features={meowthFeatures}>
|
||||
{showHeader && <Header styles={styles} />}
|
||||
|
||||
<View style={composeStyles(styles.sectionGroup, { rowGap: metrics.sectionGap })}>
|
||||
{mainSections.map((section, index) => (
|
||||
<Section key={index} section={section} placement="main" />
|
||||
))}
|
||||
</View>
|
||||
|
||||
{!page.fullWidth && (
|
||||
<View style={composeStyles(styles.sectionGroup, { rowGap: metrics.sectionGap })}>
|
||||
{sidebarSections.map((section, index) => (
|
||||
<Section key={index} section={section} placement="sidebar" />
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</TemplateProvider>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
const Header = ({ styles }: { styles: MeowthStyles }) => {
|
||||
const { basics, picture } = useRender();
|
||||
const hasPicture = hasTemplatePicture(picture);
|
||||
|
||||
return (
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerTitle}>
|
||||
<View style={styles.headerIdentity}>
|
||||
<Heading style={styles.headerName}>{basics.name}</Heading>
|
||||
<Text style={styles.headerHeadline}>{basics.headline}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.contactList}>
|
||||
{basics.email && (
|
||||
<Link src={`mailto:${basics.email}`} style={styles.contactItem}>
|
||||
<Icon name="envelope" />
|
||||
<Text>{basics.email}</Text>
|
||||
</Link>
|
||||
)}
|
||||
{basics.phone && (
|
||||
<Link src={`tel:${basics.phone}`} style={styles.contactItem}>
|
||||
<Icon name="phone" />
|
||||
<Text>{basics.phone}</Text>
|
||||
</Link>
|
||||
)}
|
||||
{basics.location && (
|
||||
<View style={styles.contactItem}>
|
||||
<Icon name="map-pin" />
|
||||
<Text>{basics.location}</Text>
|
||||
</View>
|
||||
)}
|
||||
{basics.website.url && (
|
||||
<Link src={basics.website.url} style={styles.contactItem}>
|
||||
<Icon name="globe" />
|
||||
<Text>{basics.website.label}</Text>
|
||||
</Link>
|
||||
)}
|
||||
{basics.customFields.map((field) => (
|
||||
<Link key={field.id} src={field.link} style={styles.contactItem}>
|
||||
<Icon name={field.icon as IconName} />
|
||||
<Text>{field.text}</Text>
|
||||
</Link>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{hasPicture && <Image src={picture.url} style={styles.picture} />}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const useMeowthTemplate = (): MeowthTemplate => {
|
||||
const { picture, metadata } = useRender();
|
||||
|
||||
return useMemo(() => {
|
||||
const foreground = rgbaStringToHex(metadata.design.colors.text);
|
||||
const background = rgbaStringToHex(metadata.design.colors.background);
|
||||
const primary = rgbaStringToHex(metadata.design.colors.primary);
|
||||
const colors: TemplateColorRoles = { foreground, background, primary };
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
const bodyText = {
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
fontSize: metadata.typography.body.fontSize,
|
||||
fontWeight: metadata.typography.body.fontWeights[0] ?? "400",
|
||||
lineHeight: metadata.typography.body.lineHeight,
|
||||
color: foreground,
|
||||
} satisfies Style;
|
||||
|
||||
const baseStyles = StyleSheet.create({
|
||||
page: {
|
||||
color: foreground,
|
||||
backgroundColor: background,
|
||||
paddingHorizontal: metrics.page.paddingHorizontal,
|
||||
paddingVertical: metrics.page.paddingVertical,
|
||||
rowGap: metrics.sectionGap,
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
fontSize: metadata.typography.body.fontSize,
|
||||
lineHeight: metadata.typography.body.lineHeight,
|
||||
},
|
||||
text: bodyText,
|
||||
heading: {
|
||||
fontFamily: metadata.typography.heading.fontFamily,
|
||||
fontSize: metadata.typography.heading.fontSize,
|
||||
fontWeight: metadata.typography.heading.fontWeights.at(-1) ?? "600",
|
||||
lineHeight: metadata.typography.heading.lineHeight,
|
||||
color: foreground,
|
||||
},
|
||||
div: { rowGap: metrics.gapY(0.125), columnGap: metrics.gapX(1 / 3) },
|
||||
inline: { flexDirection: "row", alignItems: "center", columnGap: metrics.gapX(1 / 3) },
|
||||
link: { textDecoration: "none", color: foreground },
|
||||
small: { fontSize: metadata.typography.body.fontSize * 0.875 },
|
||||
bold: { fontWeight: metadata.typography.body.fontWeights.at(-1) ?? "600" },
|
||||
richParagraph: { margin: 0, ...bodyText },
|
||||
richListItemRow: { flexDirection: "row", columnGap: metrics.gapX(1 / 3), alignItems: "flex-start" },
|
||||
richListItemMarker: { width: metadata.typography.body.fontSize, textAlign: "right", ...bodyText },
|
||||
richListItemContent: { flex: 1, ...bodyText },
|
||||
splitRow: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
columnGap: metrics.gapX(2 / 3),
|
||||
},
|
||||
alignRight: { textAlign: "right", minWidth: 0, maxWidth: "100%", flexShrink: 1 },
|
||||
inlineItemHeader: { flexDirection: "row", alignItems: "flex-start", columnGap: metrics.gapX(0.75) },
|
||||
inlineItemHeaderLeading: { flex: 1, minWidth: 0 },
|
||||
inlineItemHeaderMiddle: { flex: 1, minWidth: 0 },
|
||||
inlineItemHeaderTrailing: { flexShrink: 0, textAlign: "right" },
|
||||
section: { flexDirection: "column", rowGap: metrics.gapY(0.25) },
|
||||
sectionHeading: {
|
||||
color: primary,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: primary,
|
||||
paddingBottom: metrics.gapY(0.125),
|
||||
},
|
||||
item: { rowGap: metrics.gapY(0.125) },
|
||||
levelContainer: { width: "100%" },
|
||||
levelItem: { borderColor: primary },
|
||||
levelItemActive: { backgroundColor: primary },
|
||||
header: { flexDirection: "row", alignItems: "flex-start", columnGap: metrics.gapX(1) },
|
||||
headerTitle: { flex: 1, rowGap: metrics.gapY(0.5) },
|
||||
headerIdentity: { textAlign: "left", alignItems: "flex-start", rowGap: metrics.gapY(0.35) },
|
||||
headerName: { fontSize: metadata.typography.heading.fontSize * 1.5, lineHeight: 1 },
|
||||
headerHeadline: { opacity: 0.8 },
|
||||
contactList: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
rowGap: metrics.gapY(0.125),
|
||||
columnGap: metrics.gapX(0.75),
|
||||
},
|
||||
contactItem: { flexDirection: "row", alignItems: "center", columnGap: metrics.gapX(1 / 6) },
|
||||
picture: {
|
||||
width: picture.size,
|
||||
height: picture.size,
|
||||
objectFit: "cover",
|
||||
aspectRatio: picture.aspectRatio,
|
||||
borderRadius: picture.borderRadius,
|
||||
borderColor: rgbaStringToHex(picture.borderColor),
|
||||
borderWidth: picture.borderWidth,
|
||||
shadowColor: rgbaStringToHex(picture.shadowColor),
|
||||
shadowWidth: picture.shadowWidth,
|
||||
transform: `rotate(${picture.rotation}deg)`,
|
||||
},
|
||||
sectionGroup: {},
|
||||
});
|
||||
|
||||
const accentFor = ({ colors }: TemplateStyleContext) => colors.primary;
|
||||
|
||||
return {
|
||||
colors,
|
||||
styles: {
|
||||
...baseStyles,
|
||||
sectionHeading: (context) => ({
|
||||
...baseStyles.sectionHeading,
|
||||
color: accentFor(context),
|
||||
borderBottomColor: accentFor(context),
|
||||
}),
|
||||
levelItem: (context) => ({ borderColor: accentFor(context) }),
|
||||
levelItemActive: (context) => ({ backgroundColor: accentFor(context) }),
|
||||
icon: (context) => ({
|
||||
display: metadata.page.hideIcons ? "none" : "flex",
|
||||
size: metadata.typography.body.fontSize,
|
||||
color: accentFor(context),
|
||||
}),
|
||||
} satisfies MeowthStyles,
|
||||
};
|
||||
}, [picture, metadata]);
|
||||
};
|
||||
@@ -0,0 +1,226 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { IconName } from "phosphor-icons-react-pdf/dynamic";
|
||||
import type { TemplatePageProps } from "../../document";
|
||||
import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
|
||||
import { Image, Page, StyleSheet, View } from "@react-pdf/renderer";
|
||||
import { useMemo } from "react";
|
||||
import { rgbaStringToHex } from "@reactive-resume/utils/color";
|
||||
import { useRender } from "../../context";
|
||||
import { TemplateProvider } from "../shared/context";
|
||||
import { filterSections } from "../shared/filtering";
|
||||
import { getTemplateMetrics } from "../shared/metrics";
|
||||
import { getTemplatePageSize } from "../shared/page-size";
|
||||
import { hasTemplatePicture } from "../shared/picture";
|
||||
import { Heading, Icon, Link, Text } from "../shared/primitives";
|
||||
import { Section } from "../shared/sections";
|
||||
import { composeStyles } from "../shared/styles";
|
||||
|
||||
type OnyxStyles = Omit<TemplateStyleSlots, "page"> & {
|
||||
page: Style;
|
||||
header: Style;
|
||||
picture: Style;
|
||||
headerTitle: Style;
|
||||
headerIdentity: Style;
|
||||
headerName: Style;
|
||||
contactList: Style;
|
||||
contactItem: Style;
|
||||
sectionGroup: Style;
|
||||
};
|
||||
|
||||
type OnyxTemplate = {
|
||||
colors: TemplateColorRoles;
|
||||
styles: OnyxStyles;
|
||||
};
|
||||
|
||||
export const OnyxPage = ({ page, pageIndex }: TemplatePageProps) => {
|
||||
const data = useRender();
|
||||
const { metadata } = data;
|
||||
const { colors, styles } = useOnyxTemplate();
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
const pageSize = getTemplatePageSize(metadata.page.format);
|
||||
const showHeader = pageIndex === 0;
|
||||
const mainSections = filterSections(page.main, data);
|
||||
const sidebarSections = filterSections(page.sidebar, data);
|
||||
|
||||
return (
|
||||
<Page size={pageSize} style={styles.page}>
|
||||
<TemplateProvider styles={styles} colors={colors}>
|
||||
{showHeader && <Header styles={styles} />}
|
||||
|
||||
<View style={composeStyles(styles.sectionGroup, { rowGap: metrics.sectionGap })}>
|
||||
{mainSections.map((section, index) => (
|
||||
<Section key={index} section={section} placement="main" />
|
||||
))}
|
||||
</View>
|
||||
|
||||
{!page.fullWidth && (
|
||||
<View style={composeStyles(styles.sectionGroup, { rowGap: metrics.sectionGap })}>
|
||||
{sidebarSections.map((section, index) => (
|
||||
<Section key={index} section={section} placement="sidebar" />
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</TemplateProvider>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
const Header = ({ styles }: { styles: OnyxStyles }) => {
|
||||
const { basics, picture } = useRender();
|
||||
const hasPicture = hasTemplatePicture(picture);
|
||||
|
||||
return (
|
||||
<View style={styles.header}>
|
||||
{hasPicture && <Image src={picture.url} style={styles.picture} />}
|
||||
|
||||
<View style={styles.headerTitle}>
|
||||
<View style={styles.headerIdentity}>
|
||||
<Heading style={styles.headerName}>{basics.name}</Heading>
|
||||
<Text>{basics.headline}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.contactList}>
|
||||
{basics.email && (
|
||||
<Link src={`mailto:${basics.email}`} style={styles.contactItem}>
|
||||
<Icon name="envelope" />
|
||||
<Text>{basics.email}</Text>
|
||||
</Link>
|
||||
)}
|
||||
{basics.phone && (
|
||||
<Link src={`tel:${basics.phone}`} style={styles.contactItem}>
|
||||
<Icon name="phone" />
|
||||
<Text>{basics.phone}</Text>
|
||||
</Link>
|
||||
)}
|
||||
{basics.location && (
|
||||
<View style={styles.contactItem}>
|
||||
<Icon name="map-pin" />
|
||||
<Text>{basics.location}</Text>
|
||||
</View>
|
||||
)}
|
||||
{basics.website.url && (
|
||||
<Link src={basics.website.url} style={styles.contactItem}>
|
||||
<Icon name="globe" />
|
||||
<Text>{basics.website.label}</Text>
|
||||
</Link>
|
||||
)}
|
||||
{basics.customFields.map((field) => (
|
||||
<Link key={field.id} src={field.link} style={styles.contactItem}>
|
||||
<Icon name={field.icon as IconName} />
|
||||
<Text>{field.text}</Text>
|
||||
</Link>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const useOnyxTemplate = (): OnyxTemplate => {
|
||||
const { picture, metadata } = useRender();
|
||||
|
||||
return useMemo(() => {
|
||||
const foreground = rgbaStringToHex(metadata.design.colors.text);
|
||||
const background = rgbaStringToHex(metadata.design.colors.background);
|
||||
const primary = rgbaStringToHex(metadata.design.colors.primary);
|
||||
const colors: TemplateColorRoles = { foreground, background, primary };
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
const bodyText = {
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
fontSize: metadata.typography.body.fontSize,
|
||||
fontWeight: metadata.typography.body.fontWeights[0] ?? "400",
|
||||
lineHeight: metadata.typography.body.lineHeight,
|
||||
color: foreground,
|
||||
} satisfies Style;
|
||||
|
||||
const baseStyles = StyleSheet.create({
|
||||
page: {
|
||||
color: foreground,
|
||||
backgroundColor: background,
|
||||
paddingHorizontal: metrics.page.paddingHorizontal,
|
||||
paddingVertical: metrics.page.paddingVertical,
|
||||
rowGap: metrics.sectionGap,
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
fontSize: metadata.typography.body.fontSize,
|
||||
lineHeight: metadata.typography.body.lineHeight,
|
||||
},
|
||||
text: bodyText,
|
||||
heading: {
|
||||
fontFamily: metadata.typography.heading.fontFamily,
|
||||
fontSize: metadata.typography.heading.fontSize,
|
||||
fontWeight: metadata.typography.heading.fontWeights.at(-1) ?? "600",
|
||||
lineHeight: metadata.typography.heading.lineHeight,
|
||||
color: foreground,
|
||||
},
|
||||
div: { rowGap: metrics.gapY(0.125), columnGap: metrics.gapX(1 / 3) },
|
||||
inline: { flexDirection: "row", alignItems: "center", columnGap: metrics.gapX(1 / 3) },
|
||||
link: { textDecoration: "none", color: foreground },
|
||||
small: { fontSize: metadata.typography.body.fontSize * 0.875 },
|
||||
bold: { fontWeight: metadata.typography.body.fontWeights.at(-1) ?? "600" },
|
||||
richParagraph: { margin: 0, ...bodyText },
|
||||
richListItemRow: { flexDirection: "row", columnGap: metrics.gapX(1 / 3), alignItems: "flex-start" },
|
||||
richListItemMarker: { width: metadata.typography.body.fontSize, textAlign: "right", ...bodyText },
|
||||
richListItemContent: { flex: 1, ...bodyText },
|
||||
splitRow: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
columnGap: metrics.gapX(2 / 3),
|
||||
},
|
||||
alignRight: { textAlign: "right", minWidth: 0, maxWidth: "100%", flexShrink: 1 },
|
||||
section: { flexDirection: "column", rowGap: metrics.gapY(0.25) },
|
||||
item: { rowGap: metrics.gapY(0.125) },
|
||||
levelContainer: { width: "100%" },
|
||||
levelItem: { borderColor: primary },
|
||||
levelItemActive: { backgroundColor: primary },
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
columnGap: metrics.gapX(1),
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: primary,
|
||||
paddingBottom: metrics.page.paddingVertical,
|
||||
},
|
||||
picture: {
|
||||
width: picture.size,
|
||||
height: picture.size,
|
||||
objectFit: "cover",
|
||||
aspectRatio: picture.aspectRatio,
|
||||
borderRadius: picture.borderRadius,
|
||||
borderColor: rgbaStringToHex(picture.borderColor),
|
||||
borderWidth: picture.borderWidth,
|
||||
shadowColor: rgbaStringToHex(picture.shadowColor),
|
||||
shadowWidth: picture.shadowWidth,
|
||||
transform: `rotate(${picture.rotation}deg)`,
|
||||
},
|
||||
headerTitle: { rowGap: metrics.gapY(0.5) },
|
||||
headerIdentity: { textAlign: "left", alignItems: "flex-start", rowGap: metrics.gapY(0.35) },
|
||||
headerName: { fontSize: metadata.typography.heading.fontSize * 1.5, lineHeight: 1 },
|
||||
contactList: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
rowGap: metrics.gapY(0.125),
|
||||
columnGap: metrics.gapX(0.75),
|
||||
},
|
||||
contactItem: { flexDirection: "row", alignItems: "center", columnGap: metrics.gapX(1 / 6) },
|
||||
sectionGroup: {},
|
||||
});
|
||||
|
||||
const accentFor = ({ colors }: TemplateStyleContext) => colors.primary;
|
||||
|
||||
return {
|
||||
colors,
|
||||
styles: {
|
||||
...baseStyles,
|
||||
levelItem: (context) => ({ borderColor: accentFor(context) }),
|
||||
levelItemActive: (context) => ({ backgroundColor: accentFor(context) }),
|
||||
icon: (context) => ({
|
||||
display: metadata.page.hideIcons ? "none" : "flex",
|
||||
size: metadata.typography.body.fontSize,
|
||||
color: accentFor(context),
|
||||
}),
|
||||
} satisfies OnyxStyles,
|
||||
};
|
||||
}, [picture, metadata]);
|
||||
};
|
||||
@@ -0,0 +1,297 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { IconName } from "phosphor-icons-react-pdf/dynamic";
|
||||
import type { TemplatePageProps } from "../../document";
|
||||
import type { TemplateColorRoles, TemplateFeatures, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
|
||||
import { Image, Page, StyleSheet, View } from "@react-pdf/renderer";
|
||||
import { useMemo } from "react";
|
||||
import { rgbaStringToHex } from "@reactive-resume/utils/color";
|
||||
import { useRender } from "../../context";
|
||||
import { TemplateProvider } from "../shared/context";
|
||||
import { filterSections } from "../shared/filtering";
|
||||
import { getTemplateMetrics } from "../shared/metrics";
|
||||
import { getTemplatePageSize } from "../shared/page-size";
|
||||
import { hasTemplatePicture } from "../shared/picture";
|
||||
import { Heading, Icon, Link, Text } from "../shared/primitives";
|
||||
import { Section } from "../shared/sections";
|
||||
import { composeStyles, resolvePlacementColor } from "../shared/styles";
|
||||
|
||||
type PikachuStyles = Omit<TemplateStyleSlots, "page"> & {
|
||||
page: Style;
|
||||
layout: Style;
|
||||
sidebarColumn: Style;
|
||||
sidebarContent: Style;
|
||||
mainColumn: Style;
|
||||
headerRow: Style;
|
||||
header: Style;
|
||||
headerDivider: Style;
|
||||
headerIdentity: Style;
|
||||
headerName: Style;
|
||||
headerText: Style;
|
||||
contactList: Style;
|
||||
contactItem: Style;
|
||||
picture: Style;
|
||||
};
|
||||
|
||||
type PikachuTemplate = {
|
||||
colors: TemplateColorRoles;
|
||||
styles: PikachuStyles;
|
||||
};
|
||||
|
||||
const pikachuFeatures = {
|
||||
stackSidebarItemHeader: true,
|
||||
} satisfies TemplateFeatures;
|
||||
|
||||
export const PikachuPage = ({ page, pageIndex }: TemplatePageProps) => {
|
||||
const data = useRender();
|
||||
const { metadata, picture } = data;
|
||||
const { colors, styles } = usePikachuTemplate();
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
const pageSize = getTemplatePageSize(metadata.page.format);
|
||||
const showHeader = pageIndex === 0;
|
||||
const showSidebar = !page.fullWidth;
|
||||
const hasPicture = hasTemplatePicture(picture);
|
||||
const mainSections = filterSections(page.main, data);
|
||||
const sidebarSections = filterSections(page.sidebar, data);
|
||||
|
||||
return (
|
||||
<Page size={pageSize} style={styles.page}>
|
||||
<TemplateProvider styles={styles} colors={colors} features={pikachuFeatures}>
|
||||
<View style={styles.layout}>
|
||||
{showSidebar && (
|
||||
<View
|
||||
style={composeStyles(styles.sidebarColumn, {
|
||||
width: `${metadata.layout.sidebarWidth}%`,
|
||||
rowGap: metrics.sectionGap,
|
||||
})}
|
||||
>
|
||||
{showHeader && showSidebar && hasPicture && <Image src={picture.url} style={styles.picture} />}
|
||||
|
||||
<View style={composeStyles(styles.sidebarContent, { rowGap: metrics.sectionGap })}>
|
||||
{sidebarSections.map((section, index) => (
|
||||
<Section key={index} section={section} placement="sidebar" />
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={composeStyles(styles.mainColumn, { rowGap: metrics.sectionGap })}>
|
||||
{showHeader && (
|
||||
<View style={styles.headerRow}>
|
||||
{showHeader && !showSidebar && hasPicture && <Image src={picture.url} style={styles.picture} />}
|
||||
<Header styles={styles} colors={colors} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={{ rowGap: metrics.sectionGap }}>
|
||||
{mainSections.map((section, index) => (
|
||||
<Section key={index} section={section} placement="main" />
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</TemplateProvider>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
const Header = ({ styles, colors }: { styles: PikachuStyles; colors: TemplateColorRoles }) => {
|
||||
const { basics } = useRender();
|
||||
|
||||
return (
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerDivider}>
|
||||
<View style={styles.headerIdentity}>
|
||||
<Heading style={styles.headerName}>{basics.name}</Heading>
|
||||
<Text style={styles.headerText}>{basics.headline}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.contactList}>
|
||||
{basics.email && (
|
||||
<Link src={`mailto:${basics.email}`} style={styles.contactItem}>
|
||||
<Icon name="envelope" color={colors.background} />
|
||||
<Text style={styles.headerText}>{basics.email}</Text>
|
||||
</Link>
|
||||
)}
|
||||
{basics.phone && (
|
||||
<Link src={`tel:${basics.phone}`} style={styles.contactItem}>
|
||||
<Icon name="phone" color={colors.background} />
|
||||
<Text style={styles.headerText}>{basics.phone}</Text>
|
||||
</Link>
|
||||
)}
|
||||
{basics.location && (
|
||||
<View style={styles.contactItem}>
|
||||
<Icon name="map-pin" color={colors.background} />
|
||||
<Text style={styles.headerText}>{basics.location}</Text>
|
||||
</View>
|
||||
)}
|
||||
{basics.website.url && (
|
||||
<Link src={basics.website.url} style={styles.contactItem}>
|
||||
<Icon name="globe" color={colors.background} />
|
||||
<Text style={styles.headerText}>{basics.website.label}</Text>
|
||||
</Link>
|
||||
)}
|
||||
{basics.customFields.map((field) => (
|
||||
<Link key={field.id} src={field.link} style={styles.contactItem}>
|
||||
<Icon name={field.icon as IconName} color={colors.background} />
|
||||
<Text style={styles.headerText}>{field.text}</Text>
|
||||
</Link>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const usePikachuTemplate = (): PikachuTemplate => {
|
||||
const { picture, metadata } = useRender();
|
||||
|
||||
return useMemo(() => {
|
||||
const foreground = rgbaStringToHex(metadata.design.colors.text);
|
||||
const background = rgbaStringToHex(metadata.design.colors.background);
|
||||
const primary = rgbaStringToHex(metadata.design.colors.primary);
|
||||
const colors: TemplateColorRoles = { foreground, background, primary };
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
|
||||
const bodyText = {
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
fontSize: metadata.typography.body.fontSize,
|
||||
fontWeight: metadata.typography.body.fontWeights[0] ?? "400",
|
||||
lineHeight: metadata.typography.body.lineHeight,
|
||||
color: foreground,
|
||||
} satisfies Style;
|
||||
|
||||
const baseStyles = StyleSheet.create({
|
||||
page: {
|
||||
color: foreground,
|
||||
backgroundColor: background,
|
||||
paddingHorizontal: metrics.page.paddingHorizontal,
|
||||
paddingVertical: metrics.page.paddingVertical,
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
fontSize: metadata.typography.body.fontSize,
|
||||
lineHeight: metadata.typography.body.lineHeight,
|
||||
},
|
||||
text: bodyText,
|
||||
heading: {
|
||||
fontFamily: metadata.typography.heading.fontFamily,
|
||||
fontSize: metadata.typography.heading.fontSize,
|
||||
fontWeight: metadata.typography.heading.fontWeights.at(-1) ?? "600",
|
||||
lineHeight: metadata.typography.heading.lineHeight,
|
||||
color: foreground,
|
||||
},
|
||||
div: { rowGap: metrics.gapY(0.125), columnGap: metrics.gapX(1 / 3) },
|
||||
inline: { flexDirection: "row", alignItems: "center", columnGap: metrics.gapX(1 / 3) },
|
||||
link: { textDecoration: "none", color: foreground },
|
||||
small: { fontSize: metadata.typography.body.fontSize * 0.875 },
|
||||
bold: { fontWeight: metadata.typography.body.fontWeights.at(-1) ?? "600" },
|
||||
richParagraph: { margin: 0, ...bodyText },
|
||||
richListItemRow: { flexDirection: "row", columnGap: metrics.gapX(1 / 3), alignItems: "flex-start" },
|
||||
richListItemMarker: { width: metadata.typography.body.fontSize, textAlign: "right", ...bodyText },
|
||||
richListItemContent: { flex: 1, ...bodyText },
|
||||
splitRow: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
columnGap: metrics.gapX(2 / 3),
|
||||
},
|
||||
alignRight: { textAlign: "right", minWidth: 0, maxWidth: "100%", flexShrink: 1 },
|
||||
section: { flexDirection: "column", rowGap: metrics.gapY(0.25) },
|
||||
sectionHeading: { borderBottomWidth: 1, borderBottomColor: primary },
|
||||
item: { rowGap: metrics.gapY(0.125) },
|
||||
levelContainer: { width: "100%" },
|
||||
levelItem: { borderColor: primary },
|
||||
levelItemActive: { backgroundColor: primary },
|
||||
layout: {
|
||||
flexDirection: "row",
|
||||
columnGap: metrics.columnGap,
|
||||
},
|
||||
sidebarColumn: {
|
||||
flexShrink: 0,
|
||||
},
|
||||
sidebarContent: { overflow: "hidden" },
|
||||
mainColumn: {
|
||||
flex: 1,
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
columnGap: metrics.gapX(1),
|
||||
},
|
||||
header: {
|
||||
flex: 1,
|
||||
rowGap: metrics.gapY(0.5),
|
||||
backgroundColor: primary,
|
||||
color: background,
|
||||
borderRadius: picture.borderRadius,
|
||||
paddingHorizontal: metrics.page.paddingHorizontal,
|
||||
paddingVertical: metrics.page.paddingVertical,
|
||||
},
|
||||
headerDivider: {
|
||||
rowGap: metrics.gapY(0.125),
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: background,
|
||||
paddingBottom: metrics.gapY(0.5),
|
||||
},
|
||||
headerIdentity: {
|
||||
textAlign: "left",
|
||||
alignItems: "flex-start",
|
||||
rowGap: metrics.gapY(0.35),
|
||||
},
|
||||
headerName: {
|
||||
color: background,
|
||||
fontSize: metadata.typography.heading.fontSize * 1.5,
|
||||
lineHeight: 1,
|
||||
},
|
||||
headerText: { color: background },
|
||||
contactList: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
rowGap: metrics.gapY(0.125),
|
||||
columnGap: metrics.gapX(0.75),
|
||||
},
|
||||
contactItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
columnGap: metrics.gapX(1 / 6),
|
||||
},
|
||||
picture: {
|
||||
width: picture.size,
|
||||
height: picture.size,
|
||||
objectFit: "cover",
|
||||
aspectRatio: picture.aspectRatio,
|
||||
borderRadius: picture.borderRadius,
|
||||
borderColor: rgbaStringToHex(picture.borderColor),
|
||||
borderWidth: picture.borderWidth,
|
||||
shadowColor: rgbaStringToHex(picture.shadowColor),
|
||||
shadowWidth: picture.shadowWidth,
|
||||
transform: `rotate(${picture.rotation}deg)`,
|
||||
},
|
||||
});
|
||||
|
||||
const accentFor = ({ colors }: TemplateStyleContext) => colors.primary;
|
||||
const foregroundFor = (context: TemplateStyleContext) =>
|
||||
resolvePlacementColor({
|
||||
placement: context.placement,
|
||||
defaultForeground: colors.foreground,
|
||||
sidebarForeground: colors.sidebarForeground,
|
||||
});
|
||||
|
||||
return {
|
||||
colors,
|
||||
styles: {
|
||||
...baseStyles,
|
||||
text: (context) => ({ ...bodyText, color: foregroundFor(context) }),
|
||||
heading: (context) => ({ ...baseStyles.heading, color: foregroundFor(context) }),
|
||||
link: (context) => ({ ...baseStyles.link, color: foregroundFor(context) }),
|
||||
sectionHeading: (context) => ({ ...baseStyles.sectionHeading, color: accentFor(context) }),
|
||||
levelItem: (context) => ({ borderColor: accentFor(context) }),
|
||||
levelItemActive: (context) => ({ backgroundColor: accentFor(context) }),
|
||||
icon: (context) => ({
|
||||
display: metadata.page.hideIcons ? "none" : "flex",
|
||||
size: metadata.typography.body.fontSize,
|
||||
color: accentFor(context),
|
||||
}),
|
||||
} satisfies PikachuStyles,
|
||||
};
|
||||
}, [picture, metadata]);
|
||||
};
|
||||
@@ -0,0 +1,245 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { IconName } from "phosphor-icons-react-pdf/dynamic";
|
||||
import type { TemplatePageProps } from "../../document";
|
||||
import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
|
||||
import { Image, Page, StyleSheet, View } from "@react-pdf/renderer";
|
||||
import { useMemo } from "react";
|
||||
import { rgbaStringToHex } from "@reactive-resume/utils/color";
|
||||
import { useRender } from "../../context";
|
||||
import { TemplateProvider } from "../shared/context";
|
||||
import { filterSections } from "../shared/filtering";
|
||||
import { getTemplateMetrics } from "../shared/metrics";
|
||||
import { getTemplatePageSize } from "../shared/page-size";
|
||||
import { hasTemplatePicture } from "../shared/picture";
|
||||
import { Heading, Icon, Link, Text } from "../shared/primitives";
|
||||
import { Section } from "../shared/sections";
|
||||
import { composeStyles } from "../shared/styles";
|
||||
|
||||
type RhyhornStyles = Omit<TemplateStyleSlots, "page"> & {
|
||||
page: Style;
|
||||
header: Style;
|
||||
headerTitle: Style;
|
||||
headerIdentity: Style;
|
||||
headerName: Style;
|
||||
contactList: Style;
|
||||
contactItem: Style;
|
||||
contactItemContent: Style;
|
||||
contactItemLast: Style;
|
||||
picture: Style;
|
||||
sectionGroup: Style;
|
||||
};
|
||||
|
||||
type RhyhornTemplate = {
|
||||
colors: TemplateColorRoles;
|
||||
styles: RhyhornStyles;
|
||||
};
|
||||
|
||||
export const RhyhornPage = ({ page, pageIndex }: TemplatePageProps) => {
|
||||
const data = useRender();
|
||||
const { metadata } = data;
|
||||
const { colors, styles } = useRhyhornTemplate();
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
const pageSize = getTemplatePageSize(metadata.page.format);
|
||||
const showHeader = pageIndex === 0;
|
||||
const mainSections = filterSections(page.main, data);
|
||||
const sidebarSections = filterSections(page.sidebar, data);
|
||||
|
||||
return (
|
||||
<Page size={pageSize} style={styles.page}>
|
||||
<TemplateProvider styles={styles} colors={colors}>
|
||||
{showHeader && <Header styles={styles} />}
|
||||
|
||||
<View style={composeStyles(styles.sectionGroup, { rowGap: metrics.sectionGap })}>
|
||||
{mainSections.map((section, index) => (
|
||||
<Section key={index} section={section} placement="main" />
|
||||
))}
|
||||
</View>
|
||||
|
||||
{!page.fullWidth && (
|
||||
<View style={composeStyles(styles.sectionGroup, { rowGap: metrics.sectionGap })}>
|
||||
{sidebarSections.map((section, index) => (
|
||||
<Section key={index} section={section} placement="sidebar" />
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</TemplateProvider>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
const Header = ({ styles }: { styles: RhyhornStyles }) => {
|
||||
const { basics, picture } = useRender();
|
||||
const hasPicture = hasTemplatePicture(picture);
|
||||
const contactItems = [
|
||||
basics.email && (
|
||||
<Link src={`mailto:${basics.email}`} style={styles.contactItemContent}>
|
||||
<Icon name="envelope" />
|
||||
<Text>{basics.email}</Text>
|
||||
</Link>
|
||||
),
|
||||
basics.phone && (
|
||||
<Link src={`tel:${basics.phone}`} style={styles.contactItemContent}>
|
||||
<Icon name="phone" />
|
||||
<Text>{basics.phone}</Text>
|
||||
</Link>
|
||||
),
|
||||
basics.location && (
|
||||
<View style={styles.contactItemContent}>
|
||||
<Icon name="map-pin" />
|
||||
<Text>{basics.location}</Text>
|
||||
</View>
|
||||
),
|
||||
basics.website.url && (
|
||||
<Link src={basics.website.url} style={styles.contactItemContent}>
|
||||
<Icon name="globe" />
|
||||
<Text>{basics.website.label}</Text>
|
||||
</Link>
|
||||
),
|
||||
...basics.customFields.map((field) => (
|
||||
<Link key={field.id} src={field.link} style={styles.contactItemContent}>
|
||||
<Icon name={field.icon as IconName} />
|
||||
<Text>{field.text}</Text>
|
||||
</Link>
|
||||
)),
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerTitle}>
|
||||
<View style={styles.headerIdentity}>
|
||||
<Heading style={styles.headerName}>{basics.name}</Heading>
|
||||
<Text>{basics.headline}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.contactList}>
|
||||
{contactItems.map((item, index) => (
|
||||
<View
|
||||
key={index}
|
||||
style={composeStyles(
|
||||
styles.contactItem,
|
||||
index === contactItems.length - 1 ? styles.contactItemLast : undefined,
|
||||
)}
|
||||
>
|
||||
{item}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{hasPicture && <Image src={picture.url} style={styles.picture} />}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const useRhyhornTemplate = (): RhyhornTemplate => {
|
||||
const { picture, metadata } = useRender();
|
||||
|
||||
return useMemo(() => {
|
||||
const foreground = rgbaStringToHex(metadata.design.colors.text);
|
||||
const background = rgbaStringToHex(metadata.design.colors.background);
|
||||
const primary = rgbaStringToHex(metadata.design.colors.primary);
|
||||
const colors: TemplateColorRoles = { foreground, background, primary };
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
|
||||
const bodyText = {
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
fontSize: metadata.typography.body.fontSize,
|
||||
fontWeight: metadata.typography.body.fontWeights[0] ?? "400",
|
||||
lineHeight: metadata.typography.body.lineHeight,
|
||||
color: foreground,
|
||||
} satisfies Style;
|
||||
|
||||
const baseStyles = StyleSheet.create({
|
||||
page: {
|
||||
color: foreground,
|
||||
backgroundColor: background,
|
||||
paddingHorizontal: metrics.page.paddingHorizontal,
|
||||
paddingVertical: metrics.page.paddingVertical,
|
||||
rowGap: metrics.sectionGap,
|
||||
fontFamily: metadata.typography.body.fontFamily,
|
||||
fontSize: metadata.typography.body.fontSize,
|
||||
lineHeight: metadata.typography.body.lineHeight,
|
||||
},
|
||||
text: bodyText,
|
||||
heading: {
|
||||
fontFamily: metadata.typography.heading.fontFamily,
|
||||
fontSize: metadata.typography.heading.fontSize,
|
||||
fontWeight: metadata.typography.heading.fontWeights.at(-1) ?? "600",
|
||||
lineHeight: metadata.typography.heading.lineHeight,
|
||||
color: foreground,
|
||||
},
|
||||
div: { rowGap: metrics.gapY(0.125), columnGap: metrics.gapX(1 / 3) },
|
||||
inline: { flexDirection: "row", alignItems: "center", columnGap: metrics.gapX(1 / 3) },
|
||||
link: { textDecoration: "none", color: foreground },
|
||||
small: { fontSize: metadata.typography.body.fontSize * 0.875 },
|
||||
bold: { fontWeight: metadata.typography.body.fontWeights.at(-1) ?? "600" },
|
||||
richParagraph: { margin: 0, ...bodyText },
|
||||
richListItemRow: { flexDirection: "row", columnGap: metrics.gapX(1 / 3), alignItems: "flex-start" },
|
||||
richListItemMarker: { width: metadata.typography.body.fontSize, textAlign: "right", ...bodyText },
|
||||
richListItemContent: { flex: 1, ...bodyText },
|
||||
splitRow: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
columnGap: metrics.gapX(2 / 3),
|
||||
},
|
||||
alignRight: { textAlign: "right", minWidth: 0, maxWidth: "100%", flexShrink: 1 },
|
||||
section: { flexDirection: "column", rowGap: metrics.gapY(0.25) },
|
||||
sectionHeading: { color: primary, borderBottomWidth: 1, borderBottomColor: primary },
|
||||
item: { rowGap: metrics.gapY(0.125) },
|
||||
levelContainer: { width: "100%" },
|
||||
levelItem: { borderColor: primary },
|
||||
levelItemActive: { backgroundColor: primary },
|
||||
header: { flexDirection: "row", alignItems: "center", columnGap: metrics.gapX(0.5) },
|
||||
headerTitle: { flex: 1, rowGap: metrics.gapY(0.5) },
|
||||
headerIdentity: { textAlign: "left", alignItems: "flex-start", rowGap: metrics.gapY(0.35) },
|
||||
headerName: { fontSize: metadata.typography.heading.fontSize * 1.5, lineHeight: 1 },
|
||||
contactList: { flexDirection: "row", flexWrap: "wrap", rowGap: metrics.gapY(0.125) },
|
||||
contactItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: primary,
|
||||
paddingRight: metrics.gapX(0.5),
|
||||
marginRight: metrics.gapX(0.5),
|
||||
},
|
||||
contactItemContent: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
columnGap: metrics.gapX(1 / 6),
|
||||
},
|
||||
contactItemLast: { borderRightWidth: 0, paddingRight: 0, marginRight: 0 },
|
||||
picture: {
|
||||
width: picture.size,
|
||||
height: picture.size,
|
||||
objectFit: "cover",
|
||||
aspectRatio: picture.aspectRatio,
|
||||
borderRadius: picture.borderRadius,
|
||||
borderColor: rgbaStringToHex(picture.borderColor),
|
||||
borderWidth: picture.borderWidth,
|
||||
shadowColor: rgbaStringToHex(picture.shadowColor),
|
||||
shadowWidth: picture.shadowWidth,
|
||||
transform: `rotate(${picture.rotation}deg)`,
|
||||
},
|
||||
sectionGroup: {},
|
||||
});
|
||||
|
||||
const accentFor = ({ colors }: TemplateStyleContext) => colors.primary;
|
||||
|
||||
return {
|
||||
colors,
|
||||
styles: {
|
||||
...baseStyles,
|
||||
sectionHeading: (context) => ({ ...baseStyles.sectionHeading, color: accentFor(context) }),
|
||||
levelItem: (context) => ({ borderColor: accentFor(context) }),
|
||||
levelItemActive: (context) => ({ backgroundColor: accentFor(context) }),
|
||||
icon: (context) => ({
|
||||
display: metadata.page.hideIcons ? "none" : "flex",
|
||||
size: metadata.typography.body.fontSize,
|
||||
color: accentFor(context),
|
||||
}),
|
||||
} satisfies RhyhornStyles,
|
||||
};
|
||||
}, [picture, metadata]);
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { TemplatePlacement } from "./styles";
|
||||
|
||||
const MIN_SECTION_COLUMNS = 1;
|
||||
const MAX_SECTION_COLUMNS = 6;
|
||||
|
||||
type SectionItemsLayoutInput = {
|
||||
columns: unknown;
|
||||
rowGap: number;
|
||||
columnGap: number;
|
||||
};
|
||||
|
||||
type SectionTimelineInput = {
|
||||
sectionTimeline: boolean;
|
||||
placement: TemplatePlacement;
|
||||
columns: unknown;
|
||||
};
|
||||
|
||||
export type SectionItemsLayout = {
|
||||
columns: number;
|
||||
containerStyle: Style;
|
||||
rowStyle: Style | undefined;
|
||||
itemStyle: Style | undefined;
|
||||
isGrid: boolean;
|
||||
};
|
||||
|
||||
const normalizeSectionColumns = (columns: unknown): number => {
|
||||
if (typeof columns !== "number" || !Number.isFinite(columns) || !Number.isInteger(columns))
|
||||
return MIN_SECTION_COLUMNS;
|
||||
|
||||
return Math.min(MAX_SECTION_COLUMNS, Math.max(MIN_SECTION_COLUMNS, columns));
|
||||
};
|
||||
|
||||
export const getSectionItemsLayout = ({ columns, rowGap, columnGap }: SectionItemsLayoutInput): SectionItemsLayout => {
|
||||
const normalizedColumns = normalizeSectionColumns(columns);
|
||||
|
||||
if (normalizedColumns === 1) {
|
||||
return {
|
||||
columns: normalizedColumns,
|
||||
containerStyle: { rowGap },
|
||||
rowStyle: undefined,
|
||||
itemStyle: undefined,
|
||||
isGrid: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
columns: normalizedColumns,
|
||||
containerStyle: { rowGap },
|
||||
rowStyle: {
|
||||
flexDirection: "row",
|
||||
columnGap,
|
||||
},
|
||||
itemStyle: {
|
||||
flexBasis: 0,
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
maxWidth: "100%",
|
||||
overflow: "hidden",
|
||||
},
|
||||
isGrid: true,
|
||||
};
|
||||
};
|
||||
|
||||
export const getSectionItemRows = <T>(items: T[], columns: unknown): T[][] => {
|
||||
const normalizedColumns = normalizeSectionColumns(columns);
|
||||
const rows: T[][] = [];
|
||||
|
||||
for (let index = 0; index < items.length; index += normalizedColumns) {
|
||||
rows.push(items.slice(index, index + normalizedColumns));
|
||||
}
|
||||
|
||||
return rows;
|
||||
};
|
||||
|
||||
export const shouldUseSectionTimeline = ({ sectionTimeline, placement, columns }: SectionTimelineInput): boolean => {
|
||||
return sectionTimeline && placement === "main" && normalizeSectionColumns(columns) === 1;
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { StyleInput, TemplatePlacement } from "./styles";
|
||||
import type {
|
||||
SectionTimelineStyleSlots,
|
||||
TemplateColorRoles,
|
||||
TemplateFeatureStyleSlots,
|
||||
TemplateFeatures,
|
||||
TemplateIconProps,
|
||||
TemplateIconSlot,
|
||||
TemplateStyleSlot,
|
||||
TemplateStyleSlots,
|
||||
} from "./types";
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
type TemplateContextValue = {
|
||||
styles: TemplateStyleSlots;
|
||||
featureStyles: TemplateFeatureStyleSlots;
|
||||
colors: TemplateColorRoles;
|
||||
features: TemplateFeatures;
|
||||
};
|
||||
|
||||
type TemplateProviderProps = Omit<TemplateContextValue, "featureStyles" | "features" | "sectionTitleFallbacks"> & {
|
||||
featureStyles?: TemplateFeatureStyleSlots;
|
||||
features?: TemplateFeatures;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const TemplateContext = createContext<TemplateContextValue | null>(null);
|
||||
const TemplatePlacementContext = createContext<TemplatePlacement>("main");
|
||||
|
||||
const resolveStyleSlot = (slot: TemplateStyleSlot | undefined, context: TemplateStyleContextValue): StyleInput => {
|
||||
if (!slot) return undefined;
|
||||
if (typeof slot === "function") return slot(context);
|
||||
|
||||
return slot;
|
||||
};
|
||||
|
||||
const resolveIconSlot = (
|
||||
slot: TemplateIconSlot | undefined,
|
||||
context: TemplateStyleContextValue,
|
||||
): Partial<TemplateIconProps> => {
|
||||
if (!slot) return {};
|
||||
if (typeof slot === "function") return slot(context);
|
||||
|
||||
return slot;
|
||||
};
|
||||
|
||||
type TemplateStyleContextValue = {
|
||||
placement: TemplatePlacement;
|
||||
colors: TemplateColorRoles;
|
||||
};
|
||||
|
||||
export const TemplateProvider = ({
|
||||
styles,
|
||||
featureStyles = {},
|
||||
colors,
|
||||
features = {},
|
||||
children,
|
||||
}: TemplateProviderProps) => {
|
||||
return (
|
||||
<TemplateContext.Provider value={{ styles, featureStyles, colors, features }}>{children}</TemplateContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const TemplatePlacementProvider = ({
|
||||
placement,
|
||||
children,
|
||||
}: {
|
||||
placement: TemplatePlacement;
|
||||
children: ReactNode;
|
||||
}) => {
|
||||
return <TemplatePlacementContext.Provider value={placement}>{children}</TemplatePlacementContext.Provider>;
|
||||
};
|
||||
|
||||
const useTemplateContext = () => {
|
||||
const context = useContext(TemplateContext);
|
||||
|
||||
if (!context) throw new Error("useTemplateContext must be called inside a <TemplateProvider>.");
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export const useTemplateFeature = (feature: keyof TemplateFeatures): boolean => {
|
||||
const { features } = useTemplateContext();
|
||||
|
||||
return features[feature] ?? false;
|
||||
};
|
||||
|
||||
export const useTemplatePlacement = () => useContext(TemplatePlacementContext);
|
||||
|
||||
const useTemplateStyleContext = (): TemplateStyleContextValue => {
|
||||
const { colors } = useTemplateContext();
|
||||
const placement = useTemplatePlacement();
|
||||
|
||||
return { placement, colors };
|
||||
};
|
||||
|
||||
export const useTemplateStyle = (slot: keyof TemplateStyleSlots): StyleInput => {
|
||||
const { styles } = useTemplateContext();
|
||||
const context = useTemplateStyleContext();
|
||||
|
||||
return resolveStyleSlot(styles[slot] as TemplateStyleSlot | undefined, context);
|
||||
};
|
||||
|
||||
export const useTemplateFeatureStyle = (
|
||||
feature: keyof TemplateFeatureStyleSlots,
|
||||
slot: keyof SectionTimelineStyleSlots,
|
||||
): StyleInput => {
|
||||
const { featureStyles } = useTemplateContext();
|
||||
const context = useTemplateStyleContext();
|
||||
const slots = featureStyles[feature];
|
||||
|
||||
return resolveStyleSlot(slots?.[slot] as TemplateStyleSlot | undefined, context);
|
||||
};
|
||||
|
||||
export const useTemplateIconSlot = (slot: "icon") => {
|
||||
const { styles } = useTemplateContext();
|
||||
const context = useTemplateStyleContext();
|
||||
|
||||
return resolveIconSlot(styles[slot], context);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Summary } from "@reactive-resume/schema/resume/data";
|
||||
|
||||
type HiddenItem = {
|
||||
hidden: boolean;
|
||||
};
|
||||
|
||||
type ItemSectionLike<T extends HiddenItem = HiddenItem> = {
|
||||
hidden: boolean;
|
||||
items: T[];
|
||||
};
|
||||
|
||||
type FilterableData = {
|
||||
summary: Pick<Summary, "hidden" | "content">;
|
||||
sections: Partial<Record<string, ItemSectionLike>>;
|
||||
customSections: Array<ItemSectionLike & { id: string }>;
|
||||
};
|
||||
|
||||
const isItemSection = (section: unknown): section is ItemSectionLike => {
|
||||
return typeof section === "object" && section !== null && "items" in section;
|
||||
};
|
||||
|
||||
const isSummarySection = (section: unknown): section is Summary => {
|
||||
return typeof section === "object" && section !== null && "content" in section;
|
||||
};
|
||||
|
||||
export const filterItems = <T extends HiddenItem>(items: T[]): T[] => {
|
||||
return items.filter((item) => !item.hidden);
|
||||
};
|
||||
|
||||
export const hasVisibleItems = (section: ItemSectionLike): boolean => {
|
||||
return !section.hidden && filterItems(section.items).length > 0;
|
||||
};
|
||||
|
||||
export const isVisibleSummary = (summary: Pick<Summary, "hidden" | "content">): boolean => {
|
||||
return !summary.hidden && summary.content.trim().length > 0;
|
||||
};
|
||||
|
||||
const getSectionForFiltering = (sectionId: string, data: FilterableData) => {
|
||||
if (sectionId === "summary") return data.summary;
|
||||
|
||||
return data.sections[sectionId] ?? data.customSections.find((section) => section.id === sectionId);
|
||||
};
|
||||
|
||||
export const isSectionVisible = (sectionId: string, data: FilterableData): boolean => {
|
||||
const section = getSectionForFiltering(sectionId, data);
|
||||
|
||||
if (!section) return false;
|
||||
if (isSummarySection(section)) return isVisibleSummary(section);
|
||||
if (isItemSection(section)) return hasVisibleItems(section);
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const filterSections = (sectionIds: string[], data: FilterableData): string[] => {
|
||||
return sectionIds.filter((sectionId) => isSectionVisible(sectionId, data));
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { IconName } from "phosphor-icons-react-pdf/dynamic";
|
||||
import { View } from "@react-pdf/renderer";
|
||||
import { useRender } from "../../context";
|
||||
import { useTemplateIconSlot, useTemplateStyle } from "./context";
|
||||
import { getTemplateMetrics } from "./metrics";
|
||||
import { Icon } from "./primitives";
|
||||
import { composeStyles } from "./styles";
|
||||
|
||||
export const LevelDisplay = ({ level }: { level: number }) => {
|
||||
const data = useRender();
|
||||
const levelDesign = data.metadata.design.level;
|
||||
const iconSize = data.metadata.typography.body.fontSize - 4;
|
||||
const metrics = getTemplateMetrics(data.metadata.page);
|
||||
const iconProps = useTemplateIconSlot("icon");
|
||||
const levelContainerStyle = useTemplateStyle("levelContainer");
|
||||
const levelItemStyle = useTemplateStyle("levelItem");
|
||||
const levelItemActiveStyle = useTemplateStyle("levelItemActive");
|
||||
const levelItemInactiveStyle = useTemplateStyle("levelItemInactive");
|
||||
const color = typeof iconProps.color === "string" ? iconProps.color : "#000000";
|
||||
|
||||
if (level === 0) return null;
|
||||
if (levelDesign.type === "hidden") return null;
|
||||
if (levelDesign.type === "icon" && levelDesign.icon === "") return null;
|
||||
|
||||
let gap = metrics.gapX(1 / 3);
|
||||
|
||||
if (levelDesign.type === "progress-bar") {
|
||||
gap = 0;
|
||||
}
|
||||
|
||||
if (levelDesign.type === "rectangle-full") {
|
||||
gap = metrics.gapX(0.5);
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={composeStyles(
|
||||
{ flexDirection: "row", alignItems: "center", marginTop: 2, columnGap: gap },
|
||||
levelContainerStyle,
|
||||
)}
|
||||
>
|
||||
{Array.from({ length: 5 }).map((_, index) => {
|
||||
const isActive = index < level;
|
||||
|
||||
if (levelDesign.type === "icon") {
|
||||
return (
|
||||
<Icon
|
||||
key={index}
|
||||
size={iconSize + 4}
|
||||
name={levelDesign.icon as IconName}
|
||||
style={{ opacity: isActive ? 1 : 0.35 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (levelDesign.type === "progress-bar") {
|
||||
return (
|
||||
<View
|
||||
key={index}
|
||||
style={composeStyles(
|
||||
{
|
||||
flex: 1,
|
||||
height: iconSize,
|
||||
borderWidth: 0.75,
|
||||
borderColor: color,
|
||||
backgroundColor: isActive ? color : "transparent",
|
||||
},
|
||||
levelItemStyle,
|
||||
isActive ? levelItemActiveStyle : levelItemInactiveStyle,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const itemStyle: Style = {};
|
||||
let borderRadius = 0;
|
||||
let width: string | number = iconSize;
|
||||
|
||||
if (levelDesign.type === "rectangle") {
|
||||
width = 16;
|
||||
}
|
||||
|
||||
if (levelDesign.type === "rectangle-full") {
|
||||
width = "auto";
|
||||
itemStyle.flex = 1;
|
||||
}
|
||||
|
||||
if (levelDesign.type === "circle") {
|
||||
borderRadius = 999;
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
key={index}
|
||||
style={composeStyles(
|
||||
{
|
||||
width,
|
||||
height: iconSize,
|
||||
borderWidth: 0.75,
|
||||
borderColor: color,
|
||||
borderRadius,
|
||||
backgroundColor: isActive ? color : "transparent",
|
||||
...itemStyle,
|
||||
},
|
||||
levelItemStyle,
|
||||
isActive ? levelItemActiveStyle : levelItemInactiveStyle,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Small } from "./primitives";
|
||||
|
||||
export const MetaLine = ({ children }: { children: Array<string | undefined> }) => {
|
||||
const content = children.filter(Boolean).join(" • ");
|
||||
|
||||
if (!content) return null;
|
||||
|
||||
return <Small>{content}</Small>;
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
|
||||
type PageMetricsInput = Pick<ResumeData["metadata"]["page"], "gapX" | "gapY" | "marginX" | "marginY">;
|
||||
|
||||
export type TemplateMetrics = {
|
||||
page: {
|
||||
paddingHorizontal: number;
|
||||
paddingVertical: number;
|
||||
};
|
||||
headerGap: number;
|
||||
columnGap: number;
|
||||
sectionGap: number;
|
||||
itemGapX: number;
|
||||
itemGapY: number;
|
||||
gapX: (factor: number) => number;
|
||||
gapY: (factor: number) => number;
|
||||
};
|
||||
|
||||
export const getTemplateMetrics = ({ gapX, gapY, marginX, marginY }: PageMetricsInput): TemplateMetrics => ({
|
||||
page: {
|
||||
paddingHorizontal: marginX,
|
||||
paddingVertical: marginY,
|
||||
},
|
||||
headerGap: marginY,
|
||||
columnGap: marginX,
|
||||
sectionGap: marginY,
|
||||
itemGapX: gapX,
|
||||
itemGapY: gapY,
|
||||
gapX: (factor) => gapX * factor,
|
||||
gapY: (factor) => gapY * factor,
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
|
||||
export const getTemplatePageSize = (format: ResumeData["metadata"]["page"]["format"]) =>
|
||||
format === "letter" ? "LETTER" : "A4";
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
|
||||
export const hasTemplatePicture = (picture: ResumeData["picture"]) => !picture.hidden && picture.url.trim() !== "";
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type { ComponentProps } from "react";
|
||||
import type { StyleInput } from "./styles";
|
||||
import { Link as PdfLink, Text as PdfText, View } from "@react-pdf/renderer";
|
||||
import { Icon as PhosphorIcon } from "phosphor-icons-react-pdf/dynamic";
|
||||
import { useTemplateIconSlot, useTemplateStyle } from "./context";
|
||||
import { composeStyles } from "./styles";
|
||||
|
||||
const asStyleInput = (style: unknown): StyleInput => style as StyleInput;
|
||||
|
||||
export const safeTextStyle = {
|
||||
minWidth: 0,
|
||||
maxWidth: "100%",
|
||||
flexShrink: 1,
|
||||
overflow: "hidden",
|
||||
} satisfies Style;
|
||||
|
||||
export const Div = ({ style, ...props }: ComponentProps<typeof View>) => {
|
||||
const divStyle = useTemplateStyle("div");
|
||||
|
||||
return <View style={composeStyles(divStyle, style as Style | Style[] | undefined)} {...props} />;
|
||||
};
|
||||
|
||||
export const Text = ({ style, ...props }: ComponentProps<typeof PdfText>) => {
|
||||
const textStyle = useTemplateStyle("text");
|
||||
|
||||
return <PdfText style={composeStyles(textStyle, asStyleInput(style), safeTextStyle)} {...props} />;
|
||||
};
|
||||
|
||||
export const Heading = ({ style, ...props }: ComponentProps<typeof PdfText>) => {
|
||||
const headingStyle = useTemplateStyle("heading");
|
||||
|
||||
return <PdfText style={composeStyles(headingStyle, asStyleInput(style), safeTextStyle)} {...props} />;
|
||||
};
|
||||
|
||||
export const Link = ({ style, ...props }: ComponentProps<typeof PdfLink>) => {
|
||||
const linkStyle = useTemplateStyle("link");
|
||||
|
||||
return <PdfLink style={composeStyles(linkStyle, asStyleInput(style), safeTextStyle)} {...props} />;
|
||||
};
|
||||
|
||||
export const Small = ({ style, ...props }: ComponentProps<typeof PdfText>) => {
|
||||
const textStyle = useTemplateStyle("text");
|
||||
const smallStyle = useTemplateStyle("small");
|
||||
|
||||
return <PdfText style={composeStyles(textStyle, smallStyle, asStyleInput(style), safeTextStyle)} {...props} />;
|
||||
};
|
||||
|
||||
export const Bold = ({ style, ...props }: ComponentProps<typeof PdfText>) => {
|
||||
const textStyle = useTemplateStyle("text");
|
||||
const boldStyle = useTemplateStyle("bold");
|
||||
|
||||
return <PdfText style={composeStyles(textStyle, boldStyle, asStyleInput(style), safeTextStyle)} {...props} />;
|
||||
};
|
||||
|
||||
export const Icon = ({ style, ...props }: ComponentProps<typeof PhosphorIcon>) => {
|
||||
const { style: iconStyle, ...iconProps } = useTemplateIconSlot("icon");
|
||||
|
||||
if (iconProps.display === "none") return null;
|
||||
|
||||
return <PhosphorIcon {...iconProps} {...props} style={composeStyles(asStyleInput(iconStyle), asStyleInput(style))} />;
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import { Text as PdfText, View } from "@react-pdf/renderer";
|
||||
import { Html } from "react-pdf-html";
|
||||
import { useTemplateStyle } from "./context";
|
||||
import { safeTextStyle } from "./primitives";
|
||||
import { composeStyles, mergeStyles } from "./styles";
|
||||
|
||||
export const RichText = ({ children }: { children: string }) => {
|
||||
const boldStyle = useTemplateStyle("bold");
|
||||
const linkStyle = useTemplateStyle("link");
|
||||
const richParagraphStyle = useTemplateStyle("richParagraph");
|
||||
const richListItemRowStyle = useTemplateStyle("richListItemRow");
|
||||
const richListItemMarkerStyle = useTemplateStyle("richListItemMarker");
|
||||
const richListItemContentStyle = useTemplateStyle("richListItemContent");
|
||||
|
||||
if (!children.trim()) return null;
|
||||
|
||||
return (
|
||||
<Html
|
||||
resetStyles
|
||||
renderers={{
|
||||
b: ({ children }) => <PdfText style={composeStyles(boldStyle, safeTextStyle)}>{children}</PdfText>,
|
||||
li: ({ element, style, children }) => {
|
||||
const list = element.closest("ol, ul");
|
||||
const isOrderedList = list?.rawTagName === "ol" || element.parentNode.tag === "ol";
|
||||
const marker = isOrderedList ? `${element.indexOfType + 1}.` : "•";
|
||||
const itemStyle = Array.isArray(style) ? (style as Style[]) : [style as Style | undefined];
|
||||
|
||||
return (
|
||||
<View style={composeStyles(richListItemRowStyle)}>
|
||||
<PdfText style={composeStyles(richListItemMarkerStyle)}>{marker}</PdfText>
|
||||
<PdfText style={composeStyles(richListItemContentStyle, ...itemStyle, safeTextStyle)}>{children}</PdfText>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
}}
|
||||
stylesheet={{
|
||||
b: mergeStyles(boldStyle, safeTextStyle),
|
||||
strong: mergeStyles(boldStyle, safeTextStyle),
|
||||
p: mergeStyles(richParagraphStyle, safeTextStyle),
|
||||
a: mergeStyles(linkStyle, { textDecoration: "underline" }, safeTextStyle),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,901 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
import type {
|
||||
AwardItem,
|
||||
CertificationItem,
|
||||
CoverLetterItem,
|
||||
EducationItem,
|
||||
ExperienceItem,
|
||||
InterestItem,
|
||||
LanguageItem,
|
||||
ProfileItem,
|
||||
ProjectItem,
|
||||
PublicationItem,
|
||||
ReferenceItem,
|
||||
SkillItem,
|
||||
SummaryItem,
|
||||
VolunteerItem,
|
||||
} from "@reactive-resume/schema/resume/data";
|
||||
import type { IconName } from "phosphor-icons-react-pdf/dynamic";
|
||||
import type { ReactNode } from "react";
|
||||
import type { StyleInput, TemplatePlacement } from "./styles";
|
||||
import type { CustomItemSection, ItemSection } from "./types";
|
||||
import { View } from "@react-pdf/renderer";
|
||||
import { Children, createContext, useContext } from "react";
|
||||
import { match } from "ts-pattern";
|
||||
import { useRender } from "../../context";
|
||||
import { getResumeSectionTitle } from "../../section-title";
|
||||
import { getSectionItemRows, getSectionItemsLayout, shouldUseSectionTimeline } from "./columns";
|
||||
import {
|
||||
TemplatePlacementProvider,
|
||||
useTemplateFeature,
|
||||
useTemplateFeatureStyle,
|
||||
useTemplatePlacement,
|
||||
useTemplateStyle,
|
||||
} from "./context";
|
||||
import { filterItems, hasVisibleItems, isSectionVisible, isVisibleSummary } from "./filtering";
|
||||
import { LevelDisplay } from "./level-display";
|
||||
import { MetaLine } from "./meta-line";
|
||||
import { getTemplateMetrics } from "./metrics";
|
||||
import { Bold, Div, Heading, Icon, Link, Small, Text } from "./primitives";
|
||||
import { RichText } from "./rich-text";
|
||||
import { composeStyles } from "./styles";
|
||||
|
||||
type SectionItemsContextValue = {
|
||||
itemStyle: StyleInput;
|
||||
useTimeline: boolean;
|
||||
};
|
||||
|
||||
const SectionItemsContext = createContext<SectionItemsContextValue>({ itemStyle: undefined, useTimeline: false });
|
||||
|
||||
const useSectionItemsContext = () => useContext(SectionItemsContext);
|
||||
|
||||
const getVisibleItems = <T extends { hidden: boolean }>(section: ItemSection<T>): T[] => {
|
||||
if (!hasVisibleItems(section)) return [];
|
||||
|
||||
return filterItems(section.items);
|
||||
};
|
||||
|
||||
const SectionShell = ({
|
||||
sectionId,
|
||||
title,
|
||||
showHeading = true,
|
||||
children,
|
||||
}: {
|
||||
sectionId: string;
|
||||
title: string;
|
||||
showHeading?: boolean;
|
||||
children: ReactNode;
|
||||
}) => {
|
||||
const data = useRender();
|
||||
const sectionStyle = useTemplateStyle("section");
|
||||
const sectionHeadingStyle = useTemplateStyle("sectionHeading");
|
||||
const sectionTitle = getResumeSectionTitle(data, sectionId, title);
|
||||
|
||||
return (
|
||||
<View style={composeStyles(sectionStyle)}>
|
||||
{showHeading && <Heading style={composeStyles(sectionHeadingStyle)}>{sectionTitle}</Heading>}
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const SectionItems = ({ children, columns = 1 }: { children: ReactNode; columns?: number }) => {
|
||||
const data = useRender();
|
||||
const placement = useTemplatePlacement();
|
||||
const sectionTimeline = useTemplateFeature("sectionTimeline");
|
||||
const sectionItemsStyle = useTemplateStyle("sectionItems");
|
||||
const timelineItemsStyle = useTemplateFeatureStyle("sectionTimeline", "items");
|
||||
const timelineLineStyle = useTemplateFeatureStyle("sectionTimeline", "line");
|
||||
const metrics = getTemplateMetrics(data.metadata.page);
|
||||
const layout = getSectionItemsLayout({
|
||||
columns,
|
||||
rowGap: metrics.itemGapY,
|
||||
columnGap: metrics.itemGapX,
|
||||
});
|
||||
const useTimeline = shouldUseSectionTimeline({
|
||||
sectionTimeline,
|
||||
placement,
|
||||
columns: layout.columns,
|
||||
});
|
||||
const context = { itemStyle: layout.itemStyle, useTimeline };
|
||||
|
||||
if (!useTimeline) {
|
||||
if (layout.isGrid) {
|
||||
const rows = getSectionItemRows(Children.toArray(children), layout.columns);
|
||||
|
||||
return (
|
||||
<SectionItemsContext.Provider value={context}>
|
||||
<View style={composeStyles(layout.containerStyle, sectionItemsStyle)}>
|
||||
{rows.map((row, rowIndex) => (
|
||||
<View key={rowIndex} style={composeStyles(layout.rowStyle)}>
|
||||
{row}
|
||||
{Array.from({ length: layout.columns - row.length }, (_, index) => (
|
||||
<View key={`placeholder-${index}`} style={composeStyles(layout.itemStyle)} />
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</SectionItemsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionItemsContext.Provider value={context}>
|
||||
<View style={composeStyles(layout.containerStyle, sectionItemsStyle)}>{children}</View>
|
||||
</SectionItemsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionItemsContext.Provider value={context}>
|
||||
<View style={composeStyles(layout.containerStyle, sectionItemsStyle, timelineItemsStyle)}>
|
||||
<View style={composeStyles(timelineLineStyle)} />
|
||||
{children}
|
||||
</View>
|
||||
</SectionItemsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const SectionItem = ({ children, style }: { children: ReactNode; style?: StyleInput }) => {
|
||||
const { itemStyle: sectionItemStyle, useTimeline } = useSectionItemsContext();
|
||||
const itemStyle = useTemplateStyle("item");
|
||||
const timelineItemStyle = useTemplateFeatureStyle("sectionTimeline", "item");
|
||||
const timelineMarkerStyle = useTemplateFeatureStyle("sectionTimeline", "marker");
|
||||
const timelineDotStyle = useTemplateFeatureStyle("sectionTimeline", "dot");
|
||||
const timelineContentStyle = useTemplateFeatureStyle("sectionTimeline", "content");
|
||||
|
||||
if (!useTimeline) {
|
||||
return <Div style={composeStyles(itemStyle, sectionItemStyle, style)}>{children}</Div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={composeStyles(timelineItemStyle)}>
|
||||
<View style={composeStyles(timelineMarkerStyle)}>
|
||||
<View style={composeStyles(timelineDotStyle)} />
|
||||
</View>
|
||||
<Div style={composeStyles(itemStyle, timelineContentStyle, style)}>{children}</Div>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const InlineItemHeader = ({
|
||||
leading,
|
||||
middle,
|
||||
trailing,
|
||||
}: {
|
||||
leading?: ReactNode;
|
||||
middle?: ReactNode;
|
||||
trailing?: ReactNode;
|
||||
}) => {
|
||||
const inlineItemHeaderStyle = useTemplateStyle("inlineItemHeader");
|
||||
const leadingStyle = useTemplateStyle("inlineItemHeaderLeading");
|
||||
const middleStyle = useTemplateStyle("inlineItemHeaderMiddle");
|
||||
const trailingStyle = useTemplateStyle("inlineItemHeaderTrailing");
|
||||
|
||||
return (
|
||||
<View style={composeStyles(inlineItemHeaderStyle)}>
|
||||
<View style={composeStyles(leadingStyle)}>{leading}</View>
|
||||
<View style={composeStyles(middleStyle)}>{middle}</View>
|
||||
<View style={composeStyles(trailingStyle)}>{trailing}</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const stackedSidebarSplitRowStyle = {
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
} satisfies Style;
|
||||
|
||||
const useSectionSplitRowStyle = () => {
|
||||
const splitRowStyle = useTemplateStyle("splitRow");
|
||||
const placement = useTemplatePlacement();
|
||||
const stackSidebarItemHeader = useTemplateFeature("stackSidebarItemHeader");
|
||||
|
||||
return composeStyles(
|
||||
splitRowStyle,
|
||||
stackSidebarItemHeader && placement === "sidebar" ? stackedSidebarSplitRowStyle : undefined,
|
||||
);
|
||||
};
|
||||
|
||||
const SectionItemHeader = ({ children }: { children: ReactNode }) => {
|
||||
const mainItemHeaderBorder = useTemplateFeature("mainItemHeaderBorder");
|
||||
const sectionItemHeaderStyle = useTemplateStyle("sectionItemHeader");
|
||||
|
||||
if (!mainItemHeaderBorder) return <>{children}</>;
|
||||
|
||||
return <View style={composeStyles(sectionItemHeaderStyle)}>{children}</View>;
|
||||
};
|
||||
|
||||
const SummarySection = ({ showHeading = true }: { showHeading?: boolean } = {}) => {
|
||||
const data = useRender();
|
||||
const { summary } = data;
|
||||
|
||||
if (!isVisibleSummary(summary)) return null;
|
||||
|
||||
return (
|
||||
<SectionShell sectionId="summary" title={summary.title} showHeading={showHeading}>
|
||||
<SectionItems>
|
||||
<SectionItem>
|
||||
<RichText>{summary.content}</RichText>
|
||||
</SectionItem>
|
||||
</SectionItems>
|
||||
</SectionShell>
|
||||
);
|
||||
};
|
||||
|
||||
const CustomSummarySection = ({
|
||||
section,
|
||||
showHeading = true,
|
||||
}: {
|
||||
section: CustomItemSection<SummaryItem>;
|
||||
showHeading?: boolean;
|
||||
}) => {
|
||||
const items = getVisibleItems(section);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SectionShell sectionId={section.id} title={section.title} showHeading={showHeading}>
|
||||
<SectionItems columns={section.columns}>
|
||||
{items.map((item) => (
|
||||
<SectionItem key={item.id}>
|
||||
<RichText>{item.content}</RichText>
|
||||
</SectionItem>
|
||||
))}
|
||||
</SectionItems>
|
||||
</SectionShell>
|
||||
);
|
||||
};
|
||||
|
||||
const ProfileSection = ({
|
||||
sectionId = "profiles",
|
||||
sectionData,
|
||||
}: {
|
||||
sectionId?: string;
|
||||
sectionData?: ItemSection<ProfileItem>;
|
||||
} = {}) => {
|
||||
const data = useRender();
|
||||
const profiles = sectionData ?? data.sections.profiles;
|
||||
const items = getVisibleItems(profiles);
|
||||
const inlineStyle = useTemplateStyle("inline");
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SectionShell sectionId={sectionId} title={profiles.title}>
|
||||
<SectionItems columns={profiles.columns}>
|
||||
{items.map((item) => (
|
||||
<SectionItem key={item.id}>
|
||||
<SectionItemHeader>
|
||||
<View style={composeStyles(inlineStyle)}>
|
||||
<Icon name={item.icon as IconName} />
|
||||
<Bold>{item.network}</Bold>
|
||||
</View>
|
||||
</SectionItemHeader>
|
||||
<Link src={item.website.url}>
|
||||
<Text>{item.username}</Text>
|
||||
</Link>
|
||||
</SectionItem>
|
||||
))}
|
||||
</SectionItems>
|
||||
</SectionShell>
|
||||
);
|
||||
};
|
||||
|
||||
const ExperienceSection = ({
|
||||
sectionId = "experience",
|
||||
sectionData,
|
||||
}: {
|
||||
sectionId?: string;
|
||||
sectionData?: ItemSection<ExperienceItem>;
|
||||
} = {}) => {
|
||||
const data = useRender();
|
||||
const experience = sectionData ?? data.sections.experience;
|
||||
const items = getVisibleItems(experience);
|
||||
const splitRowStyle = useSectionSplitRowStyle();
|
||||
const alignRightStyle = useTemplateStyle("alignRight");
|
||||
const inlineItemHeader = useTemplateFeature("inlineItemHeader");
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SectionShell sectionId={sectionId} title={experience.title}>
|
||||
<SectionItems columns={experience.columns}>
|
||||
{items.map((item) => {
|
||||
const hasPosition = Boolean(item.position.trim());
|
||||
const hasLocation = Boolean(item.location.trim());
|
||||
|
||||
const renderInlineHeader = () => (
|
||||
<InlineItemHeader
|
||||
leading={
|
||||
hasPosition || hasLocation ? (
|
||||
<Text>
|
||||
{hasPosition ? item.position : ""}
|
||||
{hasPosition && hasLocation ? " " : ""}
|
||||
{hasLocation ? `(${item.location})` : ""}
|
||||
</Text>
|
||||
) : null
|
||||
}
|
||||
middle={<Bold>{item.company}</Bold>}
|
||||
trailing={<Text style={composeStyles(alignRightStyle)}>{item.period}</Text>}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderSplitHeader = () => (
|
||||
<>
|
||||
<View style={composeStyles(splitRowStyle)}>
|
||||
<Bold>{item.company}</Bold>
|
||||
<Text style={composeStyles(alignRightStyle)}>{item.location}</Text>
|
||||
</View>
|
||||
|
||||
{item.roles.length === 0 && (
|
||||
<View style={composeStyles(splitRowStyle)}>
|
||||
<Text>{item.position}</Text>
|
||||
<Text style={composeStyles(alignRightStyle)}>{item.period}</Text>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<SectionItem key={item.id}>
|
||||
<SectionItemHeader>{inlineItemHeader ? renderInlineHeader() : renderSplitHeader()}</SectionItemHeader>
|
||||
|
||||
{item.roles.length > 0 && <MetaLine>{[item.period]}</MetaLine>}
|
||||
|
||||
{item.roles.map((role) => (
|
||||
<View key={role.id}>
|
||||
<View style={composeStyles(splitRowStyle)}>
|
||||
<Text>{role.position}</Text>
|
||||
<Text style={composeStyles(alignRightStyle)}>{role.period}</Text>
|
||||
</View>
|
||||
<RichText>{role.description}</RichText>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{item.roles.length === 0 && <RichText>{item.description}</RichText>}
|
||||
|
||||
{item.website.url && (
|
||||
<Link src={item.website.url}>
|
||||
<Small>{item.website.label || item.website.url}</Small>
|
||||
</Link>
|
||||
)}
|
||||
</SectionItem>
|
||||
);
|
||||
})}
|
||||
</SectionItems>
|
||||
</SectionShell>
|
||||
);
|
||||
};
|
||||
|
||||
const EducationSection = ({
|
||||
sectionId = "education",
|
||||
sectionData,
|
||||
}: {
|
||||
sectionId?: string;
|
||||
sectionData?: ItemSection<EducationItem>;
|
||||
} = {}) => {
|
||||
const data = useRender();
|
||||
const education = sectionData ?? data.sections.education;
|
||||
const items = getVisibleItems(education);
|
||||
const splitRowStyle = useSectionSplitRowStyle();
|
||||
const alignRightStyle = useTemplateStyle("alignRight");
|
||||
const inlineItemHeader = useTemplateFeature("inlineItemHeader");
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SectionShell sectionId={sectionId} title={education.title}>
|
||||
<SectionItems columns={education.columns}>
|
||||
{items.map((item) => {
|
||||
const degreeAndGrade = [item.degree, item.grade].filter(Boolean).join(" • ");
|
||||
const locationAndPeriod = [item.location, item.period].filter(Boolean).join(" • ");
|
||||
const gradeAndLocation = [item.grade, item.location].filter(Boolean).join(" • ");
|
||||
const hasArea = Boolean(item.area.trim());
|
||||
const hasDegree = Boolean(item.degree.trim());
|
||||
|
||||
const renderInlineHeader = () => (
|
||||
<>
|
||||
<InlineItemHeader
|
||||
leading={
|
||||
hasArea || hasDegree ? (
|
||||
<Text>
|
||||
{hasArea ? item.area : ""}
|
||||
{hasArea && hasDegree ? " " : ""}
|
||||
{hasDegree ? `(${item.degree})` : ""}
|
||||
</Text>
|
||||
) : null
|
||||
}
|
||||
middle={<Bold>{item.school}</Bold>}
|
||||
trailing={<Text style={composeStyles(alignRightStyle)}>{item.period}</Text>}
|
||||
/>
|
||||
{gradeAndLocation && <Text>{gradeAndLocation}</Text>}
|
||||
</>
|
||||
);
|
||||
|
||||
const renderSplitHeader = () => (
|
||||
<>
|
||||
<View style={composeStyles(splitRowStyle)}>
|
||||
<Bold>{item.school}</Bold>
|
||||
<Text style={composeStyles(alignRightStyle)}>{degreeAndGrade}</Text>
|
||||
</View>
|
||||
|
||||
<View style={composeStyles(splitRowStyle)}>
|
||||
<Text>{item.area}</Text>
|
||||
<Text style={composeStyles(alignRightStyle)}>{locationAndPeriod}</Text>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<SectionItem key={item.id}>
|
||||
<SectionItemHeader>{inlineItemHeader ? renderInlineHeader() : renderSplitHeader()}</SectionItemHeader>
|
||||
|
||||
<RichText>{item.description}</RichText>
|
||||
|
||||
{item.website.url && (
|
||||
<Link src={item.website.url}>
|
||||
<Small>{item.website.label || item.website.url}</Small>
|
||||
</Link>
|
||||
)}
|
||||
</SectionItem>
|
||||
);
|
||||
})}
|
||||
</SectionItems>
|
||||
</SectionShell>
|
||||
);
|
||||
};
|
||||
|
||||
const ProjectsSection = ({
|
||||
sectionId = "projects",
|
||||
sectionData,
|
||||
}: {
|
||||
sectionId?: string;
|
||||
sectionData?: ItemSection<ProjectItem>;
|
||||
} = {}) => {
|
||||
const data = useRender();
|
||||
const projects = sectionData ?? data.sections.projects;
|
||||
const items = getVisibleItems(projects);
|
||||
const splitRowStyle = useSectionSplitRowStyle();
|
||||
const alignRightStyle = useTemplateStyle("alignRight");
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SectionShell sectionId={sectionId} title={projects.title}>
|
||||
<SectionItems columns={projects.columns}>
|
||||
{items.map((item) => (
|
||||
<SectionItem key={item.id}>
|
||||
<SectionItemHeader>
|
||||
<View style={composeStyles(splitRowStyle)}>
|
||||
<Bold>{item.name}</Bold>
|
||||
<Text style={composeStyles(alignRightStyle)}>{item.period}</Text>
|
||||
</View>
|
||||
</SectionItemHeader>
|
||||
|
||||
<RichText>{item.description}</RichText>
|
||||
|
||||
{item.website.url && (
|
||||
<Link src={item.website.url}>
|
||||
<Small>{item.website.label || item.website.url}</Small>
|
||||
</Link>
|
||||
)}
|
||||
</SectionItem>
|
||||
))}
|
||||
</SectionItems>
|
||||
</SectionShell>
|
||||
);
|
||||
};
|
||||
|
||||
const SkillsSection = ({
|
||||
sectionId = "skills",
|
||||
sectionData,
|
||||
}: {
|
||||
sectionId?: string;
|
||||
sectionData?: ItemSection<SkillItem>;
|
||||
} = {}) => {
|
||||
const data = useRender();
|
||||
const skills = sectionData ?? data.sections.skills;
|
||||
const items = getVisibleItems(skills);
|
||||
const inlineStyle = useTemplateStyle("inline");
|
||||
const metrics = getTemplateMetrics(data.metadata.page);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SectionShell sectionId={sectionId} title={skills.title}>
|
||||
<SectionItems columns={skills.columns}>
|
||||
{items.map((item) => (
|
||||
<SectionItem key={item.id} style={{ rowGap: metrics.gapY(0.25) }}>
|
||||
<SectionItemHeader>
|
||||
<View style={composeStyles(inlineStyle)}>
|
||||
<Icon name={item.icon as IconName} />
|
||||
<Bold>{item.name}</Bold>
|
||||
</View>
|
||||
</SectionItemHeader>
|
||||
|
||||
<View>
|
||||
<Text>{item.proficiency}</Text>
|
||||
<Small>{item.keywords.join(", ")}</Small>
|
||||
</View>
|
||||
|
||||
<LevelDisplay level={item.level} />
|
||||
</SectionItem>
|
||||
))}
|
||||
</SectionItems>
|
||||
</SectionShell>
|
||||
);
|
||||
};
|
||||
|
||||
const LanguagesSection = ({
|
||||
sectionId = "languages",
|
||||
sectionData,
|
||||
}: {
|
||||
sectionId?: string;
|
||||
sectionData?: ItemSection<LanguageItem>;
|
||||
} = {}) => {
|
||||
const data = useRender();
|
||||
const languages = sectionData ?? data.sections.languages;
|
||||
const items = getVisibleItems(languages);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SectionShell sectionId={sectionId} title={languages.title}>
|
||||
<SectionItems columns={languages.columns}>
|
||||
{items.map((item) => (
|
||||
<SectionItem key={item.id}>
|
||||
<SectionItemHeader>
|
||||
<Bold>{item.language}</Bold>
|
||||
<Text>{item.fluency}</Text>
|
||||
</SectionItemHeader>
|
||||
<LevelDisplay level={item.level} />
|
||||
</SectionItem>
|
||||
))}
|
||||
</SectionItems>
|
||||
</SectionShell>
|
||||
);
|
||||
};
|
||||
|
||||
const InterestsSection = ({
|
||||
sectionId = "interests",
|
||||
sectionData,
|
||||
}: {
|
||||
sectionId?: string;
|
||||
sectionData?: ItemSection<InterestItem>;
|
||||
} = {}) => {
|
||||
const data = useRender();
|
||||
const interests = sectionData ?? data.sections.interests;
|
||||
const items = getVisibleItems(interests);
|
||||
const inlineStyle = useTemplateStyle("inline");
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SectionShell sectionId={sectionId} title={interests.title}>
|
||||
<SectionItems columns={interests.columns}>
|
||||
{items.map((item) => (
|
||||
<SectionItem key={item.id}>
|
||||
<SectionItemHeader>
|
||||
<View style={composeStyles(inlineStyle)}>
|
||||
<Icon name={item.icon as IconName} />
|
||||
<Bold>{item.name}</Bold>
|
||||
</View>
|
||||
</SectionItemHeader>
|
||||
|
||||
<Small>{item.keywords.join(", ")}</Small>
|
||||
</SectionItem>
|
||||
))}
|
||||
</SectionItems>
|
||||
</SectionShell>
|
||||
);
|
||||
};
|
||||
|
||||
const AwardsSection = ({
|
||||
sectionId = "awards",
|
||||
sectionData,
|
||||
}: {
|
||||
sectionId?: string;
|
||||
sectionData?: ItemSection<AwardItem>;
|
||||
} = {}) => {
|
||||
const data = useRender();
|
||||
const awards = sectionData ?? data.sections.awards;
|
||||
const items = getVisibleItems(awards);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SectionShell sectionId={sectionId} title={awards.title}>
|
||||
<SectionItems columns={awards.columns}>
|
||||
{items.map((item) => (
|
||||
<SectionItem key={item.id}>
|
||||
<SectionItemHeader>
|
||||
<Bold>{item.title}</Bold>
|
||||
<Text>{item.awarder}</Text>
|
||||
<Small>{item.date}</Small>
|
||||
</SectionItemHeader>
|
||||
<RichText>{item.description}</RichText>
|
||||
|
||||
{item.website.url && (
|
||||
<Link src={item.website.url}>
|
||||
<Small>{item.website.label || item.website.url}</Small>
|
||||
</Link>
|
||||
)}
|
||||
</SectionItem>
|
||||
))}
|
||||
</SectionItems>
|
||||
</SectionShell>
|
||||
);
|
||||
};
|
||||
|
||||
const CertificationsSection = ({
|
||||
sectionId = "certifications",
|
||||
sectionData,
|
||||
}: {
|
||||
sectionId?: string;
|
||||
sectionData?: ItemSection<CertificationItem>;
|
||||
} = {}) => {
|
||||
const data = useRender();
|
||||
const certifications = sectionData ?? data.sections.certifications;
|
||||
const items = getVisibleItems(certifications);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SectionShell sectionId={sectionId} title={certifications.title}>
|
||||
<SectionItems columns={certifications.columns}>
|
||||
{items.map((item) => (
|
||||
<SectionItem key={item.id}>
|
||||
<SectionItemHeader>
|
||||
<Bold>{item.title}</Bold>
|
||||
<Text>{item.issuer}</Text>
|
||||
<Small>{item.date}</Small>
|
||||
</SectionItemHeader>
|
||||
<RichText>{item.description}</RichText>
|
||||
|
||||
{item.website.url && (
|
||||
<Link src={item.website.url}>
|
||||
<Small>{item.website.label || item.website.url}</Small>
|
||||
</Link>
|
||||
)}
|
||||
</SectionItem>
|
||||
))}
|
||||
</SectionItems>
|
||||
</SectionShell>
|
||||
);
|
||||
};
|
||||
|
||||
const PublicationsSection = ({
|
||||
sectionId = "publications",
|
||||
sectionData,
|
||||
}: {
|
||||
sectionId?: string;
|
||||
sectionData?: ItemSection<PublicationItem>;
|
||||
} = {}) => {
|
||||
const data = useRender();
|
||||
const publications = sectionData ?? data.sections.publications;
|
||||
const items = getVisibleItems(publications);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SectionShell sectionId={sectionId} title={publications.title}>
|
||||
<SectionItems columns={publications.columns}>
|
||||
{items.map((item) => (
|
||||
<SectionItem key={item.id}>
|
||||
<SectionItemHeader>
|
||||
<Bold>{item.title}</Bold>
|
||||
<Text>{item.publisher}</Text>
|
||||
<Small>{item.date}</Small>
|
||||
</SectionItemHeader>
|
||||
<RichText>{item.description}</RichText>
|
||||
|
||||
{item.website.url && (
|
||||
<Link src={item.website.url}>
|
||||
<Small>{item.website.label || item.website.url}</Small>
|
||||
</Link>
|
||||
)}
|
||||
</SectionItem>
|
||||
))}
|
||||
</SectionItems>
|
||||
</SectionShell>
|
||||
);
|
||||
};
|
||||
|
||||
const VolunteerSection = ({
|
||||
sectionId = "volunteer",
|
||||
sectionData,
|
||||
}: {
|
||||
sectionId?: string;
|
||||
sectionData?: ItemSection<VolunteerItem>;
|
||||
} = {}) => {
|
||||
const data = useRender();
|
||||
const volunteer = sectionData ?? data.sections.volunteer;
|
||||
const items = getVisibleItems(volunteer);
|
||||
const splitRowStyle = useSectionSplitRowStyle();
|
||||
const alignRightStyle = useTemplateStyle("alignRight");
|
||||
const inlineItemHeader = useTemplateFeature("inlineItemHeader");
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SectionShell sectionId={sectionId} title={volunteer.title}>
|
||||
<SectionItems columns={volunteer.columns}>
|
||||
{items.map((item) => (
|
||||
<SectionItem key={item.id}>
|
||||
<SectionItemHeader>
|
||||
{inlineItemHeader ? (
|
||||
<InlineItemHeader
|
||||
leading={<Text>{item.location}</Text>}
|
||||
middle={<Bold>{item.organization}</Bold>}
|
||||
trailing={<Text style={composeStyles(alignRightStyle)}>{item.period}</Text>}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<View style={composeStyles(splitRowStyle)}>
|
||||
<Bold>{item.organization}</Bold>
|
||||
<Text style={composeStyles(alignRightStyle)}>{item.location}</Text>
|
||||
</View>
|
||||
|
||||
<MetaLine>{[item.period]}</MetaLine>
|
||||
</>
|
||||
)}
|
||||
</SectionItemHeader>
|
||||
<RichText>{item.description}</RichText>
|
||||
|
||||
{item.website.url && (
|
||||
<Link src={item.website.url}>
|
||||
<Small>{item.website.label || item.website.url}</Small>
|
||||
</Link>
|
||||
)}
|
||||
</SectionItem>
|
||||
))}
|
||||
</SectionItems>
|
||||
</SectionShell>
|
||||
);
|
||||
};
|
||||
|
||||
const ReferencesSection = ({
|
||||
sectionId = "references",
|
||||
sectionData,
|
||||
}: {
|
||||
sectionId?: string;
|
||||
sectionData?: ItemSection<ReferenceItem>;
|
||||
} = {}) => {
|
||||
const data = useRender();
|
||||
const references = sectionData ?? data.sections.references;
|
||||
const items = getVisibleItems(references);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SectionShell sectionId={sectionId} title={references.title}>
|
||||
<SectionItems columns={references.columns}>
|
||||
{items.map((item) => (
|
||||
<SectionItem key={item.id}>
|
||||
<SectionItemHeader>
|
||||
<Bold>{item.name}</Bold>
|
||||
<Text>{item.position}</Text>
|
||||
<MetaLine>{[item.phone]}</MetaLine>
|
||||
</SectionItemHeader>
|
||||
<RichText>{item.description}</RichText>
|
||||
|
||||
{item.website.url && (
|
||||
<Link src={item.website.url}>
|
||||
<Small>{item.website.label || item.website.url}</Small>
|
||||
</Link>
|
||||
)}
|
||||
</SectionItem>
|
||||
))}
|
||||
</SectionItems>
|
||||
</SectionShell>
|
||||
);
|
||||
};
|
||||
|
||||
const CoverLetterSection = ({ section }: { section: CustomItemSection<CoverLetterItem> }) => {
|
||||
const items = getVisibleItems(section);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SectionShell sectionId={section.id} title={section.title} showHeading={false}>
|
||||
<SectionItems>
|
||||
{items.map((item) => (
|
||||
<SectionItem key={item.id}>
|
||||
<RichText>{item.recipient}</RichText>
|
||||
<RichText>{item.content}</RichText>
|
||||
</SectionItem>
|
||||
))}
|
||||
</SectionItems>
|
||||
</SectionShell>
|
||||
);
|
||||
};
|
||||
|
||||
const CustomSection = ({ sectionId, showHeading = true }: { sectionId: string; showHeading?: boolean }) => {
|
||||
const data = useRender();
|
||||
const customSection = data.customSections.find((section) => section.id === sectionId);
|
||||
|
||||
if (!customSection) return null;
|
||||
|
||||
return match(customSection.type)
|
||||
.with("summary", () => (
|
||||
<CustomSummarySection section={customSection as CustomItemSection<SummaryItem>} showHeading={showHeading} />
|
||||
))
|
||||
.with("profiles", () => (
|
||||
<ProfileSection sectionId={sectionId} sectionData={customSection as CustomItemSection<ProfileItem>} />
|
||||
))
|
||||
.with("experience", () => (
|
||||
<ExperienceSection sectionId={sectionId} sectionData={customSection as CustomItemSection<ExperienceItem>} />
|
||||
))
|
||||
.with("education", () => (
|
||||
<EducationSection sectionId={sectionId} sectionData={customSection as CustomItemSection<EducationItem>} />
|
||||
))
|
||||
.with("projects", () => (
|
||||
<ProjectsSection sectionId={sectionId} sectionData={customSection as CustomItemSection<ProjectItem>} />
|
||||
))
|
||||
.with("skills", () => (
|
||||
<SkillsSection sectionId={sectionId} sectionData={customSection as CustomItemSection<SkillItem>} />
|
||||
))
|
||||
.with("languages", () => (
|
||||
<LanguagesSection sectionId={sectionId} sectionData={customSection as CustomItemSection<LanguageItem>} />
|
||||
))
|
||||
.with("interests", () => (
|
||||
<InterestsSection sectionId={sectionId} sectionData={customSection as CustomItemSection<InterestItem>} />
|
||||
))
|
||||
.with("awards", () => (
|
||||
<AwardsSection sectionId={sectionId} sectionData={customSection as CustomItemSection<AwardItem>} />
|
||||
))
|
||||
.with("certifications", () => (
|
||||
<CertificationsSection
|
||||
sectionId={sectionId}
|
||||
sectionData={customSection as CustomItemSection<CertificationItem>}
|
||||
/>
|
||||
))
|
||||
.with("publications", () => (
|
||||
<PublicationsSection sectionId={sectionId} sectionData={customSection as CustomItemSection<PublicationItem>} />
|
||||
))
|
||||
.with("volunteer", () => (
|
||||
<VolunteerSection sectionId={sectionId} sectionData={customSection as CustomItemSection<VolunteerItem>} />
|
||||
))
|
||||
.with("references", () => (
|
||||
<ReferencesSection sectionId={sectionId} sectionData={customSection as CustomItemSection<ReferenceItem>} />
|
||||
))
|
||||
.with("cover-letter", () => <CoverLetterSection section={customSection as CustomItemSection<CoverLetterItem>} />)
|
||||
.exhaustive();
|
||||
};
|
||||
|
||||
export const Section = ({
|
||||
section,
|
||||
placement,
|
||||
showHeading = true,
|
||||
}: {
|
||||
section: string;
|
||||
placement: TemplatePlacement;
|
||||
showHeading?: boolean;
|
||||
}) => {
|
||||
const data = useRender();
|
||||
|
||||
if (!isSectionVisible(section, data)) return null;
|
||||
|
||||
return (
|
||||
<TemplatePlacementProvider placement={placement}>
|
||||
{match(section)
|
||||
.with("summary", () => <SummarySection showHeading={showHeading} />)
|
||||
.with("profiles", () => <ProfileSection />)
|
||||
.with("experience", () => <ExperienceSection />)
|
||||
.with("education", () => <EducationSection />)
|
||||
.with("projects", () => <ProjectsSection />)
|
||||
.with("skills", () => <SkillsSection />)
|
||||
.with("languages", () => <LanguagesSection />)
|
||||
.with("interests", () => <InterestsSection />)
|
||||
.with("awards", () => <AwardsSection />)
|
||||
.with("certifications", () => <CertificationsSection />)
|
||||
.with("publications", () => <PublicationsSection />)
|
||||
.with("volunteer", () => <VolunteerSection />)
|
||||
.with("references", () => <ReferencesSection />)
|
||||
.otherwise(() => (
|
||||
<CustomSection sectionId={section} showHeading={showHeading} />
|
||||
))}
|
||||
</TemplatePlacementProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
|
||||
export type TemplatePlacement = "main" | "sidebar";
|
||||
|
||||
export type StyleInput = Style | Style[] | null | undefined;
|
||||
|
||||
export const composeStyles = (...styles: StyleInput[]): Style[] => {
|
||||
return styles.flatMap((style) => {
|
||||
if (!style) return [];
|
||||
if (Array.isArray(style)) return style.filter(Boolean);
|
||||
|
||||
return [style];
|
||||
});
|
||||
};
|
||||
|
||||
export const mergeStyles = (...styles: StyleInput[]): Style => Object.assign({}, ...composeStyles(...styles));
|
||||
|
||||
export type ResolvePlacementColorOptions = {
|
||||
placement: TemplatePlacement;
|
||||
defaultForeground: string;
|
||||
sidebarForeground?: string | undefined;
|
||||
};
|
||||
|
||||
export const resolvePlacementColor = ({
|
||||
placement,
|
||||
defaultForeground,
|
||||
sidebarForeground,
|
||||
}: ResolvePlacementColorOptions) => {
|
||||
if (placement === "sidebar" && sidebarForeground) return sidebarForeground;
|
||||
|
||||
return defaultForeground;
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { CustomSection as SchemaCustomSection } from "@reactive-resume/schema/resume/data";
|
||||
import type { Icon } from "phosphor-icons-react-pdf/dynamic";
|
||||
import type { ComponentProps } from "react";
|
||||
import type { StyleInput, TemplatePlacement } from "./styles";
|
||||
|
||||
export type TemplateColorRoles = {
|
||||
foreground: string;
|
||||
background: string;
|
||||
primary: string;
|
||||
sidebarForeground?: string | undefined;
|
||||
sidebarBackground?: string | undefined;
|
||||
};
|
||||
|
||||
export type TemplateStyleContext = {
|
||||
placement: TemplatePlacement;
|
||||
colors: TemplateColorRoles;
|
||||
};
|
||||
|
||||
export type TemplateStyleSlot = StyleInput | ((context: TemplateStyleContext) => StyleInput);
|
||||
|
||||
export type TemplateIconProps = Omit<ComponentProps<typeof Icon>, "name">;
|
||||
|
||||
export type TemplateIconSlot =
|
||||
| Partial<TemplateIconProps>
|
||||
| ((context: TemplateStyleContext) => Partial<TemplateIconProps>);
|
||||
|
||||
export type TemplateFeatures = {
|
||||
sectionTimeline?: boolean;
|
||||
inlineItemHeader?: boolean;
|
||||
stackSidebarItemHeader?: boolean;
|
||||
mainItemHeaderBorder?: boolean;
|
||||
};
|
||||
|
||||
export type SectionTimelineStyleSlots = {
|
||||
items?: TemplateStyleSlot;
|
||||
line?: TemplateStyleSlot;
|
||||
item?: TemplateStyleSlot;
|
||||
marker?: TemplateStyleSlot;
|
||||
dot?: TemplateStyleSlot;
|
||||
content?: TemplateStyleSlot;
|
||||
};
|
||||
|
||||
export type TemplateFeatureStyleSlots = {
|
||||
sectionTimeline?: SectionTimelineStyleSlots;
|
||||
};
|
||||
|
||||
export type TemplateStyleSlots = {
|
||||
page?: TemplateStyleSlot;
|
||||
text?: TemplateStyleSlot;
|
||||
heading?: TemplateStyleSlot;
|
||||
div?: TemplateStyleSlot;
|
||||
inline?: TemplateStyleSlot;
|
||||
link?: TemplateStyleSlot;
|
||||
small?: TemplateStyleSlot;
|
||||
bold?: TemplateStyleSlot;
|
||||
richParagraph?: TemplateStyleSlot;
|
||||
richListItemRow?: TemplateStyleSlot;
|
||||
richListItemMarker?: TemplateStyleSlot;
|
||||
richListItemContent?: TemplateStyleSlot;
|
||||
splitRow?: TemplateStyleSlot;
|
||||
alignRight?: TemplateStyleSlot;
|
||||
inlineItemHeader?: TemplateStyleSlot;
|
||||
inlineItemHeaderLeading?: TemplateStyleSlot;
|
||||
inlineItemHeaderMiddle?: TemplateStyleSlot;
|
||||
inlineItemHeaderTrailing?: TemplateStyleSlot;
|
||||
sectionItemHeader?: TemplateStyleSlot;
|
||||
section?: TemplateStyleSlot;
|
||||
sectionHeading?: TemplateStyleSlot;
|
||||
sectionItems?: TemplateStyleSlot;
|
||||
item?: TemplateStyleSlot;
|
||||
levelContainer?: TemplateStyleSlot;
|
||||
levelItem?: TemplateStyleSlot;
|
||||
levelItemActive?: TemplateStyleSlot;
|
||||
levelItemInactive?: TemplateStyleSlot;
|
||||
icon?: TemplateIconSlot;
|
||||
};
|
||||
|
||||
export type ItemSection<T> = {
|
||||
title: string;
|
||||
columns: number;
|
||||
hidden: boolean;
|
||||
items: T[];
|
||||
};
|
||||
|
||||
export type CustomItemSection<T> = Omit<SchemaCustomSection, "items"> & ItemSection<T>;
|
||||
Reference in New Issue
Block a user