mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2025-11-13 08:13:49 +10:00
🚀 release v3.0.0
This commit is contained in:
27
client/templates/Castform/Castform.module.scss
Normal file
27
client/templates/Castform/Castform.module.scss
Normal file
@ -0,0 +1,27 @@
|
||||
.page {
|
||||
@apply h-full;
|
||||
|
||||
a {
|
||||
@apply font-semibold;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
@apply grid h-full grid-cols-6;
|
||||
|
||||
.main {
|
||||
@apply col-span-4 flex flex-col;
|
||||
|
||||
.inner {
|
||||
@apply flex h-full flex-col gap-4 p-4;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
@apply col-span-2 flex h-full flex-col;
|
||||
|
||||
.inner {
|
||||
@apply flex h-full flex-col gap-4 p-4;
|
||||
}
|
||||
}
|
||||
}
|
||||
46
client/templates/Castform/Castform.tsx
Normal file
46
client/templates/Castform/Castform.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { Theme } from '@reactive-resume/schema';
|
||||
import clsx from 'clsx';
|
||||
import get from 'lodash/get';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useAppSelector } from '@/store/hooks';
|
||||
import { getContrastColor } from '@/utils/styles';
|
||||
import { PageProps } from '@/utils/template';
|
||||
|
||||
import { getSectionById } from '../sectionMap';
|
||||
import styles from './Castform.module.scss';
|
||||
import { MastheadMain, MastheadSidebar } from './widgets/Masthead';
|
||||
import Section from './widgets/Section';
|
||||
|
||||
const Castform: React.FC<PageProps> = ({ page }) => {
|
||||
const isFirstPage = useMemo(() => page === 0, [page]);
|
||||
|
||||
const layout: string[][] = useAppSelector((state) => state.resume.metadata.layout[page]);
|
||||
const theme: Theme = useAppSelector((state) => get(state.resume, 'metadata.theme', {}));
|
||||
|
||||
const contrast = useMemo(() => getContrastColor(theme.primary), [theme.primary]);
|
||||
const color = useMemo(() => (contrast === 'dark' ? theme.text : theme.background), [theme, contrast]);
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.container}>
|
||||
<div
|
||||
className={clsx(styles.sidebar, css(`svg { color: ${color} } --primary-color: ${color}`))}
|
||||
style={{ color, backgroundColor: theme.primary }}
|
||||
>
|
||||
{isFirstPage && <MastheadSidebar />}
|
||||
|
||||
<div className={styles.inner}>{layout[1].map((key) => getSectionById(key, Section))}</div>
|
||||
</div>
|
||||
<div className={styles.main}>
|
||||
<div className={styles.firstPage}>{isFirstPage && <MastheadMain />}</div>
|
||||
|
||||
<div className={styles.inner}>{layout[0].map((key) => getSectionById(key, Section))}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Castform;
|
||||
22
client/templates/Castform/widgets/Heading.tsx
Normal file
22
client/templates/Castform/widgets/Heading.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { darken } from '@mui/material';
|
||||
import { Theme } from '@reactive-resume/schema';
|
||||
import get from 'lodash/get';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useAppSelector } from '@/store/hooks';
|
||||
|
||||
const Heading: React.FC = ({ children }) => {
|
||||
const theme: Theme = useAppSelector((state) => get(state.resume, 'metadata.theme', {}));
|
||||
const darkerPrimary = useMemo(() => darken(theme.primary, 0.2), [theme.primary]);
|
||||
|
||||
return (
|
||||
<h3
|
||||
className="relative -left-4 mb-2 w-[95%] rounded-r py-1.5 pl-4 font-bold uppercase"
|
||||
style={{ color: theme.background, backgroundColor: darkerPrimary }}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
};
|
||||
|
||||
export default Heading;
|
||||
76
client/templates/Castform/widgets/Masthead.tsx
Normal file
76
client/templates/Castform/widgets/Masthead.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { Email, Phone, Public, Room } from '@mui/icons-material';
|
||||
import { Theme } from '@reactive-resume/schema';
|
||||
import clsx from 'clsx';
|
||||
import get from 'lodash/get';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import Markdown from '@/components/shared/Markdown';
|
||||
import { useAppSelector } from '@/store/hooks';
|
||||
import DataDisplay from '@/templates/shared/DataDisplay';
|
||||
import getProfileIcon from '@/utils/getProfileIcon';
|
||||
import { getContrastColor } from '@/utils/styles';
|
||||
import { addHttp, formatLocation, getPhotoClassNames } from '@/utils/template';
|
||||
|
||||
export const MastheadSidebar: React.FC = () => {
|
||||
const { name, headline, photo, email, phone, website, location, profiles } = useAppSelector(
|
||||
(state) => state.resume.basics
|
||||
);
|
||||
const theme: Theme = useAppSelector((state) => get(state.resume, 'metadata.theme', {}));
|
||||
const contrast = useMemo(() => getContrastColor(theme.primary), [theme.primary]);
|
||||
const color = useMemo(() => (contrast === 'dark' ? theme.text : theme.background), [theme, contrast]);
|
||||
|
||||
return (
|
||||
<div className="col-span-2 grid justify-items-start gap-3 px-4 pt-4">
|
||||
{photo.visible && !isEmpty(photo.url) && (
|
||||
<img
|
||||
alt={name}
|
||||
src={photo.url}
|
||||
width={photo.filters.size}
|
||||
height={photo.filters.size}
|
||||
className={getPhotoClassNames(photo.filters)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h1 className="mb-1">{name}</h1>
|
||||
<p className="opacity-75">{headline}</p>
|
||||
</div>
|
||||
|
||||
<div className={clsx('flex flex-col gap-2.5', css(`svg { color: ${color} }`))}>
|
||||
<DataDisplay icon={<Room />} className="!gap-2 text-xs">
|
||||
{formatLocation(location)}
|
||||
</DataDisplay>
|
||||
|
||||
<DataDisplay icon={<Email />} className="!gap-2 text-xs" link={`mailto:${email}`}>
|
||||
{email}
|
||||
</DataDisplay>
|
||||
|
||||
<DataDisplay icon={<Phone />} className="!gap-2 text-xs" link={`tel:${phone}`}>
|
||||
{phone}
|
||||
</DataDisplay>
|
||||
|
||||
<DataDisplay icon={<Public />} link={website && addHttp(website)} className="!gap-2 text-xs">
|
||||
{website}
|
||||
</DataDisplay>
|
||||
|
||||
{profiles.map(({ id, username, network, url }) => (
|
||||
<DataDisplay key={id} icon={getProfileIcon(network)} link={url} className="!gap-2 text-xs">
|
||||
{username}
|
||||
</DataDisplay>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const MastheadMain: React.FC = () => {
|
||||
const { summary } = useAppSelector((state) => state.resume.basics);
|
||||
|
||||
return (
|
||||
<div className="px-4 pt-4">
|
||||
<Markdown>{summary}</Markdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
125
client/templates/Castform/widgets/Section.tsx
Normal file
125
client/templates/Castform/widgets/Section.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
import { Email, Link, Phone } from '@mui/icons-material';
|
||||
import { ListItem, Section } from '@reactive-resume/schema';
|
||||
import get from 'lodash/get';
|
||||
import isArray from 'lodash/isArray';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
|
||||
import Markdown from '@/components/shared/Markdown';
|
||||
import { useAppSelector } from '@/store/hooks';
|
||||
import { SectionProps } from '@/templates/sectionMap';
|
||||
import DataDisplay from '@/templates/shared/DataDisplay';
|
||||
import { formatDateString } from '@/utils/date';
|
||||
import { parseListItemPath } from '@/utils/template';
|
||||
|
||||
import Heading from './Heading';
|
||||
|
||||
const Section: React.FC<SectionProps> = ({
|
||||
path,
|
||||
titlePath = 'title',
|
||||
subtitlePath = 'subtitle',
|
||||
headlinePath = 'headline',
|
||||
keywordsPath = 'keywords',
|
||||
}) => {
|
||||
const section: Section = useAppSelector((state) => get(state.resume, path, {}));
|
||||
const dateFormat: string = useAppSelector((state) => get(state.resume, 'metadata.date.format'));
|
||||
|
||||
if (!section.visible) return null;
|
||||
|
||||
if (isArray(section.items) && isEmpty(section.items)) return null;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<Heading>{section.name}</Heading>
|
||||
|
||||
<div
|
||||
className="grid items-start gap-4"
|
||||
style={{ gridTemplateColumns: `repeat(${section.columns}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{section.items.map((item: ListItem) => {
|
||||
const id = item.id,
|
||||
title = parseListItemPath(item, titlePath),
|
||||
subtitle = parseListItemPath(item, subtitlePath),
|
||||
headline = parseListItemPath(item, headlinePath),
|
||||
keywords: string[] = get(item, keywordsPath),
|
||||
url: string = get(item, 'url'),
|
||||
summary: string = get(item, 'summary'),
|
||||
level: string = get(item, 'level'),
|
||||
levelNum: number = get(item, 'levelNum'),
|
||||
phone: string = get(item, 'phone'),
|
||||
email: string = get(item, 'email'),
|
||||
date = formatDateString(get(item, 'date'), dateFormat);
|
||||
|
||||
return (
|
||||
<div key={id} id={id} className="grid gap-1">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex flex-col">
|
||||
{title && <span className="font-semibold">{title}</span>}
|
||||
{subtitle && <span className="opacity-75">{subtitle}</span>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 text-right text-xs">
|
||||
{date && <div className="opacity-50">({date})</div>}
|
||||
{headline && <span className="opacity-75">{headline}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(level || levelNum > 0) && (
|
||||
<div className="grid gap-1">
|
||||
{level && <span className="opacity-75">{level}</span>}
|
||||
{levelNum > 0 && (
|
||||
<div className="level flex">
|
||||
{Array.from(Array(5).keys()).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="mr-2 h-3 w-3 rounded-full border"
|
||||
style={{
|
||||
borderColor: 'var(--primary-color)',
|
||||
backgroundColor: levelNum / (10 / 5) > index ? 'var(--primary-color)' : '',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{summary && <Markdown>{summary}</Markdown>}
|
||||
|
||||
{url && (
|
||||
<DataDisplay icon={<Link />} link={url}>
|
||||
{url}
|
||||
</DataDisplay>
|
||||
)}
|
||||
|
||||
{keywords && (
|
||||
<div className="leading-normal">
|
||||
<span className="font-semibold">Keywords:</span>
|
||||
|
||||
{keywords.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(phone || email) && (
|
||||
<div className="grid gap-1">
|
||||
{phone && (
|
||||
<DataDisplay icon={<Phone />} link={`tel:${phone}`}>
|
||||
{phone}
|
||||
</DataDisplay>
|
||||
)}
|
||||
|
||||
{email && (
|
||||
<DataDisplay icon={<Email />} link={`mailto:${email}`}>
|
||||
{email}
|
||||
</DataDisplay>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Section;
|
||||
27
client/templates/Gengar/Gengar.module.scss
Normal file
27
client/templates/Gengar/Gengar.module.scss
Normal file
@ -0,0 +1,27 @@
|
||||
.page {
|
||||
@apply h-full;
|
||||
|
||||
a {
|
||||
@apply font-semibold;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
@apply grid h-full grid-cols-6;
|
||||
|
||||
.main {
|
||||
@apply col-span-4 flex flex-col;
|
||||
|
||||
.inner {
|
||||
@apply flex h-full flex-col gap-4 p-4;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
@apply col-span-2 flex h-full flex-col;
|
||||
|
||||
.inner {
|
||||
@apply flex h-full flex-col gap-4 p-4;
|
||||
}
|
||||
}
|
||||
}
|
||||
45
client/templates/Gengar/Gengar.tsx
Normal file
45
client/templates/Gengar/Gengar.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { alpha } from '@mui/material';
|
||||
import { Theme } from '@reactive-resume/schema';
|
||||
import get from 'lodash/get';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useAppSelector } from '@/store/hooks';
|
||||
import { getContrastColor } from '@/utils/styles';
|
||||
import { PageProps } from '@/utils/template';
|
||||
|
||||
import { getSectionById } from '../sectionMap';
|
||||
import styles from './Gengar.module.scss';
|
||||
import { MastheadMain, MastheadSidebar } from './widgets/Masthead';
|
||||
import Section from './widgets/Section';
|
||||
|
||||
const Gengar: React.FC<PageProps> = ({ page }) => {
|
||||
const isFirstPage = useMemo(() => page === 0, [page]);
|
||||
|
||||
const layout: string[][] = useAppSelector((state) => state.resume.metadata.layout[page]);
|
||||
const theme: Theme = useAppSelector((state) => get(state.resume, 'metadata.theme', {}));
|
||||
const contrast = useMemo(() => getContrastColor(theme.primary), [theme.primary]);
|
||||
const backgroundColor: string = useMemo(() => alpha(theme.primary, 0.15), [theme.primary]);
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.sidebar}>
|
||||
<div style={{ color: contrast === 'dark' ? theme.text : theme.background, backgroundColor: theme.primary }}>
|
||||
{isFirstPage && <MastheadSidebar />}
|
||||
</div>
|
||||
|
||||
<div className={styles.inner} style={{ backgroundColor }}>
|
||||
{layout[1].map((key) => getSectionById(key, Section))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.main}>
|
||||
<div className={styles.firstPage}>{isFirstPage && <MastheadMain />}</div>
|
||||
|
||||
<div className={styles.inner}>{layout[0].map((key) => getSectionById(key, Section))}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Gengar;
|
||||
19
client/templates/Gengar/widgets/Heading.tsx
Normal file
19
client/templates/Gengar/widgets/Heading.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { Theme } from '@reactive-resume/schema';
|
||||
import get from 'lodash/get';
|
||||
|
||||
import { useAppSelector } from '@/store/hooks';
|
||||
|
||||
const Heading: React.FC = ({ children }) => {
|
||||
const theme: Theme = useAppSelector((state) => get(state.resume, 'metadata.theme', {}));
|
||||
|
||||
return (
|
||||
<h3
|
||||
className="mb-2 w-2/3 border-b-2 pb-1.5 font-bold uppercase"
|
||||
style={{ color: theme.primary, borderColor: theme.primary }}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
};
|
||||
|
||||
export default Heading;
|
||||
80
client/templates/Gengar/widgets/Masthead.tsx
Normal file
80
client/templates/Gengar/widgets/Masthead.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { Email, Phone, Public, Room } from '@mui/icons-material';
|
||||
import { alpha } from '@mui/material';
|
||||
import { Theme } from '@reactive-resume/schema';
|
||||
import clsx from 'clsx';
|
||||
import get from 'lodash/get';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import Markdown from '@/components/shared/Markdown';
|
||||
import { useAppSelector } from '@/store/hooks';
|
||||
import DataDisplay from '@/templates/shared/DataDisplay';
|
||||
import getProfileIcon from '@/utils/getProfileIcon';
|
||||
import { getContrastColor } from '@/utils/styles';
|
||||
import { addHttp, formatLocation, getPhotoClassNames } from '@/utils/template';
|
||||
|
||||
export const MastheadSidebar: React.FC = () => {
|
||||
const { name, headline, photo, email, phone, website, location, profiles } = useAppSelector(
|
||||
(state) => state.resume.basics
|
||||
);
|
||||
const theme: Theme = useAppSelector((state) => get(state.resume, 'metadata.theme', {}));
|
||||
const contrast = useMemo(() => getContrastColor(theme.primary), [theme.primary]);
|
||||
const iconColor = useMemo(() => (contrast === 'dark' ? theme.text : theme.background), [theme, contrast]);
|
||||
|
||||
return (
|
||||
<div className="col-span-2 grid justify-items-start gap-3 p-4">
|
||||
{photo.visible && !isEmpty(photo.url) && (
|
||||
<img
|
||||
alt={name}
|
||||
src={photo.url}
|
||||
width={photo.filters.size}
|
||||
height={photo.filters.size}
|
||||
className={getPhotoClassNames(photo.filters)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h1 className="mb-1">{name}</h1>
|
||||
<p className="opacity-75">{headline}</p>
|
||||
</div>
|
||||
|
||||
<div className={clsx('flex flex-col gap-2.5', css(`svg { color: ${iconColor} }`))}>
|
||||
<DataDisplay icon={<Room />} className="!gap-2 text-xs">
|
||||
{formatLocation(location)}
|
||||
</DataDisplay>
|
||||
|
||||
<DataDisplay icon={<Email />} className="!gap-2 text-xs" link={`mailto:${email}`}>
|
||||
{email}
|
||||
</DataDisplay>
|
||||
|
||||
<DataDisplay icon={<Phone />} className="!gap-2 text-xs" link={`tel:${phone}`}>
|
||||
{phone}
|
||||
</DataDisplay>
|
||||
|
||||
<DataDisplay icon={<Public />} link={website && addHttp(website)} className="!gap-2 text-xs">
|
||||
{website}
|
||||
</DataDisplay>
|
||||
|
||||
{profiles.map(({ id, username, network, url }) => (
|
||||
<DataDisplay key={id} icon={getProfileIcon(network)} link={url} className="!gap-2 text-xs">
|
||||
{username}
|
||||
</DataDisplay>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const MastheadMain: React.FC = () => {
|
||||
const primaryColor: string = useAppSelector((state) => get(state.resume, 'metadata.theme.primary'));
|
||||
const backgroundColor: string = useMemo(() => alpha(primaryColor, 0.15), [primaryColor]);
|
||||
|
||||
const { summary } = useAppSelector((state) => state.resume.basics);
|
||||
|
||||
return (
|
||||
<div className="grid gap-2 p-4" style={{ backgroundColor }}>
|
||||
<Markdown>{summary}</Markdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
126
client/templates/Gengar/widgets/Section.tsx
Normal file
126
client/templates/Gengar/widgets/Section.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
import { Email, Link, Phone } from '@mui/icons-material';
|
||||
import { ListItem, Section } from '@reactive-resume/schema';
|
||||
import get from 'lodash/get';
|
||||
import isArray from 'lodash/isArray';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
|
||||
import Markdown from '@/components/shared/Markdown';
|
||||
import { useAppSelector } from '@/store/hooks';
|
||||
import { SectionProps } from '@/templates/sectionMap';
|
||||
import DataDisplay from '@/templates/shared/DataDisplay';
|
||||
import { formatDateString } from '@/utils/date';
|
||||
import { parseListItemPath } from '@/utils/template';
|
||||
|
||||
import Heading from './Heading';
|
||||
|
||||
const Section: React.FC<SectionProps> = ({
|
||||
path,
|
||||
titlePath = 'title',
|
||||
subtitlePath = 'subtitle',
|
||||
headlinePath = 'headline',
|
||||
keywordsPath = 'keywords',
|
||||
}) => {
|
||||
const section: Section = useAppSelector((state) => get(state.resume, path, {}));
|
||||
const dateFormat: string = useAppSelector((state) => get(state.resume, 'metadata.date.format'));
|
||||
const primaryColor: string = useAppSelector((state) => get(state.resume, 'metadata.theme.primary'));
|
||||
|
||||
if (!section.visible) return null;
|
||||
|
||||
if (isArray(section.items) && isEmpty(section.items)) return null;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<Heading>{section.name}</Heading>
|
||||
|
||||
<div
|
||||
className="grid items-start gap-4"
|
||||
style={{ gridTemplateColumns: `repeat(${section.columns}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{section.items.map((item: ListItem) => {
|
||||
const id = item.id,
|
||||
title = parseListItemPath(item, titlePath),
|
||||
subtitle = parseListItemPath(item, subtitlePath),
|
||||
headline = parseListItemPath(item, headlinePath),
|
||||
keywords: string[] = get(item, keywordsPath),
|
||||
url: string = get(item, 'url'),
|
||||
summary: string = get(item, 'summary'),
|
||||
level: string = get(item, 'level'),
|
||||
levelNum: number = get(item, 'levelNum'),
|
||||
phone: string = get(item, 'phone'),
|
||||
email: string = get(item, 'email'),
|
||||
date = formatDateString(get(item, 'date'), dateFormat);
|
||||
|
||||
return (
|
||||
<div key={id} id={id} className="grid gap-1">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex flex-col">
|
||||
{title && <span className="font-semibold">{title}</span>}
|
||||
{subtitle && <span className="opacity-75">{subtitle}</span>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 text-right text-xs">
|
||||
{date && <div className="opacity-50">({date})</div>}
|
||||
{headline && <span className="opacity-75">{headline}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(level || levelNum > 0) && (
|
||||
<div className="grid gap-1">
|
||||
{level && <span className="opacity-75">{level}</span>}
|
||||
{levelNum > 0 && (
|
||||
<div className="flex">
|
||||
{Array.from(Array(8).keys()).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="mr-1 h-2 w-4 rounded-sm border"
|
||||
style={{
|
||||
borderColor: primaryColor,
|
||||
backgroundColor: levelNum / (10 / 8) > index ? primaryColor : '',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{summary && <Markdown>{summary}</Markdown>}
|
||||
|
||||
{url && (
|
||||
<DataDisplay icon={<Link />} link={url}>
|
||||
{url}
|
||||
</DataDisplay>
|
||||
)}
|
||||
|
||||
{keywords && (
|
||||
<div className="leading-normal">
|
||||
<span className="font-semibold">Keywords:</span>
|
||||
|
||||
{keywords.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(phone || email) && (
|
||||
<div className="grid gap-1">
|
||||
{phone && (
|
||||
<DataDisplay icon={<Phone />} link={`tel:${phone}`}>
|
||||
{phone}
|
||||
</DataDisplay>
|
||||
)}
|
||||
|
||||
{email && (
|
||||
<DataDisplay icon={<Email />} link={`mailto:${email}`}>
|
||||
{email}
|
||||
</DataDisplay>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Section;
|
||||
19
client/templates/Glalie/Glalie.module.scss
Normal file
19
client/templates/Glalie/Glalie.module.scss
Normal file
@ -0,0 +1,19 @@
|
||||
.page {
|
||||
@apply h-full;
|
||||
|
||||
a {
|
||||
@apply font-semibold;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
@apply grid h-full grid-cols-6;
|
||||
|
||||
.main {
|
||||
@apply col-span-4 flex flex-col gap-4 p-4;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
@apply col-span-2 flex h-full flex-col gap-4 p-4;
|
||||
}
|
||||
}
|
||||
37
client/templates/Glalie/Glalie.tsx
Normal file
37
client/templates/Glalie/Glalie.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { alpha } from '@mui/material';
|
||||
import get from 'lodash/get';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useAppSelector } from '@/store/hooks';
|
||||
import { PageProps } from '@/utils/template';
|
||||
|
||||
import { getSectionById } from '../sectionMap';
|
||||
import styles from './Glalie.module.scss';
|
||||
import { MastheadMain, MastheadSidebar } from './widgets/Masthead';
|
||||
import Section from './widgets/Section';
|
||||
|
||||
const Glalie: React.FC<PageProps> = ({ page }) => {
|
||||
const isFirstPage = useMemo(() => page === 0, [page]);
|
||||
|
||||
const layout: string[][] = useAppSelector((state) => state.resume.metadata.layout[page]);
|
||||
const primaryColor: string = useAppSelector((state) => get(state.resume, 'metadata.theme.primary'));
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.sidebar} style={{ backgroundColor: alpha(primaryColor, 0.15) }}>
|
||||
{isFirstPage && <MastheadSidebar />}
|
||||
|
||||
{layout[1].map((key) => getSectionById(key, Section))}
|
||||
</div>
|
||||
<div className={styles.main}>
|
||||
{isFirstPage && <MastheadMain />}
|
||||
|
||||
{layout[0].map((key) => getSectionById(key, Section))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Glalie;
|
||||
39
client/templates/Glalie/widgets/BadgeDisplay.tsx
Normal file
39
client/templates/Glalie/widgets/BadgeDisplay.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import { alpha } from '@mui/material';
|
||||
import { Theme } from '@reactive-resume/schema';
|
||||
import get from 'lodash/get';
|
||||
import isArray from 'lodash/isArray';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useAppSelector } from '@/store/hooks';
|
||||
import { getContrastColor } from '@/utils/styles';
|
||||
|
||||
type Props = {
|
||||
items: string[];
|
||||
};
|
||||
|
||||
const BadgeDisplay: React.FC<Props> = ({ items }) => {
|
||||
const theme: Theme = useAppSelector((state) => get(state.resume, 'metadata.theme', {}));
|
||||
const contrast = useMemo(() => getContrastColor(theme.primary), [theme.primary]);
|
||||
|
||||
if (!isArray(items) || isEmpty(items)) return null;
|
||||
|
||||
return (
|
||||
<ul className="mt-1 flex flex-wrap gap-2 text-xs">
|
||||
{items.map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
className="rounded-sm px-2 py-0.5"
|
||||
style={{
|
||||
color: contrast === 'dark' ? theme.text : theme.background,
|
||||
backgroundColor: alpha(theme.primary, 0.75),
|
||||
}}
|
||||
>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
export default BadgeDisplay;
|
||||
19
client/templates/Glalie/widgets/Heading.tsx
Normal file
19
client/templates/Glalie/widgets/Heading.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { Theme } from '@reactive-resume/schema';
|
||||
import get from 'lodash/get';
|
||||
|
||||
import { useAppSelector } from '@/store/hooks';
|
||||
|
||||
const Heading: React.FC = ({ children }) => {
|
||||
const theme: Theme = useAppSelector((state) => get(state.resume, 'metadata.theme', {}));
|
||||
|
||||
return (
|
||||
<h3
|
||||
className="mb-2 w-full border-b-2 pb-1.5 font-bold uppercase"
|
||||
style={{ color: theme.primary, borderColor: theme.primary }}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
};
|
||||
|
||||
export default Heading;
|
||||
65
client/templates/Glalie/widgets/Masthead.tsx
Normal file
65
client/templates/Glalie/widgets/Masthead.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import { Email, Phone, Public, Room } from '@mui/icons-material';
|
||||
import get from 'lodash/get';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
|
||||
import Markdown from '@/components/shared/Markdown';
|
||||
import { useAppSelector } from '@/store/hooks';
|
||||
import DataDisplay from '@/templates/shared/DataDisplay';
|
||||
import getProfileIcon from '@/utils/getProfileIcon';
|
||||
import { addHttp, formatLocation, getPhotoClassNames } from '@/utils/template';
|
||||
|
||||
export const MastheadSidebar: React.FC = () => {
|
||||
const primaryColor: string = useAppSelector((state) => get(state.resume, 'metadata.theme.primary'));
|
||||
const { name, headline, photo, email, phone, website, location, profiles } = useAppSelector(
|
||||
(state) => state.resume.basics
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="col-span-2 grid justify-items-center gap-4">
|
||||
{photo.visible && !isEmpty(photo.url) && (
|
||||
<img
|
||||
alt={name}
|
||||
src={photo.url}
|
||||
width={photo.filters.size}
|
||||
height={photo.filters.size}
|
||||
className={getPhotoClassNames(photo.filters)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="text-center">
|
||||
<h1>{name}</h1>
|
||||
<p className="mt-1 opacity-75">{headline}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 rounded border-2 p-4" style={{ borderColor: primaryColor }}>
|
||||
<DataDisplay icon={<Room />} className="text-xs">
|
||||
{formatLocation(location)}
|
||||
</DataDisplay>
|
||||
|
||||
<DataDisplay icon={<Email />} className="text-xs" link={`mailto:${email}`}>
|
||||
{email}
|
||||
</DataDisplay>
|
||||
|
||||
<DataDisplay icon={<Phone />} className="text-xs" link={`tel:${phone}`}>
|
||||
{phone}
|
||||
</DataDisplay>
|
||||
|
||||
<DataDisplay icon={<Public />} link={addHttp(website)} className="text-xs">
|
||||
{website}
|
||||
</DataDisplay>
|
||||
|
||||
{profiles.map(({ id, username, network, url }) => (
|
||||
<DataDisplay key={id} icon={getProfileIcon(network)} link={url} className="text-xs">
|
||||
{username}
|
||||
</DataDisplay>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const MastheadMain: React.FC = () => {
|
||||
const { summary } = useAppSelector((state) => state.resume.basics);
|
||||
|
||||
return <Markdown>{summary}</Markdown>;
|
||||
};
|
||||
113
client/templates/Glalie/widgets/Section.tsx
Normal file
113
client/templates/Glalie/widgets/Section.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
import { Email, Link, Phone } from '@mui/icons-material';
|
||||
import { ListItem, Section } from '@reactive-resume/schema';
|
||||
import get from 'lodash/get';
|
||||
import isArray from 'lodash/isArray';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
|
||||
import Markdown from '@/components/shared/Markdown';
|
||||
import { useAppSelector } from '@/store/hooks';
|
||||
import { SectionProps } from '@/templates/sectionMap';
|
||||
import DataDisplay from '@/templates/shared/DataDisplay';
|
||||
import { formatDateString } from '@/utils/date';
|
||||
import { parseListItemPath } from '@/utils/template';
|
||||
|
||||
import BadgeDisplay from './BadgeDisplay';
|
||||
import Heading from './Heading';
|
||||
|
||||
const Section: React.FC<SectionProps> = ({
|
||||
path,
|
||||
titlePath = 'title',
|
||||
subtitlePath = 'subtitle',
|
||||
headlinePath = 'headline',
|
||||
keywordsPath = 'keywords',
|
||||
}) => {
|
||||
const section: Section = useAppSelector((state) => get(state.resume, path, {}));
|
||||
const dateFormat: string = useAppSelector((state) => get(state.resume, 'metadata.date.format'));
|
||||
const primaryColor: string = useAppSelector((state) => get(state.resume, 'metadata.theme.primary'));
|
||||
|
||||
if (!section.visible) return null;
|
||||
|
||||
if (isArray(section.items) && isEmpty(section.items)) return null;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<Heading>{section.name}</Heading>
|
||||
|
||||
<div
|
||||
className="grid items-start gap-4"
|
||||
style={{ gridTemplateColumns: `repeat(${section.columns}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{section.items.map((item: ListItem) => {
|
||||
const id = item.id,
|
||||
title = parseListItemPath(item, titlePath),
|
||||
subtitle = parseListItemPath(item, subtitlePath),
|
||||
headline = parseListItemPath(item, headlinePath),
|
||||
keywords: string[] = get(item, keywordsPath),
|
||||
url: string = get(item, 'url'),
|
||||
summary: string = get(item, 'summary'),
|
||||
level: string = get(item, 'level'),
|
||||
levelNum: number = get(item, 'levelNum'),
|
||||
phone: string = get(item, 'phone'),
|
||||
email: string = get(item, 'email'),
|
||||
date = formatDateString(get(item, 'date'), dateFormat);
|
||||
|
||||
return (
|
||||
<div key={id} id={id} className="grid gap-1">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex flex-col">
|
||||
{title && <span className="font-semibold">{title}</span>}
|
||||
{subtitle && <span className="opacity-75">{subtitle}</span>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 text-right text-xs">
|
||||
{date && <div className="opacity-50">({date})</div>}
|
||||
{headline && <span className="opacity-75">{headline}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(level || levelNum > 0) && (
|
||||
<div className="grid gap-1">
|
||||
{level && <span className="opacity-75">{level}</span>}
|
||||
{levelNum > 0 && (
|
||||
<div
|
||||
className="h-2.5 rounded-sm border-2"
|
||||
style={{ width: `${levelNum * 9}%`, backgroundColor: primaryColor, borderColor: primaryColor }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{summary && <Markdown>{summary}</Markdown>}
|
||||
|
||||
{url && (
|
||||
<DataDisplay icon={<Link />} link={url}>
|
||||
{url}
|
||||
</DataDisplay>
|
||||
)}
|
||||
|
||||
{keywords && <BadgeDisplay items={keywords} />}
|
||||
|
||||
{(phone || email) && (
|
||||
<div className="grid gap-1">
|
||||
{phone && (
|
||||
<DataDisplay icon={<Phone />} link={`tel:${phone}`}>
|
||||
{phone}
|
||||
</DataDisplay>
|
||||
)}
|
||||
|
||||
{email && (
|
||||
<DataDisplay icon={<Email />} link={`mailto:${email}`}>
|
||||
{email}
|
||||
</DataDisplay>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Section;
|
||||
19
client/templates/Kakuna/Kakuna.module.scss
Normal file
19
client/templates/Kakuna/Kakuna.module.scss
Normal file
@ -0,0 +1,19 @@
|
||||
.page {
|
||||
@apply px-6 py-4;
|
||||
|
||||
a {
|
||||
@apply font-bold;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
@apply grid gap-4 text-center;
|
||||
|
||||
.main {
|
||||
@apply grid gap-4;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
@apply grid gap-4;
|
||||
}
|
||||
}
|
||||
35
client/templates/Kakuna/Kakuna.tsx
Normal file
35
client/templates/Kakuna/Kakuna.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import Markdown from '@/components/shared/Markdown';
|
||||
import { useAppSelector } from '@/store/hooks';
|
||||
import { PageProps } from '@/utils/template';
|
||||
|
||||
import { getSectionById } from '../sectionMap';
|
||||
import styles from './Kakuna.module.scss';
|
||||
import Masthead from './widgets/Masthead';
|
||||
import Section from './widgets/Section';
|
||||
|
||||
const Kakuna: React.FC<PageProps> = ({ page }) => {
|
||||
const isFirstPage = useMemo(() => page === 0, [page]);
|
||||
|
||||
const { summary } = useAppSelector((state) => state.resume.basics);
|
||||
const layout: string[][] = useAppSelector((state) => state.resume.metadata.layout[page]);
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
{isFirstPage && (
|
||||
<>
|
||||
<Masthead />
|
||||
<Markdown className="mb-2 text-center">{summary}</Markdown>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={styles.container}>
|
||||
<div className={styles.main}>{layout[0].map((key) => getSectionById(key, Section))}</div>
|
||||
<div className={styles.sidebar}>{layout[1].map((key) => getSectionById(key, Section))}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Kakuna;
|
||||
38
client/templates/Kakuna/widgets/BadgeDisplay.tsx
Normal file
38
client/templates/Kakuna/widgets/BadgeDisplay.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import { Theme } from '@reactive-resume/schema';
|
||||
import get from 'lodash/get';
|
||||
import isArray from 'lodash/isArray';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useAppSelector } from '@/store/hooks';
|
||||
import { getContrastColor } from '@/utils/styles';
|
||||
|
||||
type Props = {
|
||||
items: string[];
|
||||
};
|
||||
|
||||
const BadgeDisplay: React.FC<Props> = ({ items }) => {
|
||||
const theme: Theme = useAppSelector((state) => get(state.resume, 'metadata.theme', {}));
|
||||
const contrast = useMemo(() => getContrastColor(theme.primary), [theme.primary]);
|
||||
|
||||
if (!isArray(items) || isEmpty(items)) return null;
|
||||
|
||||
return (
|
||||
<ul className="my-1 flex flex-wrap items-start justify-center gap-1.5">
|
||||
{items.map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
className="rounded-lg px-2 py-0.5 text-xs"
|
||||
style={{
|
||||
color: contrast === 'dark' ? theme.text : theme.background,
|
||||
backgroundColor: theme.primary,
|
||||
}}
|
||||
>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
export default BadgeDisplay;
|
||||
5
client/templates/Kakuna/widgets/Heading.tsx
Normal file
5
client/templates/Kakuna/widgets/Heading.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
const Heading: React.FC = ({ children }) => {
|
||||
return <h3 className="my-2 inline-block border-b px-5 pb-2">{children}</h3>;
|
||||
};
|
||||
|
||||
export default Heading;
|
||||
59
client/templates/Kakuna/widgets/Masthead.tsx
Normal file
59
client/templates/Kakuna/widgets/Masthead.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import { Email, Phone, Public, Room } from '@mui/icons-material';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import React from 'react';
|
||||
|
||||
import { useAppSelector } from '@/store/hooks';
|
||||
import DataDisplay from '@/templates/shared/DataDisplay';
|
||||
import getProfileIcon from '@/utils/getProfileIcon';
|
||||
import { addHttp, formatLocation, getPhotoClassNames } from '@/utils/template';
|
||||
|
||||
const Masthead = () => {
|
||||
const { name, photo, email, phone, website, headline, location, profiles } = useAppSelector(
|
||||
(state) => state.resume.basics
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 justify-center mb-4 border-b pb-4 text-center">
|
||||
<div className="mx-auto">
|
||||
{photo.visible && !isEmpty(photo.url) && (
|
||||
<img
|
||||
alt={name}
|
||||
src={photo.url}
|
||||
width={photo.filters.size}
|
||||
height={photo.filters.size}
|
||||
className={getPhotoClassNames(photo.filters)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="mb-1">{name}</h1>
|
||||
<p className="opacity-75">{headline}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-3">
|
||||
<DataDisplay icon={<Email />} link={`mailto:${email}`}>
|
||||
{email}
|
||||
</DataDisplay>
|
||||
|
||||
<DataDisplay icon={<Phone />} link={`tel:${phone}`}>
|
||||
{phone}
|
||||
</DataDisplay>
|
||||
|
||||
<DataDisplay icon={<Public />} link={addHttp(website)}>
|
||||
{website}
|
||||
</DataDisplay>
|
||||
|
||||
<DataDisplay icon={<Room />}>{formatLocation(location)}</DataDisplay>
|
||||
|
||||
{profiles.map(({ id, username, network, url }) => (
|
||||
<DataDisplay key={id} icon={getProfileIcon(network)} link={url}>
|
||||
{username}
|
||||
</DataDisplay>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Masthead;
|
||||
120
client/templates/Kakuna/widgets/Section.tsx
Normal file
120
client/templates/Kakuna/widgets/Section.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
import { Email, Phone } from '@mui/icons-material';
|
||||
import { ListItem, Section } from '@reactive-resume/schema';
|
||||
import get from 'lodash/get';
|
||||
import isArray from 'lodash/isArray';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
|
||||
import Markdown from '@/components/shared/Markdown';
|
||||
import { useAppSelector } from '@/store/hooks';
|
||||
import { SectionProps } from '@/templates/sectionMap';
|
||||
import { formatDateString } from '@/utils/date';
|
||||
import { parseListItemPath } from '@/utils/template';
|
||||
|
||||
import BadgeDisplay from './BadgeDisplay';
|
||||
import Heading from './Heading';
|
||||
|
||||
const Section: React.FC<SectionProps> = ({
|
||||
path,
|
||||
titlePath = 'title',
|
||||
subtitlePath = 'subtitle',
|
||||
headlinePath = 'headline',
|
||||
keywordsPath = 'keywords',
|
||||
}) => {
|
||||
const section: Section = useAppSelector((state) => get(state.resume, path, {}));
|
||||
const dateFormat: string = useAppSelector((state) => get(state.resume, 'metadata.date.format'));
|
||||
const primaryColor: string = useAppSelector((state) => get(state.resume, 'metadata.theme.primary'));
|
||||
|
||||
if (!section.visible) return null;
|
||||
|
||||
if (isArray(section.items) && isEmpty(section.items)) return null;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<Heading>{section.name}</Heading>
|
||||
|
||||
<div
|
||||
className="grid items-start gap-4"
|
||||
style={{ gridTemplateColumns: `repeat(${section.columns}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{section.items.map((item: ListItem) => {
|
||||
const id = item.id,
|
||||
title = parseListItemPath(item, titlePath),
|
||||
subtitle = parseListItemPath(item, subtitlePath),
|
||||
headline = parseListItemPath(item, headlinePath),
|
||||
keywords: string[] = get(item, keywordsPath),
|
||||
url: string = get(item, 'url'),
|
||||
summary: string = get(item, 'summary'),
|
||||
level: string = get(item, 'level'),
|
||||
levelNum: number = get(item, 'levelNum'),
|
||||
phone: string = get(item, 'phone'),
|
||||
email: string = get(item, 'email'),
|
||||
date = formatDateString(get(item, 'date'), dateFormat);
|
||||
|
||||
return (
|
||||
<div key={id} id={id} className="grid gap-1">
|
||||
{title && <span className="font-bold">{title}</span>}
|
||||
|
||||
{subtitle && <span className="opacity-75">{subtitle}</span>}
|
||||
|
||||
{headline && <span className="opacity-75">{headline}</span>}
|
||||
|
||||
{(level || levelNum > 0) && (
|
||||
<div className="grid gap-1">
|
||||
{level && <span className="opacity-75">{level}</span>}
|
||||
{levelNum > 0 && (
|
||||
<div className="flex justify-center">
|
||||
{Array.from(Array(5).keys()).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="mr-1 h-3 w-4 rounded border-2"
|
||||
style={{
|
||||
borderColor: primaryColor,
|
||||
backgroundColor: levelNum / (10 / 5) > index ? primaryColor : '',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{date && <div className="opacity-50">({date})</div>}
|
||||
|
||||
{summary && <Markdown>{summary}</Markdown>}
|
||||
|
||||
{url && (
|
||||
<div className="inline-flex justify-center">
|
||||
<a href={url} target="_blank" rel="noreferrer">
|
||||
{url}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{keywords && <BadgeDisplay items={keywords} />}
|
||||
|
||||
{(phone || email) && (
|
||||
<div className="grid gap-1">
|
||||
{phone && (
|
||||
<div className="inline-flex items-center justify-center gap-x-2">
|
||||
<Phone />
|
||||
<span>{phone}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{email && (
|
||||
<div className="inline-flex items-center justify-center gap-x-2">
|
||||
<Email />
|
||||
<span>{email}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Section;
|
||||
19
client/templates/Onyx/Onyx.module.scss
Normal file
19
client/templates/Onyx/Onyx.module.scss
Normal file
@ -0,0 +1,19 @@
|
||||
.page {
|
||||
@apply px-6 py-4;
|
||||
|
||||
a {
|
||||
@apply font-semibold;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
@apply grid gap-4;
|
||||
|
||||
.main {
|
||||
@apply grid gap-4;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
@apply grid gap-4;
|
||||
}
|
||||
}
|
||||
35
client/templates/Onyx/Onyx.tsx
Normal file
35
client/templates/Onyx/Onyx.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import Markdown from '@/components/shared/Markdown';
|
||||
import { useAppSelector } from '@/store/hooks';
|
||||
import { PageProps } from '@/utils/template';
|
||||
|
||||
import { getSectionById } from '../sectionMap';
|
||||
import styles from './Onyx.module.scss';
|
||||
import Masthead from './widgets/Masthead';
|
||||
import Section from './widgets/Section';
|
||||
|
||||
const Onyx: React.FC<PageProps> = ({ page }) => {
|
||||
const isFirstPage = useMemo(() => page === 0, [page]);
|
||||
|
||||
const { summary } = useAppSelector((state) => state.resume.basics);
|
||||
const layout: string[][] = useAppSelector((state) => state.resume.metadata.layout[page]);
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
{isFirstPage && (
|
||||
<div className="mb-4 grid gap-4 border-b pb-4">
|
||||
<Masthead />
|
||||
<Markdown>{summary}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.container}>
|
||||
<div className={styles.main}>{layout[0].map((key) => getSectionById(key, Section))}</div>
|
||||
<div className={styles.sidebar}>{layout[1].map((key) => getSectionById(key, Section))}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Onyx;
|
||||
16
client/templates/Onyx/widgets/Heading.tsx
Normal file
16
client/templates/Onyx/widgets/Heading.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import { Theme } from '@reactive-resume/schema';
|
||||
import get from 'lodash/get';
|
||||
|
||||
import { useAppSelector } from '@/store/hooks';
|
||||
|
||||
const Heading: React.FC = ({ children }) => {
|
||||
const theme: Theme = useAppSelector((state) => get(state.resume, 'metadata.theme', {}));
|
||||
|
||||
return (
|
||||
<h4 className="mb-2 font-bold uppercase" style={{ color: theme.primary }}>
|
||||
{children}
|
||||
</h4>
|
||||
);
|
||||
};
|
||||
|
||||
export default Heading;
|
||||
62
client/templates/Onyx/widgets/Masthead.tsx
Normal file
62
client/templates/Onyx/widgets/Masthead.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import { Email, Phone, Public, Room } from '@mui/icons-material';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
|
||||
import { useAppSelector } from '@/store/hooks';
|
||||
import DataDisplay from '@/templates/shared/DataDisplay';
|
||||
import getProfileIcon from '@/utils/getProfileIcon';
|
||||
import { addHttp, formatLocation, getPhotoClassNames } from '@/utils/template';
|
||||
|
||||
const Masthead: React.FC = () => {
|
||||
const { name, photo, email, phone, website, headline, location, profiles } = useAppSelector(
|
||||
(state) => state.resume.basics
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
{photo.visible && !isEmpty(photo.url) && (
|
||||
<img
|
||||
alt={name}
|
||||
src={photo.url}
|
||||
width={photo.filters.size}
|
||||
height={photo.filters.size}
|
||||
className={getPhotoClassNames(photo.filters)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="grid flex-1 gap-1">
|
||||
<h1>{name}</h1>
|
||||
<p className="opacity-75">{headline}</p>
|
||||
|
||||
<div className="mt-2 grid gap-2">
|
||||
<DataDisplay icon={<Room />} className="text-xs">
|
||||
{formatLocation(location)}
|
||||
</DataDisplay>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<DataDisplay icon={<Email />} className="text-xs" link={`mailto:${email}`}>
|
||||
{email}
|
||||
</DataDisplay>
|
||||
|
||||
<DataDisplay icon={<Phone />} className="text-xs" link={`tel:${phone}`}>
|
||||
{phone}
|
||||
</DataDisplay>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid flex-[0.4] gap-2">
|
||||
<DataDisplay icon={<Public />} link={addHttp(website)} className="text-xs">
|
||||
{website}
|
||||
</DataDisplay>
|
||||
|
||||
{profiles.map(({ id, username, network, url }) => (
|
||||
<DataDisplay key={id} icon={getProfileIcon(network)} link={url} className="text-xs">
|
||||
{username}
|
||||
</DataDisplay>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Masthead;
|
||||
126
client/templates/Onyx/widgets/Section.tsx
Normal file
126
client/templates/Onyx/widgets/Section.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
import { Email, Link, Phone } from '@mui/icons-material';
|
||||
import { ListItem, Section } from '@reactive-resume/schema';
|
||||
import get from 'lodash/get';
|
||||
import isArray from 'lodash/isArray';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
|
||||
import Markdown from '@/components/shared/Markdown';
|
||||
import { useAppSelector } from '@/store/hooks';
|
||||
import { SectionProps } from '@/templates/sectionMap';
|
||||
import DataDisplay from '@/templates/shared/DataDisplay';
|
||||
import { formatDateString } from '@/utils/date';
|
||||
import { parseListItemPath } from '@/utils/template';
|
||||
|
||||
import Heading from './Heading';
|
||||
|
||||
const Section: React.FC<SectionProps> = ({
|
||||
path,
|
||||
titlePath = 'title',
|
||||
subtitlePath = 'subtitle',
|
||||
headlinePath = 'headline',
|
||||
keywordsPath = 'keywords',
|
||||
}) => {
|
||||
const section: Section = useAppSelector((state) => get(state.resume, path, {}));
|
||||
const dateFormat: string = useAppSelector((state) => get(state.resume, 'metadata.date.format'));
|
||||
const primaryColor: string = useAppSelector((state) => get(state.resume, 'metadata.theme.primary'));
|
||||
|
||||
if (!section.visible) return null;
|
||||
|
||||
if (isArray(section.items) && isEmpty(section.items)) return null;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<Heading>{section.name}</Heading>
|
||||
|
||||
<div
|
||||
className="grid items-start gap-4"
|
||||
style={{ gridTemplateColumns: `repeat(${section.columns}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{section.items.map((item: ListItem) => {
|
||||
const id = item.id,
|
||||
title = parseListItemPath(item, titlePath),
|
||||
subtitle = parseListItemPath(item, subtitlePath),
|
||||
headline = parseListItemPath(item, headlinePath),
|
||||
keywords: string[] = get(item, keywordsPath),
|
||||
url: string = get(item, 'url'),
|
||||
summary: string = get(item, 'summary'),
|
||||
level: string = get(item, 'level'),
|
||||
levelNum: number = get(item, 'levelNum'),
|
||||
phone: string = get(item, 'phone'),
|
||||
email: string = get(item, 'email'),
|
||||
date = formatDateString(get(item, 'date'), dateFormat);
|
||||
|
||||
return (
|
||||
<div key={id} id={id} className="grid gap-1">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex flex-col">
|
||||
{title && <span className="font-semibold">{title}</span>}
|
||||
{subtitle && <span className="opacity-75">{subtitle}</span>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 text-right text-xs">
|
||||
{date && <div className="opacity-50">({date})</div>}
|
||||
{headline && <span className="opacity-75">{headline}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(level || levelNum > 0) && (
|
||||
<div className="grid gap-1">
|
||||
{level && <span className="opacity-75">{level}</span>}
|
||||
{levelNum > 0 && (
|
||||
<div className="flex">
|
||||
{Array.from(Array(5).keys()).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="mr-1 h-3 w-3 rounded-full border-2"
|
||||
style={{
|
||||
borderColor: primaryColor,
|
||||
backgroundColor: levelNum / (10 / 5) > index ? primaryColor : '',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{summary && <Markdown>{summary}</Markdown>}
|
||||
|
||||
{url && (
|
||||
<DataDisplay icon={<Link />} link={url} className="text-xs">
|
||||
{url}
|
||||
</DataDisplay>
|
||||
)}
|
||||
|
||||
{keywords && (
|
||||
<div>
|
||||
<span className="font-semibold">Keywords:</span>
|
||||
|
||||
{keywords.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(phone || email) && (
|
||||
<div className="grid gap-1">
|
||||
{phone && (
|
||||
<DataDisplay icon={<Phone />} link={`tel:${phone}`}>
|
||||
{phone}
|
||||
</DataDisplay>
|
||||
)}
|
||||
|
||||
{email && (
|
||||
<DataDisplay icon={<Email />} link={`mailto:${email}`}>
|
||||
{email}
|
||||
</DataDisplay>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Section;
|
||||
19
client/templates/Pikachu/Pikachu.module.scss
Normal file
19
client/templates/Pikachu/Pikachu.module.scss
Normal file
@ -0,0 +1,19 @@
|
||||
.page {
|
||||
@apply px-6 py-4;
|
||||
|
||||
a {
|
||||
@apply font-semibold;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
@apply grid grid-cols-6 gap-4;
|
||||
|
||||
.main {
|
||||
@apply col-span-4 flex flex-col gap-4;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
@apply col-span-2 flex flex-col gap-4;
|
||||
}
|
||||
}
|
||||
33
client/templates/Pikachu/Pikachu.tsx
Normal file
33
client/templates/Pikachu/Pikachu.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useAppSelector } from '@/store/hooks';
|
||||
import { PageProps } from '@/utils/template';
|
||||
|
||||
import { getSectionById } from '../sectionMap';
|
||||
import styles from './Pikachu.module.scss';
|
||||
import { MastheadMain, MastheadSidebar } from './widgets/Masthead';
|
||||
import Section from './widgets/Section';
|
||||
|
||||
const Pikachu: React.FC<PageProps> = ({ page }) => {
|
||||
const isFirstPage = useMemo(() => page === 0, [page]);
|
||||
const layout: string[][] = useAppSelector((state) => state.resume.metadata.layout[page]);
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.sidebar}>
|
||||
{isFirstPage && <MastheadSidebar />}
|
||||
|
||||
{layout[1].map((key) => getSectionById(key, Section))}
|
||||
</div>
|
||||
<div className={styles.main}>
|
||||
{isFirstPage && <MastheadMain />}
|
||||
|
||||
{layout[0].map((key) => getSectionById(key, Section))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Pikachu;
|
||||
19
client/templates/Pikachu/widgets/Heading.tsx
Normal file
19
client/templates/Pikachu/widgets/Heading.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { Theme } from '@reactive-resume/schema';
|
||||
import get from 'lodash/get';
|
||||
|
||||
import { useAppSelector } from '@/store/hooks';
|
||||
|
||||
const Heading: React.FC = ({ children }) => {
|
||||
const theme: Theme = useAppSelector((state) => get(state.resume, 'metadata.theme', {}));
|
||||
|
||||
return (
|
||||
<h3
|
||||
className="mb-2 w-2/3 border-b-2 pb-1.5 font-bold uppercase"
|
||||
style={{ color: theme.primary, borderColor: theme.primary }}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
};
|
||||
|
||||
export default Heading;
|
||||
73
client/templates/Pikachu/widgets/Masthead.tsx
Normal file
73
client/templates/Pikachu/widgets/Masthead.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import { Email, Phone, Public, Room } from '@mui/icons-material';
|
||||
import { Theme } from '@reactive-resume/schema';
|
||||
import get from 'lodash/get';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import Markdown from '@/components/shared/Markdown';
|
||||
import { useAppSelector } from '@/store/hooks';
|
||||
import DataDisplay from '@/templates/shared/DataDisplay';
|
||||
import getProfileIcon from '@/utils/getProfileIcon';
|
||||
import { getContrastColor } from '@/utils/styles';
|
||||
import { addHttp, formatLocation, getPhotoClassNames } from '@/utils/template';
|
||||
|
||||
export const MastheadSidebar: React.FC = () => {
|
||||
const { name, photo, email, phone, website, location, profiles } = useAppSelector((state) => state.resume.basics);
|
||||
|
||||
return (
|
||||
<div className="col-span-2 grid justify-items-center gap-4">
|
||||
{photo.visible && !isEmpty(photo.url) && (
|
||||
<div className="relative aspect-square h-full w-full">
|
||||
<img alt={name} src={photo.url} className={getPhotoClassNames(photo.filters)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<DataDisplay icon={<Room />} className="text-xs">
|
||||
{formatLocation(location)}
|
||||
</DataDisplay>
|
||||
|
||||
<DataDisplay icon={<Email />} className="text-xs" link={`mailto:${email}`}>
|
||||
{email}
|
||||
</DataDisplay>
|
||||
|
||||
<DataDisplay icon={<Phone />} className="text-xs" link={`tel:${phone}`}>
|
||||
{phone}
|
||||
</DataDisplay>
|
||||
|
||||
<DataDisplay icon={<Public />} link={addHttp(website)} className="text-xs">
|
||||
{website}
|
||||
</DataDisplay>
|
||||
|
||||
{profiles.map(({ id, username, network, url }) => (
|
||||
<DataDisplay key={id} icon={getProfileIcon(network)} link={url} className="text-xs">
|
||||
{username}
|
||||
</DataDisplay>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const MastheadMain: React.FC = () => {
|
||||
const theme: Theme = useAppSelector((state) => get(state.resume, 'metadata.theme', {}));
|
||||
const contrast = useMemo(() => getContrastColor(theme.primary), [theme.primary]);
|
||||
|
||||
const { name, summary, headline } = useAppSelector((state) => state.resume.basics);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="grid gap-2 p-4"
|
||||
style={{ color: contrast === 'dark' ? theme.text : theme.background, backgroundColor: theme.primary }}
|
||||
>
|
||||
<div>
|
||||
<h1>{name}</h1>
|
||||
<p className="opacity-75">{headline}</p>
|
||||
</div>
|
||||
|
||||
<hr className="opacity-25" />
|
||||
|
||||
<Markdown>{summary}</Markdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
118
client/templates/Pikachu/widgets/Section.tsx
Normal file
118
client/templates/Pikachu/widgets/Section.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
import { Email, Link, Phone } from '@mui/icons-material';
|
||||
import { ListItem, Section } from '@reactive-resume/schema';
|
||||
import get from 'lodash/get';
|
||||
import isArray from 'lodash/isArray';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
|
||||
import Markdown from '@/components/shared/Markdown';
|
||||
import { useAppSelector } from '@/store/hooks';
|
||||
import { SectionProps } from '@/templates/sectionMap';
|
||||
import DataDisplay from '@/templates/shared/DataDisplay';
|
||||
import { formatDateString } from '@/utils/date';
|
||||
import { parseListItemPath } from '@/utils/template';
|
||||
|
||||
import Heading from './Heading';
|
||||
|
||||
const Section: React.FC<SectionProps> = ({
|
||||
path,
|
||||
titlePath = 'title',
|
||||
subtitlePath = 'subtitle',
|
||||
headlinePath = 'headline',
|
||||
keywordsPath = 'keywords',
|
||||
}) => {
|
||||
const section: Section = useAppSelector((state) => get(state.resume, path, {}));
|
||||
const dateFormat: string = useAppSelector((state) => get(state.resume, 'metadata.date.format'));
|
||||
const primaryColor: string = useAppSelector((state) => get(state.resume, 'metadata.theme.primary'));
|
||||
|
||||
if (!section.visible) return null;
|
||||
|
||||
if (isArray(section.items) && isEmpty(section.items)) return null;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<Heading>{section.name}</Heading>
|
||||
|
||||
<div
|
||||
className="grid items-start gap-4"
|
||||
style={{ gridTemplateColumns: `repeat(${section.columns}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{section.items.map((item: ListItem) => {
|
||||
const id = item.id,
|
||||
title = parseListItemPath(item, titlePath),
|
||||
subtitle = parseListItemPath(item, subtitlePath),
|
||||
headline = parseListItemPath(item, headlinePath),
|
||||
keywords: string[] = get(item, keywordsPath),
|
||||
url: string = get(item, 'url'),
|
||||
summary: string = get(item, 'summary'),
|
||||
level: string = get(item, 'level'),
|
||||
levelNum: number = get(item, 'levelNum'),
|
||||
phone: string = get(item, 'phone'),
|
||||
email: string = get(item, 'email'),
|
||||
date = formatDateString(get(item, 'date'), dateFormat);
|
||||
|
||||
return (
|
||||
<div key={id} id={id} className="grid gap-1">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex flex-col">
|
||||
{title && <span className="font-semibold">{title}</span>}
|
||||
{subtitle && <span className="opacity-75">{subtitle}</span>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 text-right text-xs">
|
||||
{date && <div className="opacity-50">({date})</div>}
|
||||
{headline && <span className="opacity-75">{headline}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(level || levelNum > 0) && (
|
||||
<div className="grid gap-1">
|
||||
{level && <span className="opacity-75">{level}</span>}
|
||||
{levelNum > 0 && (
|
||||
<div
|
||||
className="h-2.5 rounded-sm border-2"
|
||||
style={{ width: `${levelNum * 9}%`, backgroundColor: primaryColor, borderColor: primaryColor }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{summary && <Markdown>{summary}</Markdown>}
|
||||
|
||||
{url && (
|
||||
<DataDisplay icon={<Link />} link={url}>
|
||||
{url}
|
||||
</DataDisplay>
|
||||
)}
|
||||
|
||||
{keywords && (
|
||||
<div className="leading-normal">
|
||||
<span className="font-semibold">Keywords:</span>
|
||||
|
||||
{keywords.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(phone || email) && (
|
||||
<div className="grid gap-1">
|
||||
{phone && (
|
||||
<DataDisplay icon={<Phone />} link={`tel:${phone}`}>
|
||||
{phone}
|
||||
</DataDisplay>
|
||||
)}
|
||||
|
||||
{email && (
|
||||
<DataDisplay icon={<Email />} link={`mailto:${email}`}>
|
||||
{email}
|
||||
</DataDisplay>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Section;
|
||||
54
client/templates/sectionMap.tsx
Normal file
54
client/templates/sectionMap.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import get from 'lodash/get';
|
||||
import React from 'react';
|
||||
import { validate } from 'uuid';
|
||||
|
||||
export type SectionProps = {
|
||||
path: string;
|
||||
titlePath?: string | string[];
|
||||
subtitlePath?: string | string[];
|
||||
headlinePath?: string | string[];
|
||||
keywordsPath?: string;
|
||||
};
|
||||
|
||||
const sectionMap = (Section: React.FC<SectionProps>): Record<string, JSX.Element> => ({
|
||||
work: <Section key="work" path="sections.work" titlePath="name" subtitlePath="position" />,
|
||||
education: (
|
||||
<Section
|
||||
key="education"
|
||||
path="sections.education"
|
||||
titlePath="institution"
|
||||
subtitlePath={['degree', 'area']}
|
||||
headlinePath="score"
|
||||
keywordsPath="courses"
|
||||
/>
|
||||
),
|
||||
awards: <Section key="awards" path="sections.awards" titlePath="title" subtitlePath="awarder" />,
|
||||
certifications: (
|
||||
<Section key="certifications" path="sections.certifications" titlePath="name" subtitlePath="issuer" />
|
||||
),
|
||||
publications: <Section key="publications" path="sections.publications" titlePath="name" subtitlePath="publisher" />,
|
||||
skills: <Section key="skills" path="sections.skills" titlePath="name" keywordsPath="keywords" />,
|
||||
languages: <Section key="languages" path="sections.languages" titlePath="name" />,
|
||||
interests: <Section key="interests" path="sections.interests" titlePath="name" keywordsPath="keywords" />,
|
||||
projects: (
|
||||
<Section
|
||||
key="projects"
|
||||
path="sections.projects"
|
||||
titlePath="name"
|
||||
subtitlePath="description"
|
||||
keywordsPath="keywords"
|
||||
/>
|
||||
),
|
||||
volunteer: <Section key="volunteer" path="sections.volunteer" titlePath="organization" subtitlePath="position" />,
|
||||
references: <Section key="references" path="sections.references" titlePath="name" subtitlePath="relationship" />,
|
||||
});
|
||||
|
||||
export const getSectionById = (id: string, Section: React.FC<SectionProps>): JSX.Element => {
|
||||
if (validate(id)) {
|
||||
return <Section key={id} path={`sections.${id}`} />;
|
||||
}
|
||||
|
||||
return get(sectionMap(Section), id);
|
||||
};
|
||||
|
||||
export default sectionMap;
|
||||
32
client/templates/shared/DataDisplay.tsx
Normal file
32
client/templates/shared/DataDisplay.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import clsx from 'clsx';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
|
||||
type Props = {
|
||||
icon?: JSX.Element;
|
||||
link?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const DataDisplay: React.FC<Props> = ({ icon, link, className, children }) => {
|
||||
if (isEmpty(children)) return null;
|
||||
|
||||
if (!isEmpty(link)) {
|
||||
return (
|
||||
<div className={clsx('inline-flex items-center gap-1', className)}>
|
||||
{icon}
|
||||
<a href={link} target="_blank" rel="noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx('inline-flex items-center gap-1', className)}>
|
||||
{icon}
|
||||
<span>{children}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataDisplay;
|
||||
56
client/templates/templateMap.tsx
Normal file
56
client/templates/templateMap.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import { PageProps } from '@/utils/template';
|
||||
|
||||
import Castform from './Castform/Castform';
|
||||
import Gengar from './Gengar/Gengar';
|
||||
import Glalie from './Glalie/Glalie';
|
||||
import Kakuna from './Kakuna/Kakuna';
|
||||
import Onyx from './Onyx/Onyx';
|
||||
import Pikachu from './Pikachu/Pikachu';
|
||||
|
||||
export type TemplateMeta = {
|
||||
id: string;
|
||||
name: string;
|
||||
preview: string;
|
||||
component: React.FC<PageProps>;
|
||||
};
|
||||
|
||||
const templateMap: Record<string, TemplateMeta> = {
|
||||
kakuna: {
|
||||
id: 'kakuna',
|
||||
name: 'Kakuna',
|
||||
preview: '/images/templates/kakuna.jpg',
|
||||
component: Kakuna,
|
||||
},
|
||||
onyx: {
|
||||
id: 'onyx',
|
||||
name: 'Onyx',
|
||||
preview: '/images/templates/onyx.jpg',
|
||||
component: Onyx,
|
||||
},
|
||||
pikachu: {
|
||||
id: 'pikachu',
|
||||
name: 'Pikachu',
|
||||
preview: '/images/templates/pikachu.jpg',
|
||||
component: Pikachu,
|
||||
},
|
||||
gengar: {
|
||||
id: 'gengar',
|
||||
name: 'Gengar',
|
||||
preview: '/images/templates/gengar.jpg',
|
||||
component: Gengar,
|
||||
},
|
||||
castform: {
|
||||
id: 'castform',
|
||||
name: 'Castform',
|
||||
preview: '/images/templates/castform.jpg',
|
||||
component: Castform,
|
||||
},
|
||||
glalie: {
|
||||
id: 'glalie',
|
||||
name: 'Glalie',
|
||||
preview: '/images/templates/glalie.jpg',
|
||||
component: Glalie,
|
||||
},
|
||||
};
|
||||
|
||||
export default templateMap;
|
||||
Reference in New Issue
Block a user