Data structure refactor (#164)

This commit is contained in:
Konrad Szwarc 2023-01-20 16:13:03 +01:00 committed by GitHub
parent 5afdfe9e16
commit b2650d771d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
146 changed files with 1501 additions and 2169 deletions

View file

@ -34,3 +34,17 @@ jobs:
run: npm ci run: npm ci
- name: Run TypeScript types check - name: Run TypeScript types check
run: npm run ts:check run: npm run ts:check
astro:
name: Run Astro check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 16
cache: npm
- name: Install dependencies
run: npm ci
- name: Run Astro check
run: npm run astro:check

View file

@ -1,8 +1,8 @@
import image from '@astrojs/image'; import image from '@astrojs/image';
import tailwind from '@astrojs/tailwind'; import tailwind from '@astrojs/tailwind';
import compress from 'astro-compress';
import { defineConfig } from 'astro/config'; import { defineConfig } from 'astro/config';
import { visualizer } from 'rollup-plugin-visualizer'; import { visualizer } from 'rollup-plugin-visualizer';
import compress from 'astro-compress';
// https://astro.build/config // https://astro.build/config
export default defineConfig({ export default defineConfig({

37
package-lock.json generated
View file

@ -19,7 +19,9 @@
"astro": "1.9.2", "astro": "1.9.2",
"astro-compress": "1.1.27", "astro-compress": "1.1.27",
"concurrently": "7.6.0", "concurrently": "7.6.0",
"date-fns": "2.29.3",
"iconify-icon-names": "1.1.0", "iconify-icon-names": "1.1.0",
"immer": "9.0.18",
"locales-ts": "1.0.0", "locales-ts": "1.0.0",
"postcss": "8.4.21", "postcss": "8.4.21",
"prettier": "2.8.2", "prettier": "2.8.2",
@ -29,6 +31,7 @@
"puppeteer-report": "3.1.0", "puppeteer-report": "3.1.0",
"rollup-plugin-visualizer": "5.9.0", "rollup-plugin-visualizer": "5.9.0",
"tailwindcss": "3.2.4", "tailwindcss": "3.2.4",
"type-fest": "3.5.2",
"typescript": "4.9.4" "typescript": "4.9.4"
}, },
"engines": { "engines": {
@ -3976,6 +3979,16 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/immer": {
"version": "9.0.18",
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.18.tgz",
"integrity": "sha512-eAPNpsj7Ax1q6Y/3lm2PmlwRcFzpON7HSNQ3ru5WQH1/PSpnyed/HpNOELl2CxLKoj4r+bAHgdyKqW5gc2Se1A==",
"dev": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": { "node_modules/import-fresh": {
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
@ -7918,6 +7931,18 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/type-fest": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.5.2.tgz",
"integrity": "sha512-Ph7S4EhXzWy0sbljEuZo0tTNoLl+K2tPauGrQpcwUWrOVneLePTuhVzcuzVJJ6RU5DsNwQZka+8YtkXXU4z9cA==",
"dev": true,
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "4.9.4", "version": "4.9.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.4.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.4.tgz",
@ -11971,6 +11996,12 @@
"queue": "6.0.2" "queue": "6.0.2"
} }
}, },
"immer": {
"version": "9.0.18",
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.18.tgz",
"integrity": "sha512-eAPNpsj7Ax1q6Y/3lm2PmlwRcFzpON7HSNQ3ru5WQH1/PSpnyed/HpNOELl2CxLKoj4r+bAHgdyKqW5gc2Se1A==",
"dev": true
},
"import-fresh": { "import-fresh": {
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
@ -14725,6 +14756,12 @@
"safe-buffer": "^5.0.1" "safe-buffer": "^5.0.1"
} }
}, },
"type-fest": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.5.2.tgz",
"integrity": "sha512-Ph7S4EhXzWy0sbljEuZo0tTNoLl+K2tPauGrQpcwUWrOVneLePTuhVzcuzVJJ6RU5DsNwQZka+8YtkXXU4z9cA==",
"dev": true
},
"typescript": { "typescript": {
"version": "4.9.4", "version": "4.9.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.4.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.4.tgz",

View file

@ -13,9 +13,10 @@
"dev": "astro dev", "dev": "astro dev",
"build": "astro build", "build": "astro build",
"preview": "astro preview", "preview": "astro preview",
"generate-cv": "node scripts/generate-cv.cjs --with-clause", "generate-cv": "node scripts/generate-cv.cjs",
"prettier:check": "prettier --check --ignore-path .gitignore .", "prettier:check": "prettier --check --ignore-path .gitignore .",
"prettier:write": "prettier --write --ignore-path .gitignore .", "prettier:write": "prettier --write --ignore-path .gitignore .",
"astro:check": "astro check",
"ts:check": "tsc --jsx preserve --skipLibCheck", "ts:check": "tsc --jsx preserve --skipLibCheck",
"check": "concurrently npm:*:check" "check": "concurrently npm:*:check"
}, },
@ -31,7 +32,9 @@
"astro": "1.9.2", "astro": "1.9.2",
"astro-compress": "1.1.27", "astro-compress": "1.1.27",
"concurrently": "7.6.0", "concurrently": "7.6.0",
"date-fns": "2.29.3",
"iconify-icon-names": "1.1.0", "iconify-icon-names": "1.1.0",
"immer": "9.0.18",
"locales-ts": "1.0.0", "locales-ts": "1.0.0",
"postcss": "8.4.21", "postcss": "8.4.21",
"prettier": "2.8.2", "prettier": "2.8.2",
@ -41,6 +44,7 @@
"puppeteer-report": "3.1.0", "puppeteer-report": "3.1.0",
"rollup-plugin-visualizer": "5.9.0", "rollup-plugin-visualizer": "5.9.0",
"tailwindcss": "3.2.4", "tailwindcss": "3.2.4",
"type-fest": "3.5.2",
"typescript": "4.9.4" "typescript": "4.9.4"
} }
} }

Binary file not shown.

View file

@ -25,10 +25,6 @@ const config = {
margin: { top: '10mm', right: '10mm', bottom: '10mm', left: '10mm' }, margin: { top: '10mm', right: '10mm', bottom: '10mm', left: '10mm' },
}; };
const hasClause = process.argv.includes('--with-clause');
const url = hasClause ? 'http://localhost:3000/pdf?clause' : 'http://localhost:3000/pdf';
const main = async () => { const main = async () => {
const child = exec('npm run dev'); const child = exec('npm run dev');
@ -39,7 +35,7 @@ const main = async () => {
await page.setViewport({ width: 794, height: 1122, deviceScaleFactor: 2 }); await page.setViewport({ width: 794, height: 1122, deviceScaleFactor: 2 });
await retry({ await retry({
promise: () => page.goto(url, { waitUntil: 'networkidle0' }), promise: () => page.goto('http://localhost:3000/pdf', { waitUntil: 'networkidle0' }),
retries: 5, retries: 5,
retryTime: 1000, retryTime: 1000,
}); });

View file

@ -1 +0,0 @@
<div class="h-px w-full bg-gray-200 dark:bg-gray-600"></div>

View file

@ -1,32 +0,0 @@
---
import type { IconName } from '@/types/icon';
import Icon from './icon.astro';
type IconButtonSize = 'small' | 'large';
export interface Props {
icon: IconName;
target?: astroHTML.JSX.AnchorHTMLAttributes['target'];
href: string;
size: IconButtonSize;
'aria-label'?: astroHTML.JSX.AnchorHTMLAttributes['aria-label'];
}
const sizeMap: Record<IconButtonSize, string> = {
small: 'w-7 h-7',
large: 'w-9 h-9',
};
const { icon, href, target, size, ...rest } = Astro.props;
const classes = /* tw */ {
main: 'flex items-center justify-center rounded text-gray-400 bg-gray-100 dark:bg-gray-600 dark:text-gray-200',
active: 'active:translate-y-px',
focus: 'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500',
};
---
<a href={href} target={target} class:list={[classes.main, classes.active, classes.focus, sizeMap[size]]} {...rest}>
<Icon name={icon} size={16} />
</a>

View file

@ -1,13 +0,0 @@
---
export interface Props {
label: string;
value: string | string[];
}
const { label, value } = Astro.props;
---
<div class="text-base">
<span class="font-medium text-gray-700 dark:text-gray-300">{label}:</span>
<span class="font-normal text-gray-500 dark:text-gray-400">{value}</span>
</div>

View file

@ -1,28 +0,0 @@
---
import { Image } from '@astrojs/image/components';
import type { Photo } from '@/types/common';
export interface Props {
src: Photo;
alt: string;
class?: string;
loading?: 'eager' | 'lazy';
width?: number;
height?: number;
}
const { src, loading, ...props } = Astro.props;
const className = Astro.props.class ?? '';
const isRemoteImage = typeof src === 'string';
---
{
isRemoteImage ? (
<img class={className} src={src} {...props} />
) : (
<Image format="webp" fit="cover" class={className} src={src} loading={loading} {...props} />
)
}

View file

@ -1,47 +0,0 @@
---
import type { SectionKey } from '@/types/data';
import Typography from './typography.astro';
export interface Props {
section: SectionKey;
title?: string;
className?: string;
}
const { section, title, className } = Astro.props;
---
<div
data-type="section"
id={section}
class:list={['flex flex-col gap-6 rounded-2xl bg-white p-8 shadow-md dark:bg-gray-800', className]}
>
{
title && (
<Typography variant="section-title" id={`${section}-heading`}>
{title}
</Typography>
)
}
<slot />
</div>
<script>
import hashState from '@/utils/hash-state';
import throttle from '@/utils/throttle';
const sections = [...document.querySelectorAll('[data-type="section"]')];
const isInUpperView = (section: Element) => section.getBoundingClientRect().bottom >= window.innerHeight / 3;
const updateHash = () => {
const currentSection = sections.find(isInUpperView);
if (currentSection) {
hashState.updateHash(currentSection.id);
}
};
document.addEventListener('scroll', throttle(updateHash, 200));
</script>

View file

@ -1,19 +0,0 @@
---
import type { IconName } from '@/types/icon';
import Icon from './icon.astro';
export interface Props {
name?: IconName;
color?: string;
}
const { name, color } = Astro.props;
---
<div
class="flex h-6 w-fit items-center gap-x-1.5 rounded bg-gray-100 px-2.5 text-sm font-medium tracking-wide text-gray-700 dark:bg-gray-700 dark:text-gray-100"
>
{name && <Icon name={name} color={color} size={16} />}
<slot />
</div>

View file

@ -1,29 +0,0 @@
---
import type { Tag } from '@/types/common';
import TagComponent from './tag.astro';
export interface Props {
tags: Tag[];
}
const { tags } = Astro.props;
---
<div class="flex flex-wrap gap-3">
{
tags.map(({ name: tagName, icon, iconColor, url }) =>
url ? (
<a href={url} target="_blank" rel="noopener noreferrer">
<TagComponent name={icon} color={iconColor}>
{tagName}
</TagComponent>
</a>
) : (
<TagComponent name={icon} color={iconColor}>
{tagName}
</TagComponent>
)
)
}
</div>

View file

@ -1,17 +0,0 @@
---
import getDateFormatter from '@/utils/date-formatter';
import Typography from './typography.astro';
export interface Props {
startDate: Date;
endDate: Date | null;
locale: string;
translationForNow: string;
}
const { startDate, endDate, locale, translationForNow } = Astro.props;
const getFormattedDate = getDateFormatter(locale);
---
<Typography variant="item-subtitle">
{getFormattedDate(startDate).concat(' - ', endDate ? getFormattedDate(endDate) : translationForNow)}
</Typography>

25
src/data/config.ts Normal file
View file

@ -0,0 +1,25 @@
import type { Config } from '@/types/data';
import enUS from 'date-fns/locale/en-US/index.js';
import type { ReadonlyDeep } from 'type-fest';
const config = {
i18n: {
locale: enUS,
dateFormat: 'MMMM yyyy',
translations: {
now: 'now',
},
},
seo: {
title: 'Mark Freeman - Senior React Developer',
description:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. In sodales ac dui at vestibulum. In condimentum metus id dui tincidunt, in blandit mi vehicula.',
favicon: '/favicon.svg',
},
pdf: {
footer:
'I hereby give consent for my personal data included in my application to be processed for the purposes of the recruitment process.',
},
} as const satisfies ReadonlyDeep<Config>;
export default config;

11
src/data/cv.ts Normal file
View file

@ -0,0 +1,11 @@
import type { ReadonlyDeep } from 'type-fest';
import type { Data } from '@/types/data';
import transformData from './transformers/transform-data';
import configData from './config';
import sectionsData from './sections';
const data = { config: configData, sections: sectionsData } as const satisfies ReadonlyDeep<Data>;
export type PreciseData = typeof data;
export const cv = transformData(data);

146
src/data/helpers/links.ts Normal file
View file

@ -0,0 +1,146 @@
// GENERAL
import createLinkFactory from '@/utils/create-link-factory';
export const facebook = createLinkFactory({
name: 'Facebook',
icon: 'fa6-brands:facebook-f',
});
export const linkedin = createLinkFactory({
name: 'LinkedIn',
icon: 'fa6-brands:linkedin-in',
});
export const twitter = createLinkFactory({
name: 'Twitter',
icon: 'fa6-brands:twitter',
});
export const pinterest = createLinkFactory({
name: 'Pinterest',
icon: 'fa6-brands:pinterest',
});
// CODE
export const github = createLinkFactory({
name: 'GitHub',
icon: 'fa6-brands:github',
});
export const codepen = createLinkFactory({
name: 'CodePen',
icon: 'fa6-brands:codepen',
});
export const stackblitz = createLinkFactory({
name: 'StackBlitz',
icon: 'simple-icons:stackblitz',
});
export const codesandbox = createLinkFactory({
name: 'CodeSandbox',
icon: 'simple-icons:codesandbox',
});
// BLOG
export const dev = createLinkFactory({
name: 'Dev',
icon: 'fa6-brands:dev',
});
export const medium = createLinkFactory({
name: 'Medium',
icon: 'fa6-brands:medium',
});
// FORUM / CHAT
export const reddit = createLinkFactory({
name: 'Reddit',
icon: 'fa6-brands:reddit',
});
export const quora = createLinkFactory({
name: 'Quora',
icon: 'fa6-brands:quora',
});
export const stackoverflow = createLinkFactory({
name: 'Stack Overflow',
icon: 'fa6-brands:stack-overflow',
});
// DESIGN
export const instagram = createLinkFactory({
name: 'Instagram',
icon: 'fa6-brands:instagram',
});
export const behance = createLinkFactory({
name: 'Behance',
icon: 'fa6-brands:behance',
});
export const dribbble = createLinkFactory({
name: 'Dribbble',
icon: 'fa6-brands:dribbble',
});
export const figma = createLinkFactory({
name: 'Figma',
icon: 'fa6-brands:figma',
});
// MUSIC
export const spotify = createLinkFactory({
name: 'Spotify',
icon: 'fa6-brands:spotify',
});
export const soundcloud = createLinkFactory({
name: 'SoundCloud',
icon: 'fa6-brands:soundcloud',
});
// VIDEO
export const youtube = createLinkFactory({
name: 'YouTube',
icon: 'fa6-brands:youtube',
});
export const twitch = createLinkFactory({
name: 'Twitch',
icon: 'fa6-brands:twitch',
});
export const vimeo = createLinkFactory({
name: 'Vimeo',
icon: 'fa6-brands:vimeo',
});
// PROJECT TYPE
export const website = createLinkFactory({
name: 'Website',
icon: 'fa6-solid:globe',
});
export const demo = createLinkFactory({
name: 'App demo',
icon: 'fa6-solid:desktop',
});
export const mockups = createLinkFactory({
name: 'Mockups',
icon: 'fa6-solid:image',
});
export const repository = createLinkFactory({
name: 'Repository',
icon: 'fa6-solid:code-branch',
});

View file

@ -1,159 +1,146 @@
import type { LevelledSkill } from '@/types/skills-section'; import createSkillFactory from '@/utils/create-skill-factory';
import type { Tag } from '../types/common'; export const apolloGraphql = createSkillFactory({
interface SkillFactory {
(data: Partial<Tag> & { level: LevelledSkill['level'] }): LevelledSkill;
(data?: Partial<Tag>): Tag;
}
const createSkill = (defaultData: Omit<Tag, 'description'>) =>
((data?: Partial<Tag> & { level?: LevelledSkill['level'] }): Tag | LevelledSkill => ({
...defaultData,
...data,
})) as SkillFactory;
export const apolloGraphql = createSkill({
name: 'Apollo GraphQL', name: 'Apollo GraphQL',
icon: 'simple-icons:apollographql', icon: 'simple-icons:apollographql',
iconColor: '#311C87', iconColor: '#311C87',
url: 'https://www.apollographql.com/', url: 'https://www.apollographql.com/',
}); });
export const astro = createSkill({ export const astro = createSkillFactory({
name: 'Astro', name: 'Astro',
icon: 'simple-icons:astro', icon: 'simple-icons:astro',
iconColor: '#FF5D01', iconColor: '#FF5D01',
url: 'https://astro.build/', url: 'https://astro.build/',
}); });
export const chakraUi = createSkill({ export const chakraUi = createSkillFactory({
name: 'Chakra UI', name: 'Chakra UI',
icon: 'simple-icons:chakraui', icon: 'simple-icons:chakraui',
iconColor: '#319795', iconColor: '#319795',
url: 'https://chakra-ui.com/', url: 'https://chakra-ui.com/',
}); });
export const cypress = createSkill({ export const cypress = createSkillFactory({
name: 'Cypress', name: 'Cypress',
icon: 'simple-icons:cypress', icon: 'simple-icons:cypress',
iconColor: '#17202C', iconColor: '#17202C',
url: 'https://www.cypress.io/', url: 'https://www.cypress.io/',
}); });
export const eslint = createSkill({ export const eslint = createSkillFactory({
name: 'ESLint', name: 'ESLint',
icon: 'simple-icons:eslint', icon: 'simple-icons:eslint',
iconColor: '#4B32C3', iconColor: '#4B32C3',
url: 'https://eslint.org/', url: 'https://eslint.org/',
}); });
export const firebase = createSkill({ export const firebase = createSkillFactory({
name: 'Firebase', name: 'Firebase',
icon: 'simple-icons:firebase', icon: 'simple-icons:firebase',
iconColor: '#FFCA28', iconColor: '#FFCA28',
url: 'https://firebase.google.com/', url: 'https://firebase.google.com/',
}); });
export const jest = createSkill({ export const jest = createSkillFactory({
name: 'Jest', name: 'Jest',
icon: 'simple-icons:jest', icon: 'simple-icons:jest',
iconColor: '#C21325', iconColor: '#C21325',
url: 'https://jestjs.io/', url: 'https://jestjs.io/',
}); });
export const mongoDb = createSkill({ export const mongoDb = createSkillFactory({
name: 'MongoDB', name: 'MongoDB',
icon: 'simple-icons:mongodb', icon: 'simple-icons:mongodb',
iconColor: '#47A248', iconColor: '#47A248',
url: 'https://www.mongodb.com/', url: 'https://www.mongodb.com/',
}); });
export const nestJs = createSkill({ export const nestJs = createSkillFactory({
name: 'NestJS', name: 'NestJS',
icon: 'simple-icons:nestjs', icon: 'simple-icons:nestjs',
iconColor: '#E0234E', iconColor: '#E0234E',
url: 'https://nestjs.com/', url: 'https://nestjs.com/',
}); });
export const nextJs = createSkill({ export const nextJs = createSkillFactory({
name: 'Next.js', name: 'Next.js',
icon: 'simple-icons:nextdotjs', icon: 'simple-icons:nextdotjs',
iconColor: '#000000', iconColor: '#000000',
url: 'https://nextjs.org/', url: 'https://nextjs.org/',
}); });
export const nx = createSkill({ export const nx = createSkillFactory({
name: 'Nx', name: 'Nx',
icon: 'simple-icons:nx', icon: 'simple-icons:nx',
iconColor: '#143055', iconColor: '#143055',
url: 'https://nx.dev/', url: 'https://nx.dev/',
}); });
export const pnpm = createSkill({ export const pnpm = createSkillFactory({
name: 'pnpm', name: 'pnpm',
icon: 'simple-icons:pnpm', icon: 'simple-icons:pnpm',
iconColor: '#F69220', iconColor: '#F69220',
url: 'https://pnpm.io/', url: 'https://pnpm.io/',
}); });
export const postgreSql = createSkill({ export const postgreSql = createSkillFactory({
name: 'PostgreSQL', name: 'PostgreSQL',
icon: 'simple-icons:postgresql', icon: 'simple-icons:postgresql',
iconColor: '#4169E1', iconColor: '#4169E1',
url: 'https://www.postgresql.org/', url: 'https://www.postgresql.org/',
}); });
export const prettier = createSkill({ export const prettier = createSkillFactory({
name: 'Prettier', name: 'Prettier',
icon: 'simple-icons:prettier', icon: 'simple-icons:prettier',
iconColor: '#F7B93E', iconColor: '#F7B93E',
url: 'https://prettier.io/', url: 'https://prettier.io/',
}); });
export const react = createSkill({ export const react = createSkillFactory({
name: 'React.js', name: 'React.js',
icon: 'simple-icons:react', icon: 'simple-icons:react',
iconColor: '#61DAFB', iconColor: '#61DAFB',
url: 'https://reactjs.org/', url: 'https://reactjs.org/',
}); });
export const reactQuery = createSkill({ export const reactQuery = createSkillFactory({
name: 'React Query', name: 'React Query',
icon: 'simple-icons:reactquery', icon: 'simple-icons:reactquery',
iconColor: '#FF4154', iconColor: '#FF4154',
url: 'https://tanstack.com/query', url: 'https://tanstack.com/query',
}); });
export const sass = createSkill({ export const sass = createSkillFactory({
name: 'SASS', name: 'SASS',
icon: 'simple-icons:sass', icon: 'simple-icons:sass',
iconColor: '#CC6699', iconColor: '#CC6699',
url: 'https://sass-lang.com/', url: 'https://sass-lang.com/',
}); });
export const supabase = createSkill({ export const supabase = createSkillFactory({
name: 'Supabase', name: 'Supabase',
icon: 'simple-icons:supabase', icon: 'simple-icons:supabase',
iconColor: '#3ECF8E', iconColor: '#3ECF8E',
url: 'https://supabase.io/', url: 'https://supabase.io/',
}); });
export const tailwindCss = createSkill({ export const tailwindCss = createSkillFactory({
name: 'Tailwind CSS', name: 'Tailwind CSS',
icon: 'simple-icons:tailwindcss', icon: 'simple-icons:tailwindcss',
iconColor: '#06B6D4', iconColor: '#06B6D4',
url: 'https://tailwindcss.com/', url: 'https://tailwindcss.com/',
}); });
export const typescript = createSkill({ export const typescript = createSkillFactory({
name: 'TypeScript', name: 'TypeScript',
icon: 'simple-icons:typescript', icon: 'simple-icons:typescript',
iconColor: '#3178C6', iconColor: '#3178C6',
url: 'https://www.typescriptlang.org/', url: 'https://www.typescriptlang.org/',
}); });
export const vue = createSkill({ export const vue = createSkillFactory({
name: 'Vue.js', name: 'Vue.js',
icon: 'simple-icons:vuedotjs', icon: 'simple-icons:vuedotjs',
iconColor: '#4FC08D', iconColor: '#4FC08D',

View file

@ -1,58 +0,0 @@
import type { EducationSection } from '@/types/education-section';
import type { ExperienceSection } from '@/types/experience-section';
import type { FavoritesSection } from '@/types/favorites-section';
import type { Pdf } from '@/types/pdf';
import type { I18n } from '@/types/i18n';
import type { MainSection } from '@/types/main-section';
import type { PortfolioSection } from '@/types/portfolio-section';
import type { Seo } from '@/types/seo';
import type { SkillsSection } from '@/types/skills-section';
import type { TestimonialsSection } from '@/types/testimonials-section';
import educationData from './sections/education';
import experienceData from './sections/experience';
import favoritesData from './sections/favorites';
import mainData from './sections/main';
import portfolioData from './sections/portfolio';
import skillsData from './sections/skills';
import testimonialsData from './sections/testimonials';
export interface Data {
i18n: I18n;
seo: Seo;
pdf: Pdf;
main: MainSection;
skills?: SkillsSection;
experience?: ExperienceSection;
portfolio?: PortfolioSection;
education?: EducationSection;
testimonials?: TestimonialsSection;
favorites?: FavoritesSection;
}
const data: Data = {
i18n: {
locale: 'en-US',
translations: {
now: 'now',
},
},
seo: {
title: 'Mark Freeman - Senior React Developer',
description:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. In sodales ac dui at vestibulum. In condimentum metus id dui tincidunt, in blandit mi vehicula.',
},
pdf: {
footer:
'I hereby give consent for my personal data included in my application to be processed for the purposes of the recruitment process.',
},
main: mainData,
skills: skillsData,
experience: experienceData,
portfolio: portfolioData,
education: educationData,
testimonials: testimonialsData,
favorites: favoritesData,
};
export default data;

View file

@ -0,0 +1,30 @@
import type { EducationSection } from '@/types/sections/education-section.types';
import type { ReadonlyDeep } from 'type-fest';
import { website } from '../helpers/links';
const educationSectionData = {
config: {
title: 'Education',
slug: 'education',
icon: 'fa6-solid:graduation-cap',
visible: true,
},
diplomas: [
{
title: 'Information Technology',
institution: 'Wrocław University of Science and Technology',
dates: [new Date('2014.10'), new Date('2016.07')],
description: 'Master degree. Specialization in software development.',
links: [website({ url: '#' })],
},
{
title: 'Information Technology',
institution: 'Wrocław University of Science and Technology',
dates: [new Date('2011.10'), new Date('2014.07')],
description: "Bachelor's degree. Specialization in application development.",
links: [website({ url: '#' })],
},
],
} as const satisfies ReadonlyDeep<EducationSection>;
export default educationSectionData;

View file

@ -1,30 +0,0 @@
import type { EducationSection } from '@/types/education-section';
import { website } from '../socials';
const educationData: EducationSection = {
config: {
title: 'Education',
icon: 'fa6-solid:graduation-cap',
},
educationItems: [
{
title: 'Information Technology',
institution: 'Wrocław University of Science and Technology',
startDate: new Date('2014.10'),
endDate: new Date('2016.07'),
description: 'Master degree. Specialization in software development.',
socials: [website('#')],
},
{
title: 'Information Technology',
institution: 'Wrocław University of Science and Technology',
startDate: new Date('2011.10'),
endDate: new Date('2014.07'),
description: "Bachelor's degree. Specialization in application development.",
socials: [website('#')],
},
],
};
export default educationData;

View file

@ -1,5 +1,6 @@
import type { ExperienceSection } from '@/types/experience-section'; import type { ExperienceSection } from '@/types/sections/experience-section.types';
import type { ReadonlyDeep } from 'type-fest';
import { facebook, github, instagram, linkedin, twitter, website } from '../helpers/links';
import { import {
chakraUi, chakraUi,
eslint, eslint,
@ -12,20 +13,20 @@ import {
tailwindCss, tailwindCss,
typescript, typescript,
vue, vue,
} from '../skills'; } from '../helpers/skills';
import { facebook, github, instagram, linkedin, twitter, website } from '../socials';
const experienceData: ExperienceSection = { const experienceSectionData = {
config: { config: {
title: 'Work experience', title: 'Work experience',
slug: 'experience',
icon: 'fa6-solid:suitcase', icon: 'fa6-solid:suitcase',
visible: true,
}, },
jobs: [ jobs: [
{ {
role: 'Senior front-end developer', role: 'Senior front-end developer',
company: 'Google', company: 'Google',
startDate: new Date('2020-02'), dates: [new Date('2020-02'), null],
endDate: null,
description: [ description: [
'In tristique vulputate augue vel egestas.', 'In tristique vulputate augue vel egestas.',
'Quisque ac imperdiet tortor, at lacinia ex.', 'Quisque ac imperdiet tortor, at lacinia ex.',
@ -34,38 +35,45 @@ const experienceData: ExperienceSection = {
'Nunc malesuada leo et est iaculis facilisis.', 'Nunc malesuada leo et est iaculis facilisis.',
'Fusce eu urna ut magna malesuada fringilla.', 'Fusce eu urna ut magna malesuada fringilla.',
], ],
tags: [react(), nextJs(), typescript(), nx(), firebase()], tagsList: {
socials: [facebook('#'), linkedin('#')], title: 'Technologies',
tags: [react(), nextJs(), typescript(), nx(), firebase()],
},
links: [facebook({ url: '#' }), linkedin({ url: '#' })],
}, },
{ {
role: 'React.js developer', role: 'React.js developer',
company: 'Facebook', company: 'Facebook',
startDate: new Date('2019-04'), dates: [new Date('2019-04'), new Date('2020-02')],
endDate: new Date('2020-02'),
description: [ description: [
'Aenean eget ultricies felis. Pellentesque dictum massa ut tellus eleifend, sed posuere massa mattis.', 'Aenean eget ultricies felis. Pellentesque dictum massa ut tellus eleifend, sed posuere massa mattis.',
'Ut posuere massa lacus, eleifend molestie tortor auctor vel.', 'Ut posuere massa lacus, eleifend molestie tortor auctor vel.',
'Sed sed sollicitudin eros, id ultricies mi. Aliquam sodales elit vel ante tempor, non vehicula nibh facilisis.', 'Sed sed sollicitudin eros, id ultricies mi. Aliquam sodales elit vel ante tempor, non vehicula nibh facilisis.',
'Cras feugiat ultricies maximus. Aliquam tristique ex odio, ac semper urna accumsan a.', 'Cras feugiat ultricies maximus. Aliquam tristique ex odio, ac semper urna accumsan a.',
], ],
tags: [react(), reactQuery(), chakraUi(), eslint()], tagsList: {
socials: [website('#'), instagram('#')], title: 'Technologies',
tags: [react(), reactQuery(), chakraUi(), eslint()],
},
links: [website({ url: '#' }), instagram({ url: '#' })],
}, },
{ {
role: 'Junior front-end developer', role: 'Junior front-end developer',
company: 'GitLab', company: 'GitLab',
startDate: new Date('2016-09'), dates: [new Date('2016-09'), new Date('2019-04')],
endDate: new Date('2019-04'),
description: [ description: [
'Nulla volutpat justo ante, rhoncus posuere massa egestas in.', 'Nulla volutpat justo ante, rhoncus posuere massa egestas in.',
'Quisque pellentesque, dolor nec sollicitudin iaculis, sem velit consequat ligula, eget tempus ligula leo et est.', 'Quisque pellentesque, dolor nec sollicitudin iaculis, sem velit consequat ligula, eget tempus ligula leo et est.',
'Maecenas ut elit sit amet nibh maximus condimentum in nec lorem. Pellentesque tincidunt odio vel leo suscipit, in interdum mi gravida.', 'Maecenas ut elit sit amet nibh maximus condimentum in nec lorem. Pellentesque tincidunt odio vel leo suscipit, in interdum mi gravida.',
'Donec non vulputate augue.', 'Donec non vulputate augue.',
], ],
tags: [vue(), tailwindCss(), pnpm()], tagsList: {
socials: [twitter('#'), github('#')], title: 'Technologies',
tags: [vue(), tailwindCss(), pnpm()],
},
links: [twitter({ url: '#' }), github({ url: '#' })],
}, },
], ],
}; } as const satisfies ReadonlyDeep<ExperienceSection>;
export default experienceData; export default experienceSectionData;

View file

@ -1,33 +1,36 @@
import type { FavoritesSection } from '@/types/favorites-section'; import type { FavoritesSection } from '@/types/sections/favorites-section.types';
import type { ReadonlyDeep } from 'type-fest';
const favoritesData: FavoritesSection = { const favoritesSectionData = {
config: { config: {
title: 'My favorites', title: 'My favorites',
slug: 'favorites',
icon: 'fa6-solid:star', icon: 'fa6-solid:star',
visible: true,
}, },
books: { books: {
title: 'Books I read', title: 'Books I read',
data: [ data: [
{ {
cover: import('@/assets/favorites/books/book-1.jpeg'), image: import('@/assets/favorites/books/book-1.jpeg'),
title: 'The Pragmatic Programmer: From Journeyman to Master', title: 'The Pragmatic Programmer: From Journeyman to Master',
author: 'Andy Hunt, Dave Thomas', author: 'Andy Hunt, Dave Thomas',
url: 'https://www.goodreads.com/book/show/4099.The_Pragmatic_Programmer', url: 'https://www.goodreads.com/book/show/4099.The_Pragmatic_Programmer',
}, },
{ {
cover: 'https://m.media-amazon.com/images/I/61aFldsgAmL._SY344_BO1,204,203,200_QL70_ML2_.jpg', image: 'https://m.media-amazon.com/images/I/61aFldsgAmL._SY344_BO1,204,203,200_QL70_ML2_.jpg',
title: 'Domain-Driven Design: Tackling Complexity in the Heart of Software', title: 'Domain-Driven Design: Tackling Complexity in the Heart of Software',
author: 'Eric Evans', author: 'Eric Evans',
url: 'https://www.goodreads.com/book/show/179133.Domain_Driven_Design', url: 'https://www.goodreads.com/book/show/179133.Domain_Driven_Design',
}, },
{ {
cover: import('@/assets/favorites/books/book-3.jpeg'), image: import('@/assets/favorites/books/book-3.jpeg'),
title: 'Clean Code: A Handbook of Agile Software Craftsmanship', title: 'Clean Code: A Handbook of Agile Software Craftsmanship',
author: 'Robert C. Martin', author: 'Robert C. Martin',
url: 'https://www.goodreads.com/book/show/3735293-clean-code', url: 'https://www.goodreads.com/book/show/3735293-clean-code',
}, },
{ {
cover: import('@/assets/favorites/books/book-4.jpeg'), image: import('@/assets/favorites/books/book-4.jpeg'),
title: 'The Clean Coder: A Code of Conduct for Professional Programmers', title: 'The Clean Coder: A Code of Conduct for Professional Programmers',
author: 'Robert C. Martin', author: 'Robert C. Martin',
url: 'https://www.goodreads.com/book/show/10284614-the-clean-coder', url: 'https://www.goodreads.com/book/show/10284614-the-clean-coder',
@ -73,17 +76,17 @@ const favoritesData: FavoritesSection = {
title: 'Videos I watched', title: 'Videos I watched',
data: [ data: [
{ {
thumbnail: import('@/assets/favorites/videos/video-1.jpeg'), image: import('@/assets/favorites/videos/video-1.jpeg'),
title: 'Building Resilient Frontend Architecture • Monica Lent • GOTO 2019', title: 'Building Resilient Frontend Architecture • Monica Lent • GOTO 2019',
url: 'https://youtu.be/TqfbAXCCVwE', url: 'https://youtu.be/TqfbAXCCVwE',
}, },
{ {
thumbnail: import('@/assets/favorites/videos/video-2.jpeg'), image: import('@/assets/favorites/videos/video-2.jpeg'),
title: 'Scaling Yourself • Scott Hanselman • GOTO 2012', title: 'Scaling Yourself • Scott Hanselman • GOTO 2012',
url: 'https://youtu.be/FS1mnISoG7U', url: 'https://youtu.be/FS1mnISoG7U',
}, },
{ {
thumbnail: import('@/assets/favorites/videos/video-3.jpeg'), image: import('@/assets/favorites/videos/video-3.jpeg'),
title: "Why Isn't Functional Programming the Norm? - Richard Feldman", title: "Why Isn't Functional Programming the Norm? - Richard Feldman",
url: 'https://youtu.be/QyJZzq0v7Z4', url: 'https://youtu.be/QyJZzq0v7Z4',
}, },
@ -130,6 +133,6 @@ const favoritesData: FavoritesSection = {
}, },
], ],
}, },
}; } as const satisfies ReadonlyDeep<FavoritesSection>;
export default favoritesData; export default favoritesSectionData;

View file

@ -0,0 +1,21 @@
import type { Sections } from '@/types/data';
import type { ReadonlyDeep } from 'type-fest';
import educationData from './education-section.data';
import experienceData from './experience-section.data';
import favoritesData from './favorites-section.data';
import mainData from './main-section.data';
import portfolioData from './portfolio-section.data';
import skillsData from './skills-section.data';
import testimonialsData from './testimonials-section.data';
export const sections = {
main: mainData,
skills: skillsData,
experience: experienceData,
portfolio: portfolioData,
education: educationData,
testimonials: testimonialsData,
favorites: favoritesData,
} as const satisfies ReadonlyDeep<Sections>;
export default sections;

View file

@ -1,11 +1,13 @@
import type { MainSection } from '@/types/main-section'; import type { MainSection } from '@/types/sections/main-section.types';
import type { ReadonlyDeep } from 'type-fest';
import { facebook, github, linkedin, twitter } from '../helpers/links';
import { facebook, github, linkedin, twitter } from '../socials'; const mainSectionData = {
const mainData: MainSection = {
config: { config: {
icon: 'fa6-solid:user', icon: 'fa6-solid:user',
title: 'Profile', title: 'Profile',
slug: 'profile',
visible: true,
}, },
image: import('@/assets/my-image.jpeg'), image: import('@/assets/my-image.jpeg'),
fullName: 'Mark Freeman', fullName: 'Mark Freeman',
@ -29,8 +31,9 @@ const mainData: MainSection = {
action: { action: {
label: 'Download CV', label: 'Download CV',
url: '/cv.pdf', url: '/cv.pdf',
downloadedFileName: 'CV-Mark_Freeman.pdf',
}, },
socials: [facebook('#'), github('#'), linkedin('#'), twitter('#')], links: [facebook({ url: '#' }), github({ url: '#' }), linkedin({ url: '#' }), twitter({ url: '#' })],
}; } as const satisfies ReadonlyDeep<MainSection>;
export default mainData; export default mainSectionData;

View file

@ -1,5 +1,6 @@
import type { PortfolioSection } from '@/types/portfolio-section'; import type { PortfolioSection } from '@/types/sections/portfolio-section.types';
import type { ReadonlyDeep } from 'type-fest';
import { demo, github, mockups, website } from '../helpers/links';
import { import {
chakraUi, chakraUi,
eslint, eslint,
@ -15,20 +16,20 @@ import {
sass, sass,
tailwindCss, tailwindCss,
typescript, typescript,
} from '../skills'; } from '../helpers/skills';
import { demo, github, mockups, website } from '../socials';
const portfolioData: PortfolioSection = { const portfolioSectionData = {
config: { config: {
title: 'Projects', title: 'Projects',
slug: 'projects',
icon: 'fa6-solid:rocket', icon: 'fa6-solid:rocket',
visible: true,
}, },
projects: [ projects: [
{ {
name: 'Golden Bulls', name: 'Golden Bulls',
image: import('@/assets/portfolio/project-1.jpeg'), image: import('@/assets/portfolio/project-1.jpeg'),
startDate: new Date('2020-03'), dates: [new Date('2020-03'), null],
endDate: null,
details: [ details: [
{ label: 'Team size', value: '1 person' }, { label: 'Team size', value: '1 person' },
{ label: 'My role', value: ['Front-end Developer', 'Designer'] }, { label: 'My role', value: ['Front-end Developer', 'Designer'] },
@ -41,14 +42,16 @@ const portfolioData: PortfolioSection = {
], ],
description: description:
'In tristique vulputate augue vel egestas. Quisque ac imperdiet tortor, at lacinia ex. Duis vel ex hendrerit, commodo odio sed, aliquam enim. Ut arcu nulla, tincidunt eget arcu eget, molestie vulputate nisi. Nunc malesuada leo et est iaculis facilisis.', 'In tristique vulputate augue vel egestas. Quisque ac imperdiet tortor, at lacinia ex. Duis vel ex hendrerit, commodo odio sed, aliquam enim. Ut arcu nulla, tincidunt eget arcu eget, molestie vulputate nisi. Nunc malesuada leo et est iaculis facilisis.',
tags: [nextJs(), sass(), pnpm(), eslint(), prettier()], tagsList: {
socials: [mockups('#'), demo('#')], title: 'Technologies',
tags: [nextJs(), sass(), pnpm(), eslint(), prettier()],
},
links: [mockups({ url: '#' }), demo({ url: '#' })],
}, },
{ {
name: 'TruQuest', name: 'TruQuest',
image: import('@/assets/portfolio/project-2.jpeg'), image: import('@/assets/portfolio/project-2.jpeg'),
startDate: new Date('2019-06'), dates: [new Date('2019-06'), new Date('2020-02')],
endDate: new Date('2020-02'),
details: [ details: [
{ label: 'Team size', value: '7 people' }, { label: 'Team size', value: '7 people' },
{ label: 'My role', value: ['Front-end Developer', 'Mobile Developer', 'Designer'] }, { label: 'My role', value: ['Front-end Developer', 'Mobile Developer', 'Designer'] },
@ -61,14 +64,16 @@ const portfolioData: PortfolioSection = {
], ],
description: description:
'Ut ultricies tortor at sodales aliquam. Vivamus metus ante, fringilla nec ligula in, suscipit rhoncus mauris. Praesent hendrerit velit odio, at accumsan urna faucibus convallis. Nunc at massa eget ligula volutpat dictum a sit amet libero. Vestibulum iaculis molestie maximus. In hac habitasse platea dictumst.', 'Ut ultricies tortor at sodales aliquam. Vivamus metus ante, fringilla nec ligula in, suscipit rhoncus mauris. Praesent hendrerit velit odio, at accumsan urna faucibus convallis. Nunc at massa eget ligula volutpat dictum a sit amet libero. Vestibulum iaculis molestie maximus. In hac habitasse platea dictumst.',
tags: [react(), tailwindCss(), nestJs(), postgreSql()], tagsList: {
socials: [mockups('#'), demo('#')], title: 'Technologies',
tags: [react(), tailwindCss(), nestJs(), postgreSql()],
},
links: [mockups({ url: '#' }), demo({ url: '#' })],
}, },
{ {
name: 'Software Chasers', name: 'Software Chasers',
image: import('@/assets/portfolio/project-3.jpeg'), image: import('@/assets/portfolio/project-3.jpeg'),
startDate: new Date('2018-01'), dates: [new Date('2018-01'), new Date('2020-12')],
endDate: new Date('2020-12'),
details: [ details: [
{ label: 'Team size', value: '3 people' }, { label: 'Team size', value: '3 people' },
{ label: 'My role', value: ['Front-end Developer', 'Designer'] }, { label: 'My role', value: ['Front-end Developer', 'Designer'] },
@ -81,14 +86,16 @@ const portfolioData: PortfolioSection = {
], ],
description: description:
'Quisque id consectetur eros. In hac habitasse platea dictumst. Sed eu pulvinar orci. Mauris consequat, est in dignissim varius, neque nisl commodo mauris, id blandit risus justo eu nulla.', 'Quisque id consectetur eros. In hac habitasse platea dictumst. Sed eu pulvinar orci. Mauris consequat, est in dignissim varius, neque nisl commodo mauris, id blandit risus justo eu nulla.',
tags: [react(), chakraUi(), typescript(), nx(), pnpm()], tagsList: {
socials: [website('#'), github('#')], title: 'Technologies',
tags: [react(), chakraUi(), typescript(), nx(), pnpm()],
},
links: [website({ url: '#' }), github({ url: '#' })],
}, },
{ {
name: 'Disco Ninjas', name: 'Disco Ninjas',
image: import('@/assets/portfolio/project-4.jpeg'), image: import('@/assets/portfolio/project-4.jpeg'),
startDate: new Date('2016-05'), dates: [new Date('2016-05'), new Date('2018-07')],
endDate: new Date('2018-07'),
details: [ details: [
{ label: 'Team size', value: '11 people' }, { label: 'Team size', value: '11 people' },
{ label: 'My role', value: 'Front-end Developer' }, { label: 'My role', value: 'Front-end Developer' },
@ -101,10 +108,13 @@ const portfolioData: PortfolioSection = {
], ],
description: description:
'Praesent eu neque tortor. Vestibulum ac magna nisl. Vivamus massa sem, feugiat in pharetra non, convallis egestas purus. Ut consequat ullamcorper sem, in euismod nibh posuere ut. ', 'Praesent eu neque tortor. Vestibulum ac magna nisl. Vivamus massa sem, feugiat in pharetra non, convallis egestas purus. Ut consequat ullamcorper sem, in euismod nibh posuere ut. ',
tags: [typescript(), jest(), firebase()], tagsList: {
socials: [mockups('#'), github('#')], title: 'Technologies',
tags: [typescript(), jest(), firebase()],
},
links: [mockups({ url: '#' }), github({ url: '#' })],
}, },
], ],
}; } as const satisfies ReadonlyDeep<PortfolioSection>;
export default portfolioData; export default portfolioSectionData;

View file

@ -1,5 +1,5 @@
import type { SkillsSection } from '@/types/skills-section'; import type { SkillsSection } from '@/types/sections/skills-section.types';
import type { ReadonlyDeep } from 'type-fest';
import { import {
apolloGraphql, apolloGraphql,
astro, astro,
@ -17,17 +17,18 @@ import {
supabase, supabase,
tailwindCss, tailwindCss,
typescript, typescript,
} from '../skills'; } from '../helpers/skills';
const skillsData: SkillsSection = { const skillsSectionData = {
config: { config: {
title: 'Skills', title: 'Skills',
slug: 'skills',
icon: 'fa6-solid:bars-progress', icon: 'fa6-solid:bars-progress',
visible: true,
}, },
skillSets: [ skillSets: [
{ {
title: 'I already know', title: 'I already know',
pdfTitle: 'Technologies',
skills: [ skills: [
react({ react({
level: 5, level: 5,
@ -63,12 +64,10 @@ const skillsData: SkillsSection = {
}, },
{ {
title: 'I want to learn', title: 'I want to learn',
excludeFromPdf: true,
skills: [apolloGraphql(), astro(), supabase(), cypress()], skills: [apolloGraphql(), astro(), supabase(), cypress()],
}, },
{ {
title: 'I speak', title: 'I speak',
pdfTitle: 'Languages',
skills: [ skills: [
{ icon: 'circle-flags:pl', name: 'Polish - native' }, { icon: 'circle-flags:pl', name: 'Polish - native' },
{ icon: 'circle-flags:us', name: 'English - C1' }, { icon: 'circle-flags:us', name: 'English - C1' },
@ -76,6 +75,6 @@ const skillsData: SkillsSection = {
], ],
}, },
], ],
}; } as const satisfies ReadonlyDeep<SkillsSection>;
export default skillsData; export default skillsSectionData;

View file

@ -1,11 +1,13 @@
import type { TestimonialsSection } from '@/types/testimonials-section'; import type { TestimonialsSection } from '@/types/sections/testimonials-section.types';
import type { ReadonlyDeep } from 'type-fest';
import { github, linkedin, website } from '../helpers/links';
import { github, linkedin, website } from '../socials'; const testimonialsSectionData = {
const testimonialsData: TestimonialsSection = {
config: { config: {
title: 'Testimonials', title: 'Testimonials',
slug: 'testimonials',
icon: 'fa6-solid:comment', icon: 'fa6-solid:comment',
visible: true,
}, },
testimonials: [ testimonials: [
{ {
@ -14,7 +16,7 @@ const testimonialsData: TestimonialsSection = {
relation: 'We work together as front-end developers at Google', relation: 'We work together as front-end developers at Google',
content: content:
'In nec mattis sem. Morbi purus lorem, euismod ac varius at, aliquet vitae augue. Pellentesque ut facilisis felis. In sed dui blandit, aliquet odio eu, elementum leo. In facilisis dapibus tortor ac volutpat. Cras cursus nec odio maximus elementum.', 'In nec mattis sem. Morbi purus lorem, euismod ac varius at, aliquet vitae augue. Pellentesque ut facilisis felis. In sed dui blandit, aliquet odio eu, elementum leo. In facilisis dapibus tortor ac volutpat. Cras cursus nec odio maximus elementum.',
socials: [github('#'), linkedin('#')], links: [github({ url: '#' }), linkedin({ url: '#' })],
}, },
{ {
image: import('@/assets/testimonials/testimonial-2.jpeg'), image: import('@/assets/testimonials/testimonial-2.jpeg'),
@ -22,7 +24,7 @@ const testimonialsData: TestimonialsSection = {
relation: 'My project manager at GitLab', relation: 'My project manager at GitLab',
content: content:
'Praesent nec congue elit. Vestibulum lobortis congue ipsum, a gravida mi tempus ac. Mauris aliquet purus nibh, vel varius turpis tempus non. Nullam eget ultricies orci. Quisque nulla ante, auctor eget varius ac, imperdiet nec magna.', 'Praesent nec congue elit. Vestibulum lobortis congue ipsum, a gravida mi tempus ac. Mauris aliquet purus nibh, vel varius turpis tempus non. Nullam eget ultricies orci. Quisque nulla ante, auctor eget varius ac, imperdiet nec magna.',
socials: [linkedin('#')], links: [linkedin({ url: '#' })],
}, },
{ {
image: import('@/assets/testimonials/testimonial-3.jpeg'), image: import('@/assets/testimonials/testimonial-3.jpeg'),
@ -30,9 +32,9 @@ const testimonialsData: TestimonialsSection = {
relation: 'My customer for sidewing.com website', relation: 'My customer for sidewing.com website',
content: content:
'Mauris tincidunt at purus vehicula porta. Mauris eget mollis turpis. Sed iaculis rutrum pharetra. Vivamus risus quam, suscipit et semper ut, aliquet ut tellus. Donec quis auctor nunc.', 'Mauris tincidunt at purus vehicula porta. Mauris eget mollis turpis. Sed iaculis rutrum pharetra. Vivamus risus quam, suscipit et semper ut, aliquet ut tellus. Donec quis auctor nunc.',
socials: [github('#'), website('#')], links: [github({ url: '#' }), website({ url: '#' })],
}, },
], ],
}; } as const satisfies ReadonlyDeep<TestimonialsSection>;
export default testimonialsData; export default testimonialsSectionData;

View file

@ -1,200 +0,0 @@
import type { Social } from '../types/common';
type SocialWithoutUrl = Omit<Social, 'url'>;
// GENERAL
export const facebook = (url: string, override?: Partial<SocialWithoutUrl>): Social => ({
name: 'Facebook',
icon: 'fa6-brands:facebook-f',
url,
...override,
});
export const linkedin = (url: string, override?: Partial<SocialWithoutUrl>): Social => ({
name: 'LinkedIn',
icon: 'fa6-brands:linkedin-in',
url,
...override,
});
export const twitter = (url: string, override?: Partial<SocialWithoutUrl>): Social => ({
name: 'Twitter',
icon: 'fa6-brands:twitter',
url,
...override,
});
export const pinterest = (url: string, override?: Partial<SocialWithoutUrl>): Social => ({
name: 'Pinterest',
icon: 'fa6-brands:pinterest',
url,
...override,
});
// CODE
export const github = (url: string, override?: Partial<SocialWithoutUrl>): Social => ({
name: 'GitHub',
icon: 'fa6-brands:github',
url,
...override,
});
export const codepen = (url: string, override?: Partial<SocialWithoutUrl>): Social => ({
name: 'CodePen',
icon: 'fa6-brands:codepen',
url,
...override,
});
export const stackblitz = (url: string, override?: Partial<SocialWithoutUrl>): Social => ({
name: 'StackBlitz',
icon: 'simple-icons:stackblitz',
url,
...override,
});
export const codesandbox = (url: string, override?: Partial<SocialWithoutUrl>): Social => ({
name: 'CodeSandbox',
icon: 'simple-icons:codesandbox',
url,
...override,
});
// BLOG
export const dev = (url: string, override?: Partial<SocialWithoutUrl>): Social => ({
name: 'Dev',
icon: 'fa6-brands:dev',
url,
...override,
});
export const medium = (url: string, override?: Partial<SocialWithoutUrl>): Social => ({
name: 'Medium',
icon: 'fa6-brands:medium',
url,
...override,
});
// FORUM / CHAT
export const reddit = (url: string, override?: Partial<SocialWithoutUrl>): Social => ({
name: 'Reddit',
icon: 'fa6-brands:reddit',
url,
...override,
});
export const quora = (url: string, override?: Partial<SocialWithoutUrl>): Social => ({
name: 'Quora',
icon: 'fa6-brands:quora',
url,
...override,
});
export const stackoverflow = (url: string, override?: Partial<SocialWithoutUrl>): Social => ({
name: 'Stack Overflow',
icon: 'fa6-brands:stack-overflow',
url,
...override,
});
// DESIGN
export const instagram = (url: string, override?: Partial<SocialWithoutUrl>): Social => ({
name: 'Instagram',
icon: 'fa6-brands:instagram',
url,
...override,
});
export const behance = (url: string, override?: Partial<SocialWithoutUrl>): Social => ({
name: 'Behance',
icon: 'fa6-brands:behance',
url,
...override,
});
export const dribbble = (url: string, override?: Partial<SocialWithoutUrl>): Social => ({
name: 'Dribbble',
icon: 'fa6-brands:dribbble',
url,
...override,
});
export const figma = (url: string, override?: Partial<SocialWithoutUrl>): Social => ({
name: 'Figma',
icon: 'fa6-brands:figma',
url,
...override,
});
// MUSIC
export const spotify = (url: string, override?: Partial<SocialWithoutUrl>): Social => ({
name: 'Spotify',
icon: 'fa6-brands:spotify',
url,
...override,
});
export const soundcloud = (url: string, override?: Partial<SocialWithoutUrl>): Social => ({
name: 'SoundCloud',
icon: 'fa6-brands:soundcloud',
url,
...override,
});
// VIDEO
export const youtube = (url: string, override?: Partial<SocialWithoutUrl>): Social => ({
name: 'YouTube',
icon: 'fa6-brands:youtube',
url,
...override,
});
export const twitch = (url: string, override?: Partial<SocialWithoutUrl>): Social => ({
name: 'Twitch',
icon: 'fa6-brands:twitch',
url,
...override,
});
export const vimeo = (url: string, override?: Partial<SocialWithoutUrl>): Social => ({
name: 'Vimeo',
icon: 'fa6-brands:vimeo',
url,
...override,
});
// PROJECT TYPE
export const website = (url: string, override?: Partial<SocialWithoutUrl>): Social => ({
name: 'Website',
icon: 'fa6-solid:globe',
url,
...override,
});
export const demo = (url: string, override?: Partial<SocialWithoutUrl>): Social => ({
name: 'App demo',
icon: 'fa6-solid:desktop',
url,
...override,
});
export const mockups = (url: string, override?: Partial<SocialWithoutUrl>): Social => ({
name: 'Mockups',
icon: 'fa6-solid:image',
url,
...override,
});
export const repository = (url: string, override?: Partial<SocialWithoutUrl>): Social => ({
name: 'Repository',
icon: 'fa6-solid:code-branch',
url,
...override,
});

View file

@ -0,0 +1 @@
export * from './transformers';

View file

@ -0,0 +1,14 @@
import type { Data } from '@/types/data';
import produce from 'immer';
import type { PreciseData } from '../cv';
import type { DataTransformer } from './transformers';
const transformData =
(data: PreciseData) =>
(...callbacks: DataTransformer[]): Data =>
// @ts-ignore -- waiting for https://github.com/sindresorhus/type-fest/pull/540 to be merged
produce(data, (draft) => {
callbacks.forEach((callback) => callback(draft));
});
export default transformData;

View file

@ -0,0 +1,92 @@
import type { Data } from '@/types/data';
import type { Draft } from 'immer';
import type { PreciseData } from '../cv';
export type DraftData = Draft<Data>;
export type DataTransformer = (draft: DraftData) => void;
type Sections = PreciseData['sections'];
type SectionKey = keyof Sections;
type ProjectName = Sections['portfolio']['projects'][number]['name'];
type JobRole = Sections['experience']['jobs'][number]['role'];
type JobCompany = Sections['experience']['jobs'][number]['company'];
type DiplomaTitle = Sections['education']['diplomas'][number]['title'];
type DiplomaInstitution = Sections['education']['diplomas'][number]['institution'];
type SkillSets = Sections['skills']['skillSets'];
type SkillSetTitle = SkillSets[number]['title'];
type Filter<T extends Readonly<unknown[]>, P> = T extends Readonly<[infer A, ...infer Rest]>
? [...(A extends P ? [A] : []), ...Filter<Rest, P>]
: [];
type SkillsBySkillSet<SkillSet extends SkillSetTitle> = Filter<
SkillSets,
{ title: SkillSet }
>[number]['skills'][number]['name'];
export const hideSection =
(section: SectionKey): DataTransformer =>
(draft) => {
draft.sections[section].config.visible = false;
};
export const hideJob =
(role: JobRole, company?: JobCompany): DataTransformer =>
(draft) => {
draft.sections.experience.jobs = draft.sections.experience.jobs.filter(
(job) => job.role !== role && job.company !== company
);
};
export const hideDiploma =
(title: DiplomaTitle, institution?: DiplomaInstitution): DataTransformer =>
(draft) => {
draft.sections.education.diplomas = draft.sections.education.diplomas.filter(
(diploma) => diploma.title === title && diploma.institution === institution
);
};
export const hideProject =
(name: ProjectName): DataTransformer =>
(draft) => {
draft.sections.portfolio.projects = draft.sections.portfolio.projects.filter((project) => project.name !== name);
};
export const hideSkillSet =
(name: SkillSetTitle): DataTransformer =>
(draft) => {
draft.sections.skills.skillSets = draft.sections.skills.skillSets.filter((skillSet) => skillSet.title !== name);
};
export const renameSkillSet =
(from: SkillSetTitle, to: string): DataTransformer =>
(draft) => {
draft.sections.skills.skillSets = draft.sections.skills.skillSets.map((skillSet) =>
skillSet.title === from ? { ...skillSet, title: to } : skillSet
);
};
export const hideSkills =
<SkillSet extends SkillSetTitle>(
skillSetTitle: SkillSetTitle,
skills: SkillsBySkillSet<SkillSet>[]
): DataTransformer =>
(draft) => {
draft.sections.skills.skillSets = draft.sections.skills.skillSets.map((skillSet) => {
if (skillSet.title !== skillSetTitle) return skillSet;
return {
...skillSet,
skills: skillSet.skills.filter((skill) => !skills.includes(skill.name as (typeof skills)[number])),
};
});
};

View file

@ -1,7 +0,0 @@
---
import Button from '@/components/button.astro';
---
<div class="p-5">
<Button href="#">Button text</Button>
</div>

View file

@ -1,51 +0,0 @@
---
import Typography from '@/components/typography.astro';
import BookTile from '@/sections/favorites/book-tile.astro';
import MediaTile from '@/sections/favorites/media-tile.astro';
import PersonTile from '@/sections/favorites/person-tile.astro';
import VideoTile from '@/sections/favorites/video-tile.astro';
import type { Book, Media, Person, Video } from '@/types/favorites-section';
const book: Book = {
cover: import('@/assets/favorites/books/book-1.jpeg'),
title: 'The Pragmatic Programmer: From Journeyman to Master',
author: 'Andy Hunt, Dave Thomas',
url: 'https://www.goodreads.com/book/show/4099.The_Pragmatic_Programmer',
};
const person: Person = {
image: import('@/assets/favorites/people/person-1.jpg'),
name: 'Kent C. Dodds',
url: 'https://kentcdodds.com/',
};
const video: Video = {
thumbnail: import('@/assets/favorites/videos/video-1.jpeg'),
title: 'Building Resilient Frontend Architecture - Monica Lent - GOTO 2019',
url: 'https://youtu.be/TqfbAXCCVwE',
};
const media: Media = {
image: import('@/assets/favorites/media/media-1.jpeg'),
title: 'Fireship.io',
type: 'YouTube channel',
url: 'https://www.youtube.com/c/Fireship',
};
---
<Typography class="m-2 font-mono" variant="item-title">Favourite Book</Typography>
<div class="m-4 grid grid-cols-8">
<BookTile value={book} />
</div>
<Typography class="m-2 font-mono" variant="item-title">Favourite Person</Typography>
<div class="m-4 grid grid-cols-12">
<PersonTile value={person} />
</div>
<Typography class="m-2 font-mono" variant="item-title">Favourite Video</Typography>
<div class="m-4 grid grid-cols-6">
<VideoTile value={video} />
</div>
<Typography class="m-2 font-mono" variant="item-title">Favourite Media</Typography>
<div class="m-4 grid grid-cols-12">
<MediaTile value={media} />
</div>

View file

@ -1,10 +0,0 @@
---
import IconButton from '@/components/icon-button.astro';
---
<div class="flex flex-col gap-2 p-5">
<IconButton icon="fa6-brands:facebook-f" size="small" href="#" />
<IconButton icon="fa6-brands:github" size="small" href="#" />
<IconButton icon="fa6-brands:linkedin-in" size="large" href="#" />
<IconButton icon="fa6-brands:twitter" size="large" href="#" />
</div>

View file

@ -1,9 +0,0 @@
---
import Icon from '@/components/icon.astro';
---
<div class="p-5">
<!-- Available icon names here: https://icon-sets.iconify.design -->
<!-- Colors for simple icons here: https://simpleicons.org -->
<Icon name="simple-icons:react" size={24} color="#61DAFB" />
</div>

View file

@ -1,7 +0,0 @@
---
import { Image } from '@astrojs/image/components';
---
<div class="p-5">
<Image src={import('@/assets/my-image.jpeg')} alt="My image" width={120} height={120} format="webp" />
</div>

View file

@ -1,7 +0,0 @@
---
import LabelledValue from '@/components/labelled-value.astro';
---
<div class="p-5">
<LabelledValue label="Label" value="value" />
</div>

View file

@ -1,41 +0,0 @@
---
import MainSection from '@/sections/main/main-section.astro';
import type { MainSection as MainSectionData } from '@/types/main-section';
const mainSectionData: MainSectionData = {
config: {
icon: 'fa6-solid:user',
title: 'About me',
},
image: import('@/assets/my-image.jpeg'),
fullName: 'Mark Freeman',
role: 'Senior React Developer',
details: [
{ label: 'Phone', value: '+48 604 343 212' },
{ label: 'Email', value: 'veeeery.long.email.address@gmail.com' },
{ label: 'From', value: 'Warsaw, Poland' },
{ label: 'Salary range', value: '18 000 - 25 000 PLN' },
],
description:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. In sodales ac dui at vestibulum. In condimentum metus id dui tincidunt, in blandit mi vehicula. Nulla lacinia, erat sit amet elementum vulputate, lectus mauris volutpat mi, vitae accumsan metus elit ut nunc. Vestibulum lacinia enim eget eros fermentum scelerisque. Proin augue leo, posuere ut imperdiet vitae, fermentum eu ipsum. Sed sed neque sagittis, posuere urna nec, commodo leo. Pellentesque posuere justo vitae massa volutpat maximus.',
tags: [{ name: 'Open for freelance' }, { name: 'Available for mentoring' }, { name: 'Working on side project' }],
action: {
label: 'Download CV',
url: '#',
},
socials: [
{ name: 'Facebook', icon: 'fa6-brands:facebook-f', url: '#' },
{ name: 'GitHub', icon: 'fa6-brands:github', url: '#' },
{ name: 'LinkedIn', icon: 'fa6-brands:linkedin-in', url: '#' },
{ name: 'Twitter', icon: 'fa6-brands:twitter', url: '#' },
],
};
---
<body class="flex justify-center bg-gray-50">
<div class="flex w-full max-w-6xl gap-8 px-2 py-3 sm:px-8 sm:py-12 lg:ml-22 lg:px-16 lg:py-20">
<main class="w-full space-y-4 sm:space-y-6 lg:space-y-8">
<MainSection {...mainSectionData} />
</main>
</div>
</body>

View file

@ -1,67 +0,0 @@
---
import ProjectTimelineItem from '@/sections/portfolio/project-timeline-item.astro';
import type { I18n } from '@/types/i18n';
import type { Project } from '@/types/portfolio-section';
const project: Project = {
name: 'Golden Bulls',
image: import('@/assets/portfolio/project-1.jpeg'),
startDate: new Date('2020-03'),
endDate: null,
details: [
{ label: 'Team size', value: '1 person' },
{ label: 'Company', value: 'None' },
{ label: 'My role', value: ['Front-end Developer', 'Designer'] },
{ label: 'Category', value: ['Web app', 'Open source'] },
],
description:
'In tristique vulputate augue vel egestas. Quisque ac imperdiet tortor, at lacinia ex. Duis vel ex hendrerit, commodo odio sed, aliquam enim. Ut arcu nulla, tincidunt eget arcu eget, molestie vulputate nisi. Nunc malesuada leo et est iaculis facilisis.',
tags: [
{
icon: 'simple-icons:nextdotjs',
iconColor: '#000000',
name: 'Next.js',
url: 'https://nextjs.org/',
},
{
icon: 'simple-icons:sass',
iconColor: '#CC6699',
name: 'SASS',
url: 'https://sass-lang.com/',
},
{
icon: 'simple-icons:pnpm',
iconColor: '#F69220',
name: 'pnpm',
url: 'https://pnpm.io/',
},
{
icon: 'simple-icons:eslint',
iconColor: '#4B32C3',
name: 'ESLint',
url: 'https://eslint.org/',
},
{
icon: 'simple-icons:prettier',
iconColor: '#F7B93E',
name: 'Prettier',
url: 'https://prettier.io/',
},
],
socials: [
{ name: 'Mockups', icon: 'fa6-solid:image', url: '#' },
{ name: 'App demo', icon: 'fa6-solid:desktop', url: '#' },
],
};
const i18nData: I18n = {
locale: 'en-US',
translations: {
now: 'now',
},
};
---
<div class="p-5">
<ProjectTimelineItem project={project} i18n={i18nData} />
</div>

View file

@ -1,7 +0,0 @@
---
import SectionCard from '@/components/section-card.astro';
---
<div class="p-5">
<SectionCard section="main">SectionCard text</SectionCard>
</div>

View file

@ -1,7 +0,0 @@
---
import SidebarItem from '@/components/sidebar-item.astro';
---
<div class="p-5">
<SidebarItem icon="fa6-solid:bars-progress" section="experience" />
</div>

View file

@ -1,14 +0,0 @@
---
import Skill from '@/sections/skills/skill.astro';
import type { LevelledSkill } from '@/types/skills-section';
const levelledSkill: LevelledSkill = {
icon: 'simple-icons:react',
iconColor: '#61DAFB',
name: 'React.js',
level: 3,
url: 'https://reactjs.org/',
};
---
<Skill {...levelledSkill} />

View file

@ -1,160 +0,0 @@
---
import SkillsSection from '@/sections/skills/skills-section.astro';
import type { SkillsSection as SkillsSectionData } from '@/types/skills-section';
const skills: SkillsSectionData = {
config: {
title: 'Skills',
icon: 'fa6-solid:bars-progress',
},
skillSets: [
{
title: 'I already know',
skills: [
{
icon: 'simple-icons:react',
iconColor: '#61DAFB',
name: 'React.js',
level: 5,
url: 'https://reactjs.org/',
description:
'Proin ut erat sed massa tempus suscipit. Mauris efficitur nunc sem, nec scelerisque ligula bibendum ut.',
},
{
icon: 'simple-icons:typescript',
iconColor: '#3178C6',
name: 'TypeScript',
level: 4,
url: 'https://www.typescriptlang.org/',
description: 'Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas.',
},
{
icon: 'simple-icons:sass',
iconColor: '#CC6699',
name: 'SASS',
level: 4,
url: 'https://sass-lang.com/',
description: 'Nulla interdum pellentesque ultricies. Ut id eros commodo, ultrices ligula eu, elementum ante.',
},
{
icon: 'simple-icons:chakraui',
iconColor: '#319795',
name: 'Chakra UI',
level: 5,
url: 'https://chakra-ui.com/',
},
{
icon: 'simple-icons:tailwindcss',
iconColor: '#06B6D4',
name: 'Tailwind CSS',
level: 2,
url: 'https://tailwindcss.com/',
},
{
icon: 'simple-icons:prettier',
iconColor: '#F7B93E',
name: 'Prettier',
level: 5,
url: 'https://prettier.io/',
},
{
icon: 'simple-icons:eslint',
iconColor: '#4B32C3',
name: 'ESLint',
level: 4,
url: 'https://eslint.org/',
description:
'Nulla tempor turpis at vehicula pharetra. Vestibulum tellus tortor, commodo et suscipit id, lobortis id nunc.',
},
{
icon: 'simple-icons:nestjs',
iconColor: '#E0234E',
name: 'NestJS',
level: 2,
url: 'https://nestjs.com/',
description:
'Praesent feugiat ultricies iaculis. In posuere vehicula odio, sed consequat velit porta viverra.',
},
{
icon: 'simple-icons:postgresql',
iconColor: '#4169E1',
name: 'PostgreSQL',
level: 2,
url: 'https://www.postgresql.org/',
},
{
icon: 'simple-icons:mongodb',
iconColor: '#47A248',
name: 'MongoDB',
level: 1,
url: 'https://www.mongodb.com/',
},
{
icon: 'simple-icons:firebase',
iconColor: '#FFCA28',
name: 'Firebase',
level: 1,
url: 'https://firebase.google.com/',
},
{
icon: 'simple-icons:pnpm',
iconColor: '#F69220',
name: 'pnpm',
level: 3,
url: 'https://pnpm.io/',
},
],
},
{
title: 'I want to learn',
skills: [
{
icon: 'simple-icons:apollographql',
iconColor: '#311C87',
name: 'Apollo GraphQL',
},
{
icon: 'simple-icons:astro',
iconColor: '#FF5D01',
name: 'Astro',
},
{
icon: 'simple-icons:supabase',
iconColor: '#3ECF8E',
name: 'Supabase',
},
{
icon: 'simple-icons:cypress',
iconColor: '#17202C',
name: 'Cypress',
},
],
},
{
title: 'I speak',
skills: [
{
icon: 'circle-flags:pl',
name: 'Polish - native',
},
{
icon: 'circle-flags:us',
name: 'English - C1',
},
{
icon: 'circle-flags:es-variant',
name: 'Spanish - B1',
},
],
},
],
};
---
<body class="flex justify-center bg-gray-50">
<div class="flex w-full max-w-6xl gap-8 px-2 py-3 sm:px-8 sm:py-12 lg:ml-22 lg:px-16 lg:py-20">
<main class="w-full space-y-4 sm:space-y-6 lg:space-y-8">
<SkillsSection config={skills.config} skillSets={skills.skillSets} />
</main>
</div>
</body>

View file

@ -1,11 +0,0 @@
---
import Tag from '@/components/tag.astro';
---
<div class="p-5">
<Tag>Tag text</Tag>
</div>
<div class="p-5">
<Tag name="simple-icons:react" color="#61DAFB">Tag text</Tag>
</div>

View file

@ -1,20 +0,0 @@
---
import Testimonial from '@/sections/testimonials/testimonial.astro';
import type { Testimonial as TestimonialData } from '@/types/testimonials-section';
const testimonial: TestimonialData = {
author: 'Howard Stewart',
relation: 'We work together as front-end developers at Google',
content:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed euismod, nisl vel tincidunt aliquam, nunc nisl aliquet nisl, eget aliquet nunc nisl euismod nisl. Sed euismod, nisl vel tincidunt aliquam, nunc nisl aliquet nisl, eget aliquet nunc nisl euismod nisl.',
image: import('@/assets/testimonials/testimonial-1.jpeg'),
socials: [
{ name: 'GitHub', icon: 'fa6-brands:github', url: '#' },
{ name: 'LinkedIn', icon: 'fa6-brands:linkedin-in', url: '#' },
],
};
---
<div class="flex max-w-[896px] flex-col gap-2">
<Testimonial testimonial={testimonial} />
</div>

View file

@ -1,45 +0,0 @@
---
import TestimonialsSection from '@/sections/testimonials/testimonials-section.astro';
import type { Testimonial } from '@/types/testimonials-section';
const testimonials: Testimonial[] = [
{
image: import('@/assets/testimonials/testimonial-1.jpeg'),
author: 'Howard Stewart',
relation: 'We work together as front-end developers at Google',
content:
'In nec mattis sem. Morbi purus lorem, euismod ac varius at, aliquet vitae augue. Pellentesque ut facilisis felis. In sed dui blandit, aliquet odio eu, elementum leo. In facilisis dapibus tortor ac volutpat. Cras cursus nec odio maximus elementum.',
socials: [
{ name: 'GitHub', icon: 'fa6-brands:github', url: '#' },
{ name: 'LinkedIn', icon: 'fa6-brands:linkedin-in', url: '#' },
],
},
{
image: import('@/assets/testimonials/testimonial-2.jpeg'),
author: 'Jean Richards',
relation: 'My project manager at GitLab',
content:
'Praesent nec congue elit. Vestibulum lobortis congue ipsum, a gravida mi tempus ac. Mauris aliquet purus nibh, vel varius turpis tempus non. Nullam eget ultricies orci. Quisque nulla ante, auctor eget varius ac, imperdiet nec magna.',
socials: [{ name: 'LinkedIn', icon: 'fa6-brands:linkedin-in', url: '#' }],
},
{
image: import('@/assets/testimonials/testimonial-3.jpeg'),
author: 'Jason Fisher',
relation: 'My customer for sidewing.com website',
content:
'Mauris tincidunt at purus vehicula porta. Mauris eget mollis turpis. Sed iaculis rutrum pharetra. Vivamus risus quam, suscipit et semper ut, aliquet ut tellus. Donec quis auctor nunc.',
socials: [
{ name: 'GitHub', icon: 'fa6-brands:github', url: '#' },
{ name: 'Website', icon: 'fa6-solid:globe', url: '#' },
],
},
];
---
<body class="flex justify-center bg-gray-50">
<div class="flex w-full max-w-6xl gap-8 px-2 py-3 sm:px-8 sm:py-12 lg:ml-22 lg:px-16 lg:py-20">
<main class="w-full space-y-4 sm:space-y-6 lg:space-y-8">
<TestimonialsSection testimonials={testimonials} config={{ title: 'Testimonials', icon: 'fa6-solid:comment' }} />
</main>
</div>
</body>

View file

@ -1,48 +0,0 @@
---
import Typography from '@/components/typography.astro';
const text = 'A quick brown fox jumps over the lazy dog';
---
<div class="space-y-10 p-5">
<div>
<p class="mb-2 font-mono">paragraph (default)</p>
<Typography>{text}</Typography>
</div>
<div>
<p class="mb-2 font-mono">main-title</p>
<Typography variant="main-title">{text}</Typography>
</div>
<div>
<p class="mb-2 font-mono">main-subtitle</p>
<Typography variant="main-subtitle">{text}</Typography>
</div>
<div>
<p class="mb-2 font-mono">section-title</p>
<Typography variant="section-title">{text}</Typography>
</div>
<div>
<p class="mb-2 font-mono">section-subtitle</p>
<Typography variant="section-subtitle">{text}</Typography>
</div>
<div>
<p class="mb-2 font-mono">item-title</p>
<Typography variant="item-title">{text}</Typography>
</div>
<div>
<p class="mb-2 font-mono">item-title-suffix</p>
<Typography variant="item-title-suffix">{text}</Typography>
</div>
<div>
<p class="mb-2 font-mono">item-subtitle</p>
<Typography variant="item-subtitle">{text}</Typography>
</div>
<div>
<p class="mb-2 font-mono">tile-title</p>
<Typography variant="tile-title">{text}</Typography>
</div>
<div>
<p class="mb-2 font-mono">tile-subtitle</p>
<Typography variant="tile-subtitle">{text}</Typography>
</div>
</div>

View file

@ -1,67 +0,0 @@
---
import WorkTimelineItem from '@/sections/experience/work-timeline-item.astro';
import type { Job } from '@/types/experience-section';
import type { I18n } from '@/types/i18n';
const job: Job = {
role: 'Senior front-end developer',
company: 'Google',
startDate: new Date('2020-02'),
endDate: null,
description: [
'In tristique vulputate augue vel egestas.',
'Quisque ac imperdiet tortor, at lacinia ex.',
'Duis vel ex hendrerit, commodo odio sed, aliquam enim.',
'Ut arcu nulla, tincidunt eget arcu eget, molestie vulputate nisi.',
'Nunc malesuada leo et est iaculis facilisis.',
'Fusce eu urna ut magna malesuada fringilla.',
],
tags: [
{
icon: 'simple-icons:react',
iconColor: '#61DAFB',
name: 'React.js',
url: 'https://reactjs.org/',
},
{
icon: 'simple-icons:nextdotjs',
iconColor: '#000000',
name: 'Next.js',
url: 'https://nextjs.org/',
},
{
icon: 'simple-icons:typescript',
iconColor: '#3178C6',
name: 'TypeScript',
url: 'https://www.typescriptlang.org/',
},
{
icon: 'simple-icons:nx',
iconColor: '#143055',
name: 'Nx',
url: 'https://nx.dev/',
},
{
icon: 'simple-icons:firebase',
iconColor: '#FFCA28',
name: 'Firebase',
url: 'https://firebase.google.com/',
},
],
socials: [
{ name: 'Facebook', icon: 'fa6-brands:facebook-f', url: '#' },
{ name: 'LinkedIn', icon: 'fa6-brands:linkedin-in', url: '#' },
],
};
const i18nData: I18n = {
locale: 'en-US',
translations: {
now: 'now',
},
};
---
<div class="p-5">
<WorkTimelineItem job={job} i18n={i18nData} />
</div>

View file

@ -1,61 +1,30 @@
--- ---
import Sidebar from '@/components/sidebar.astro'; import Layout from '@/web/components/layout.astro';
import ThemeToggle from '@/components/theme-toggle.astro'; import Sidebar from '@/web/components/sidebar.astro';
import EducationSection from '@/sections/education/education-section.astro'; import ThemeToggle from '@/web/components/theme-toggle.astro';
import ExperienceSection from '@/sections/experience/experience-section.astro'; import MainSection from '@/web/sections/main/main-section.web.astro';
import FavoritesSection from '@/sections/favorites/favorites-section.astro'; import SkillsSection from '@/web/sections/skills/skills-section.web.astro';
import MainSection from '@/sections/main/main-section.astro'; import ExperienceSection from '@/web/sections/experience/experience-section.web.astro';
import PortfolioSection from '@/sections/portfolio/portfolio-section.astro'; import PortfolioSection from '@/web/sections/portfolio/portfolio-section.web.astro';
import SkillsSection from '@/sections/skills/skills-section.astro'; import EducationSection from '@/web/sections/education/education-section.web.astro';
import TestimonialsSection from '@/sections/testimonials/testimonials-section.astro'; import TestimonialsSection from '@/web/sections/testimonials/testimonials-section.web.astro';
import FavoritesSection from '@/web/sections/favorites/favorites-section.web.astro';
import { cv } from '@/data/cv';
import data from '../data'; const { config, sections } = cv();
const { seo, i18n } = data;
const seoImage = seo.image ? seo.image : '/favicon.svg';
--- ---
<!DOCTYPE html> <Layout {...config}>
<html lang="en" class="scroll-smooth"> <ThemeToggle />
<head> <main class="w-full max-w-5xl space-y-4 px-2 py-3 sm:space-y-6 sm:px-8 sm:py-12 lg:space-y-8 lg:py-20">
<meta charset="UTF-8" /> <MainSection {...sections.main} />
<meta name="viewport" content="width=device-width" /> <SkillsSection {...sections.skills} />
<meta name="generator" content={Astro.generator} /> <ExperienceSection {...sections.experience} />
<title>{seo.title}</title> <PortfolioSection {...sections.portfolio} />
<meta name="description" content={seo.description} /> <EducationSection {...sections.education} />
<link rel="icon" type={seo.image ? 'image/jpeg' : 'image/svg+xml'} href={seoImage} /> <TestimonialsSection {...sections.testimonials} />
<meta property="og:title" content={seo.title} /> <FavoritesSection {...sections.favorites} />
<meta property="og:description" content={seo.description} /> </main>
<meta property="og:image" content={seoImage} /> <Sidebar sections={sections} className="sticky top-8 mt-20" />
<script is:inline> <script src="../web/scripts/initialize-tooltips.ts"></script>
const theme = (() => { </Layout>
if (typeof localStorage !== 'undefined' && localStorage.getItem('theme')) {
return localStorage.getItem('theme');
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
})();
if (theme === 'light') {
document.documentElement.classList.remove('dark');
} else {
document.documentElement.classList.add('dark');
}
window.localStorage.setItem('theme', theme);
</script>
</head>
<body class="flex justify-center overflow-x-hidden bg-gray-50 dark:bg-gray-900 xl:relative xl:left-7">
<ThemeToggle />
<main class="w-full max-w-5xl space-y-4 px-2 py-3 sm:space-y-6 sm:px-8 sm:py-12 lg:space-y-8 lg:py-20">
<MainSection {...data.main} />
{data.skills && <SkillsSection {...data.skills} />}
{data.experience && <ExperienceSection i18n={i18n} {...data.experience} />}
{data.portfolio && <PortfolioSection i18n={i18n} {...data.portfolio} />}
{data.education && <EducationSection i18n={i18n} {...data.education} />}
{data.testimonials && <TestimonialsSection {...data.testimonials} />}
{data.favorites && <FavoritesSection {...data.favorites} />}
</main>
<Sidebar data={data} className="sticky top-8 mt-20" />
<script src="../scripts/initialize-tooltips.ts"></script>
</body>
</html>

View file

@ -1,29 +1,34 @@
--- ---
import Footer from '@/pdf/footer.astro'; import Footer from '@/pdf/components/footer.astro';
import EducationSection from '@/pdf/sections/education-section.pdf.astro'; import EducationSection from '@/pdf/sections/education-section.pdf.astro';
import ExperienceSection from '@/pdf/sections/experience-section.pdf.astro'; import ExperienceSection from '@/pdf/sections/experience-section.pdf.astro';
import MainSection from '@/pdf/sections/main-section.pdf.astro'; import MainSection from '@/pdf/sections/main-section.pdf.astro';
import PortfolioSection from '@/pdf/sections/portfolio-section.pdf.astro'; import PortfolioSection from '@/pdf/sections/portfolio-section.pdf.astro';
import SkillsSection from '@/pdf/sections/skills-section.pdf.astro'; import SkillsSection from '@/pdf/sections/skills-section.pdf.astro';
import data from '../data'; import { cv } from '@/data/cv';
import { hideProject, hideSkillSet, renameSkillSet } from '@/data/transformers';
const { i18n } = data; const { config, sections } = cv(
hideSkillSet('I want to learn'),
renameSkillSet('I speak', 'Languages'),
hideProject('Disco Ninjas')
);
--- ---
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang={config.i18n.locale.code}>
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
<title>PDF preview</title> <title>PDF preview</title>
</head> </head>
<body class="flex flex-col bg-white p-[10mm] print:p-0"> <body class="flex flex-col bg-white p-[10mm] print:p-0">
<MainSection {...data.main} /> <MainSection {...sections.main} />
{data.skills && <SkillsSection {...data.skills} />} {sections.skills && <SkillsSection {...sections.skills} />}
{data.experience && <ExperienceSection i18n={i18n} {...data.experience} />} {sections.experience && <ExperienceSection {...sections.experience} />}
{data.portfolio && <PortfolioSection i18n={i18n} {...data.portfolio} />} {sections.portfolio && <PortfolioSection {...sections.portfolio} />}
{data.education && <EducationSection i18n={i18n} {...data.education} />} {sections.education && <EducationSection {...sections.education} />}
<Footer footer={data.pdf.footer} /> {config.pdf?.footer && <Footer>{config.pdf.footer}</Footer>}
</body> </body>
</html> </html>

View file

@ -1,17 +1,13 @@
--- ---
import type { I18n } from '@/types/i18n'; import type { DateRange } from '@/types/shared';
import getDateFormatter from '@/utils/date-formatter'; import formatDateRange from '@/utils/format-date-range';
export interface Props { export interface Props {
i18n: I18n;
startDate: Date;
endDate: Date | null;
class?: string; class?: string;
dates: DateRange;
} }
const { startDate, endDate, i18n, ...props } = Astro.props; const { dates, ...props } = Astro.props;
const getFormattedDate = getDateFormatter(i18n.locale);
--- ---
<div <div
@ -20,6 +16,5 @@ const getFormattedDate = getDateFormatter(i18n.locale);
props.class, props.class,
]} ]}
> >
{getFormattedDate(startDate)} -{' '} {formatDateRange(dates)}
{endDate ? getFormattedDate(endDate) : i18n.translations.now}
</div> </div>

View file

@ -1,6 +1,8 @@
--- ---
import type { Description } from '@/types/shared';
export interface Props { export interface Props {
content: string | string[]; content: Description;
} }
const { content } = Astro.props; const { content } = Astro.props;

View file

@ -0,0 +1,6 @@
<div
id="footer"
class="mt-4 flex w-full justify-center rounded border border-gray-100 bg-gray-50 px-2 py-1 text-justify text-[11px]"
>
<slot />
</div>

View file

@ -1,11 +1,13 @@
--- ---
import type { PdfDetail } from '@/types/common'; import type { LabelledValue } from '@/types/shared';
export interface Props extends PdfDetail { export interface Props extends LabelledValue {
class?: string; class?: string;
} }
const { label, value, url, ...props } = Astro.props; const { label, value, url, ...props } = Astro.props;
const parsedValue = Array.isArray(value) ? value.join(', ') : value;
--- ---
<div class:list={['flex gap-1 text-base font-normal text-gray-500', props.class]}> <div class:list={['flex gap-1 text-base font-normal text-gray-500', props.class]}>
@ -13,10 +15,10 @@ const { label, value, url, ...props } = Astro.props;
{ {
url ? ( url ? (
<a href={url} class="underline"> <a href={url} class="underline">
{value} {parsedValue}
</a> </a>
) : ( ) : (
<div>{value}</div> <div>{parsedValue}</div>
) )
} }
</div> </div>

View file

@ -1,22 +1,20 @@
--- ---
import type { I18n } from '@/types/i18n'; import type { DateRange } from '@/types/shared';
import DateRangeTag from './date-range-tag.astro'; import DateRangeTag from './date-range-tag.astro';
export interface Props { export interface Props {
title: string; title: string;
subtitle?: string; subtitle?: string;
startDate: Date; dates: DateRange;
endDate: Date | null;
i18n: I18n;
} }
const { title, subtitle, i18n, startDate, endDate } = Astro.props; const { title, subtitle, dates } = Astro.props;
--- ---
<div> <div>
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div class="text-lg font-extrabold text-gray-900">{title}</div> <div class="text-lg font-extrabold text-gray-900">{title}</div>
<DateRangeTag class="mt-0.5" i18n={i18n} startDate={startDate} endDate={endDate} /> <DateRangeTag class="mt-0.5" dates={dates} />
</div> </div>
{subtitle && <div class="text-md -mt-0.5 font-medium text-gray-700">{subtitle}</div>} {subtitle && <div class="text-md -mt-0.5 font-medium text-gray-700">{subtitle}</div>}
</div> </div>

View file

@ -0,0 +1,24 @@
---
import type { Photo } from '@/types/shared';
import { Image } from '@astrojs/image/components';
export interface Props {
src: Photo;
alt: string;
class?: string;
width?: number;
height?: number;
}
const { src, ...props } = Astro.props;
const isRemoteImage = typeof src === 'string';
---
{
isRemoteImage ? (
<img src={src} {...props} />
) : (
<Image format="webp" fit="cover" loading="eager" src={src} {...props} />
)
}

View file

@ -1,15 +1,12 @@
--- ---
import type { Tag } from '@/types/common'; import type { TagsList } from '@/types/shared';
export interface Props { export interface Props extends TagsList {}
tags: Tag[];
label: string;
}
const { tags, label } = Astro.props; const { tags, title } = Astro.props;
--- ---
<div class="text-base"> <div class="text-base">
<span class="font-medium text-gray-700">{label}:</span> <span class="font-medium text-gray-700">{title}:</span>
<span class="font-normal text-gray-500">{tags.map((t) => t.name).join(', ')}</span> <span class="font-normal text-gray-500">{tags.map((t) => t.name).join(', ')}</span>
</div> </div>

View file

@ -1,24 +0,0 @@
---
import type { Pdf } from '@/types/pdf';
export interface Props {
footer: Pdf['footer'];
}
const { footer } = Astro.props;
---
<div
id="footer"
class="mt-4 flex w-full justify-center rounded border border-gray-100 bg-gray-50 px-2 py-1 text-justify text-[11px]"
>
{footer}
</div>
<script>
const footer = document.getElementById('footer')!;
const withClause = window.location.search.includes('clause');
if (!withClause) {
footer.remove();
}
</script>

View file

@ -1,30 +1,23 @@
--- ---
import type { EducationSection } from '@/types/education-section'; import type { EducationSection } from '@/types/sections/education-section.types';
import type { I18n } from '@/types/i18n';
import DashedDivider from '../components/dashed-divider.astro'; import DashedDivider from '../components/dashed-divider.astro';
import Description from '../components/description.astro'; import Description from '../components/description.astro';
import ListItemHeading from '../components/list-item-heading.astro'; import ListItemHeading from '../components/list-item-heading.astro';
import SectionHeading from '../components/section-heading.astro'; import SectionHeading from '../components/section-heading.astro';
export interface Props extends EducationSection { export interface Props extends EducationSection {}
i18n: I18n;
}
const { const { config, diplomas } = Astro.props;
config: { title },
educationItems,
i18n,
} = Astro.props;
--- ---
<div> <div>
<SectionHeading>{title}</SectionHeading> <SectionHeading>{config.title}</SectionHeading>
<div class="flex flex-col"> <div class="flex flex-col">
{ {
educationItems.map(({ title, description, institution, startDate, endDate }) => () => ( diplomas.map(({ title, description, institution, dates }) => () => (
<> <>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<ListItemHeading title={title} subtitle={institution} startDate={startDate} endDate={endDate} i18n={i18n} /> <ListItemHeading title={title} subtitle={institution} dates={dates} />
<Description content={description} /> <Description content={description} />
</div> </div>
<DashedDivider /> <DashedDivider />

View file

@ -1,34 +1,26 @@
--- ---
import type { ExperienceSection, Job } from '@/types/experience-section'; import type { ExperienceSection } from '@/types/sections/experience-section.types';
import type { I18n } from '@/types/i18n';
import DashedDivider from '../components/dashed-divider.astro'; import DashedDivider from '../components/dashed-divider.astro';
import Description from '../components/description.astro'; import Description from '../components/description.astro';
import ListItemHeading from '../components/list-item-heading.astro'; import ListItemHeading from '../components/list-item-heading.astro';
import SectionHeading from '../components/section-heading.astro'; import SectionHeading from '../components/section-heading.astro';
import TagsList from '../components/tags-list.astro'; import TagsList from '../components/tags-list.astro';
export interface Props extends ExperienceSection { export interface Props extends ExperienceSection {}
jobs: Job[];
i18n: I18n;
}
const { const { config, jobs } = Astro.props;
config: { title },
i18n,
jobs,
} = Astro.props;
--- ---
<div> <div>
<SectionHeading>{title}</SectionHeading> <SectionHeading>{config.title}</SectionHeading>
<div class="flex flex-col"> <div class="flex flex-col">
{ {
jobs.map(({ company, role, description, tags, startDate, endDate }) => () => ( jobs.map(({ company, role, description, tagsList, dates }) => () => (
<> <>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<ListItemHeading title={role} subtitle={company} startDate={startDate} endDate={endDate} i18n={i18n} /> <ListItemHeading title={role} subtitle={company} dates={dates} />
<Description content={description} /> <Description content={description} />
<TagsList label="Technologies" tags={tags} /> <TagsList {...tagsList} />
</div> </div>
<DashedDivider /> <DashedDivider />
</> </>

View file

@ -1,24 +1,23 @@
--- ---
import Photo from '@/components/photo.astro'; import type { MainSection } from '@/types/sections/main-section.types';
import type { PdfDetail } from '@/types/common'; import Photo from '@/pdf/components/photo.astro';
import type { MainSection } from '@/types/main-section'; import Description from '@/pdf/components/description.astro';
import Description from '../components/description.astro'; import LabelledValue from '@/pdf/components/labelled-value.astro';
import LabelledValue from '../components/labelled-value.astro';
export interface Props extends MainSection {} export interface Props extends MainSection {}
const { image, fullName, role, pdfDetails, details, description } = Astro.props; const { image, fullName, role, details, pdfDetails, description } = Astro.props;
--- ---
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<div class="flex gap-6"> <div class="flex gap-6">
<Photo src={image} alt={fullName} loading="eager" class="w-40 h-40 max-w-[160px] max-h-[160px] rounded-2xl" /> <Photo src={image} alt={fullName} class="w-40 h-40 max-w-[160px] max-h-[160px] rounded-2xl" />
<div> <div>
<div class="text-3xl font-extrabold text-gray-900">{fullName}</div> <div class="text-3xl font-extrabold text-gray-900">{fullName}</div>
<div class="text-lg font-medium text-gray-700">{role}</div> <div class="text-lg font-medium text-gray-700">{role}</div>
<div class="grid grid-cols-[auto_auto] gap-x-4 gap-y-1 pt-4"> <div class="grid grid-cols-[auto_auto] gap-x-4 gap-y-1 pt-4">
{ {
(pdfDetails || details).map((detail: PdfDetail) => ( (pdfDetails ?? details).map((detail) => (
<LabelledValue {...detail} class={detail.fullRow ? 'col-span-2' : undefined} /> <LabelledValue {...detail} class={detail.fullRow ? 'col-span-2' : undefined} />
)) ))
} }

View file

@ -1,7 +1,5 @@
--- ---
import type { PdfDetail } from '@/types/common'; import type { PortfolioSection } from '@/types/sections/portfolio-section.types';
import type { I18n } from '@/types/i18n';
import type { PortfolioSection } from '@/types/portfolio-section';
import DashedDivider from '../components/dashed-divider.astro'; import DashedDivider from '../components/dashed-divider.astro';
import Description from '../components/description.astro'; import Description from '../components/description.astro';
import LabelledValue from '../components/labelled-value.astro'; import LabelledValue from '../components/labelled-value.astro';
@ -9,32 +7,26 @@ import ListItemHeading from '../components/list-item-heading.astro';
import SectionHeading from '../components/section-heading.astro'; import SectionHeading from '../components/section-heading.astro';
import TagsList from '../components/tags-list.astro'; import TagsList from '../components/tags-list.astro';
export interface Props extends PortfolioSection { export interface Props extends PortfolioSection {}
i18n: I18n;
}
const { const { config, projects } = Astro.props;
config: { title },
projects,
i18n,
} = Astro.props;
--- ---
<div> <div>
<SectionHeading>{title}</SectionHeading> <SectionHeading>{config.title}</SectionHeading>
<div class="flex flex-col"> <div class="flex flex-col">
{ {
projects.map(({ name, description, pdfDetails, details, tags, startDate, endDate }) => () => ( projects.map(({ name, description, details, pdfDetails, tagsList, dates }) => () => (
<> <>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<ListItemHeading title={name} startDate={startDate} endDate={endDate} i18n={i18n} /> <ListItemHeading title={name} dates={dates} />
<Description content={description} /> <Description content={description} />
<div> <div class="grid grid-cols-[auto_auto] gap-x-4 gap-y-1">
{(pdfDetails || details).map((detail: PdfDetail) => ( {(pdfDetails ?? details).map((detail) => (
<LabelledValue {...detail} /> <LabelledValue {...detail} />
))} ))}
</div> </div>
<TagsList label="Technologies" tags={tags} /> <TagsList {...tagsList} />
</div> </div>
<DashedDivider /> <DashedDivider />
</> </>

View file

@ -1,56 +1,48 @@
--- ---
import type { SkillsSection } from '@/types/skills-section'; import type { SkillsSection } from '@/types/sections/skills-section.types';
import SectionHeading from '../components/section-heading.astro'; import SectionHeading from '../components/section-heading.astro';
export interface Props extends SkillsSection {} export interface Props extends SkillsSection {}
const { const { config, skillSets } = Astro.props;
config: { title },
skillSets,
} = Astro.props;
--- ---
<div> <div>
<SectionHeading>{title}</SectionHeading> <SectionHeading>{config.title}</SectionHeading>
<div class="flex flex-col gap-5"> <div class="flex flex-col gap-5">
{ {
skillSets.map( skillSets.map((skillSet) => (
(skillSet) => <div>
!skillSet.excludeFromPdf && ( <div class="text-base font-extrabold text-gray-900">{skillSet.title}</div>
<div> <div class="mt-2 flex flex-wrap gap-3.5 text-sm text-gray-700">
<div class="text-base font-extrabold text-gray-900">{skillSet.pdfTitle || skillSet.title}</div> {skillSet.skills.map((skill) => {
<div class="mt-2 flex flex-wrap gap-3.5 text-sm text-gray-700"> if ('level' in skill) {
{skillSet.skills.map((skill) => { return (
if ('level' in skill) { <div class="flex h-6 w-fit overflow-hidden rounded">
return ( <div class="flex items-center bg-gray-100 pl-2.5 pr-2 font-medium">{skill.name}</div>
<div class="flex h-6 w-fit overflow-hidden rounded"> <div class="flex items-center bg-gray-200 pr-2.5 pl-2 font-normal">{skill.level}/5</div>
<div class="flex items-center bg-gray-100 pl-2.5 pr-2 font-medium">{skill.name}</div> </div>
<div class="flex items-center bg-gray-200 pr-2.5 pl-2 font-normal">{skill.level}/5</div> );
</div> }
);
}
if (skill.name.includes(' - ')) { if (skill.name.includes(' - ')) {
return ( return (
<div class="flex h-6 w-fit overflow-hidden rounded"> <div class="flex h-6 w-fit overflow-hidden rounded">
<div class="flex items-center bg-gray-100 pl-2.5 pr-2 font-medium"> <div class="flex items-center bg-gray-100 pl-2.5 pr-2 font-medium">
{skill.name.split(' - ')[0]} {skill.name.split(' - ')[0]}
</div> </div>
<div class="flex items-center bg-gray-200 pr-2.5 pl-2 font-normal"> <div class="flex items-center bg-gray-200 pr-2.5 pl-2 font-normal">
{skill.name.split(' - ')[1]} {skill.name.split(' - ')[1]}
</div> </div>
</div> </div>
); );
} }
return ( return <div class="flex h-6 w-fit items-center rounded bg-gray-100 px-2.5 font-medium">{skill.name}</div>;
<div class="flex h-6 w-fit items-center rounded bg-gray-100 px-2.5 font-medium">{skill.name}</div> })}
); </div>
})} </div>
</div> ))
</div>
)
)
} }
</div> </div>
</div> </div>

View file

@ -1,42 +0,0 @@
---
import IconButton from '@/components/icon-button.astro';
import Timestamp from '@/components/timestamp.astro';
import Typography from '@/components/typography.astro';
import type { EducationItem } from '@/types/education-section';
import type { I18n } from '@/types/i18n';
export interface Props {
educationItem: EducationItem;
i18n: I18n;
}
const {
educationItem: { title, institution, startDate, endDate, description, socials },
i18n,
} = Astro.props;
---
<div class="flex flex-col gap-3">
<div class="flex w-full justify-between gap-2">
<div class="flex flex-col">
<Typography variant="item-title">{title}</Typography>
<Typography variant="main-subtitle">{institution}</Typography>
<Timestamp
startDate={startDate}
endDate={endDate}
locale={i18n.locale}
translationForNow={i18n.translations.now}
/>
</div>
{
socials.length > 0 && (
<div class="flex flex-wrap gap-3 sm:flex-nowrap">
{socials.map(({ icon, url: iconUrl, name }) => (
<IconButton href={iconUrl} icon={icon} size="small" target="_blank" aria-label={name} />
))}
</div>
)
}
</div>
<Typography variant="paragraph">{description}</Typography>
</div>

View file

@ -1,30 +0,0 @@
---
import Divider from '@/components/divider.astro';
import SectionCard from '@/components/section-card.astro';
import type { EducationSection } from '@/types/education-section';
import type { I18n } from '@/types/i18n';
import removeLast from '@/utils/remove-last';
import EducationItem from './education-item.astro';
export interface Props extends EducationSection {
i18n: I18n;
}
const {
config: { title },
educationItems,
i18n,
} = Astro.props;
---
<SectionCard section="education" title={title}>
{
removeLast(
educationItems.flatMap((educationItem) => [
<EducationItem i18n={i18n} educationItem={educationItem} />,
<Divider />,
])
)
}
</SectionCard>

View file

@ -1,30 +0,0 @@
---
import DividedList from '@/components/divided-list.astro';
import Divider from '@/components/divider.astro';
import SectionCard from '@/components/section-card.astro';
import type { SectionKey } from '@/types/data';
import type { ExperienceSection, Job } from '@/types/experience-section';
import type { I18n } from '@/types/i18n';
import removeLast from '@/utils/remove-last';
import WorkTimelineItem from './work-timeline-item.astro';
export interface Props extends ExperienceSection {
jobs: Job[];
i18n: I18n;
}
const {
config: { title },
i18n,
jobs,
} = Astro.props;
const section: SectionKey = 'experience';
---
<SectionCard section={section} title={title}>
<DividedList>
{removeLast(jobs.flatMap((job) => [<WorkTimelineItem job={job} i18n={i18n} />, <Divider />]))}
</DividedList>
</SectionCard>

View file

@ -1,54 +0,0 @@
---
import IconButton from '@/components/icon-button.astro';
import TagsList from '@/components/tags-list.astro';
import Timestamp from '@/components/timestamp.astro';
import Typography from '@/components/typography.astro';
import type { Job } from '@/types/experience-section';
import type { I18n } from '@/types/i18n';
export interface Props {
job: Job;
i18n: I18n;
}
const { job, i18n } = Astro.props;
---
<div class="flex flex-col gap-2 md:gap-0">
<div class="flex w-full flex-row justify-between">
<div>
<Typography variant="item-title">
{job.role}
<span class="font-medium"> &#8212;&nbsp;{job.company}</span>
</Typography>
</div>
<div class="flex flex-wrap gap-2">
{
job.socials?.map(({ icon, url, name }) => (
<IconButton icon={icon} href={url} target="_blank" size="small" aria-label={name} />
))
}
</div>
</div>
<Timestamp
startDate={job.startDate}
endDate={job.endDate}
locale={i18n.locale}
translationForNow={i18n.translations.now}
/>
<ul class="ml-[18px] list-disc pt-3 pb-6 text-white">
{
Array.isArray(job.description) ? (
job.description.map((d) => (
<li>
<Typography variant="paragraph">{d}</Typography>
</li>
))
) : (
<li>
<Typography variant="paragraph">{job.description}</Typography>
</li>
)
}
</ul>
<TagsList tags={job.tags} />
</div>

View file

@ -1,87 +0,0 @@
---
import type { ComponentInstance } from 'astro';
import SectionCard from '@/components/section-card.astro';
import Typography from '@/components/typography.astro';
import type { SectionKey } from '@/types/data';
import type { Book, FavoritesSection, Media, Person, Video } from '@/types/favorites-section';
import BookTile from './book-tile.astro';
import MediaTile from './media-tile.astro';
import PersonTile from './person-tile.astro';
import VideoTile from './video-tile.astro';
export interface Props extends FavoritesSection {}
const {
config: { title },
books,
medias,
people,
videos,
} = Astro.props;
type Subsection = 'books' | 'medias' | 'people' | 'videos';
type SubsectionData = Book | Media | Person | Video;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- required to avoid type casting
type SubsectionComponent = (_props: { value: any }) => ComponentInstance;
interface FavoritesSubsection<T extends SubsectionData> {
name: Subsection;
data: T[];
title: string;
columnsLayout: string;
Component: SubsectionComponent;
}
const booksSubsection: FavoritesSubsection<Book> = {
name: 'books',
columnsLayout: 'grid-cols-fluid200',
Component: BookTile,
...books,
};
const mediasSubsection: FavoritesSubsection<Media> = {
name: 'medias',
columnsLayout: 'grid-cols-fluid120',
Component: MediaTile,
...medias,
};
const peopleSubsection: FavoritesSubsection<Person> = {
name: 'people',
columnsLayout: 'grid-cols-fluid120',
Component: PersonTile,
...people,
};
const videosSubsection: FavoritesSubsection<Video> = {
name: 'videos',
columnsLayout: 'grid-cols-fluid240',
Component: VideoTile,
...videos,
};
const subsections = [booksSubsection, peopleSubsection, videosSubsection, mediasSubsection];
const section: SectionKey = 'favorites';
---
<SectionCard section={section} title={title}>
<div class="flex flex-col gap-16">
{
subsections.map(({ Component, data, name, columnsLayout, title: subsectionTitle }) => (
<div class="flex flex-col gap-6">
<Typography variant="section-subtitle" id={`${section}-${name}-heading`}>
{subsectionTitle}
</Typography>
<div class:list={['grid gap-8', columnsLayout]}>
{data.map((value) => (
<Component value={value} />
))}
</div>
</div>
))
}
</div>
</SectionCard>

View file

@ -1,23 +0,0 @@
---
import Photo from '@/components/photo.astro';
import Typography from '@/components/typography.astro';
import type { Video } from '@/types/favorites-section';
export interface Props {
value: Video;
}
const {
value: { title, url },
} = Astro.props;
const id = url.split('/').pop();
const thumbnail = `https://img.youtube.com/vi/${id}/0.jpg`;
---
<a href={url} class="flex w-full max-w-[240px] flex-col gap-3 transition duration-300 hover:translate-y-2">
<Photo class="rounded-lg shadow-md aspect-video object-cover" src={thumbnail} alt={title} width={480} height={270} />
<Typography variant="tile-title">
{title}
</Typography>
</a>

View file

@ -1,29 +0,0 @@
---
import DividedList from '@/components/divided-list.astro';
import Divider from '@/components/divider.astro';
import SectionCard from '@/components/section-card.astro';
import type { SectionKey } from '@/types/data';
import type { I18n } from '@/types/i18n';
import type { PortfolioSection } from '@/types/portfolio-section';
import removeLast from '@/utils/remove-last';
import ProjectTimelineItem from './project-timeline-item.astro';
export interface Props extends PortfolioSection {
i18n: I18n;
}
const {
config: { title },
projects,
i18n,
} = Astro.props;
const section: SectionKey = 'portfolio';
---
<SectionCard section={section} title={title}>
<DividedList>
{removeLast(projects.flatMap((project) => [<ProjectTimelineItem project={project} i18n={i18n} />, <Divider />]))}
</DividedList>
</SectionCard>

View file

@ -1,68 +0,0 @@
---
import IconButton from '@/components/icon-button.astro';
import LabelledValue from '@/components/labelled-value.astro';
import Photo from '@/components/photo.astro';
import TagsList from '@/components/tags-list.astro';
import Timestamp from '@/components/timestamp.astro';
import Typography from '@/components/typography.astro';
import type { I18n } from '@/types/i18n';
import type { Project } from '@/types/portfolio-section';
export interface Props {
project: Project;
i18n: I18n;
}
const { project, i18n } = Astro.props;
const { description, details, endDate, name, socials, startDate, tags, image } = project;
const alt = `Thumbnail for ${name} project`;
---
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-4">
<div class="flex gap-6">
<Photo
src={image}
alt={alt}
width={200}
height={200}
class="rounded-lg object-cover max-w-[120px] overflow-hidden sm:block hidden"
/>
<div class="flex w-full flex-col gap-4">
<div class="flex justify-between">
<div>
<Typography variant="item-title">{name}</Typography>
<Timestamp
startDate={startDate}
endDate={endDate}
locale={i18n.locale}
translationForNow={i18n.translations.now}
/>
</div>
<div class="flex gap-2">
{
socials?.map(({ icon, url, name: socialName }) => (
<IconButton icon={icon} href={url} target="_blank" size="small" aria-label={socialName} />
))
}
</div>
</div>
<Photo class="rounded-lg object-cover max-w-[120px] sm:hidden" src={image} alt={alt} />
<div class="inline-grid w-full xl:grid-cols-[auto_auto]">
{
details.map(({ label: detailLabel, value: detailValue }) => (
<LabelledValue
label={detailLabel}
value={typeof detailValue === 'object' ? detailValue.join(', ') : detailValue}
/>
))
}
</div>
</div>
</div>
<div class="col-span-3 col-start-1">
<Typography variant="paragraph">{description}</Typography>
</div>
</div>
<TagsList tags={tags} />
</div>

View file

@ -1,15 +0,0 @@
---
import type { LevelledSkill } from '@/types/skills-section';
import Skill from './skill.astro';
export interface Props {
skills: LevelledSkill[];
}
const { skills } = Astro.props;
---
<div class="flex flex-wrap gap-8">
{skills.map((skill) => <Skill {...skill} />)}
</div>

View file

@ -1,28 +0,0 @@
---
import TagsList from '@/components/tags-list.astro';
import Typography from '@/components/typography.astro';
import type { Tag } from '@/types/common';
import type { LevelledSkill, SkillSet } from '@/types/skills-section';
import LevelledSkillSubsection from './levelled-skill-subsection.astro';
export interface Props {
skillSet: SkillSet<Tag> | SkillSet<LevelledSkill>;
}
const {
skillSet: { skills, title },
} = Astro.props;
const isLevelledSkillSection = (skillsSectionData: Tag[] | LevelledSkill[]): skillsSectionData is LevelledSkill[] => {
const firstSkill = skillsSectionData[0];
if (!firstSkill) return false;
return 'level' in firstSkill && firstSkill.level !== undefined;
};
---
<div class="flex flex-col gap-3">
<Typography variant="section-subtitle">{title}</Typography>
{isLevelledSkillSection(skills) ? <LevelledSkillSubsection skills={skills} /> : <TagsList tags={skills} />}
</div>

View file

@ -1,22 +0,0 @@
---
import SectionCard from '@/components/section-card.astro';
import type { SectionKey } from '@/types/data';
import type { SkillsSection } from '@/types/skills-section';
import SkillSubsection from './skill-subsection.astro';
export interface Props extends SkillsSection {}
const {
config: { title },
skillSets,
} = Astro.props;
const section: SectionKey = 'skills';
---
<SectionCard section={section} title={title}>
<div class="flex flex-col gap-10">
{skillSets.map((skillSet) => <SkillSubsection skillSet={skillSet} />)}
</div>
</SectionCard>

View file

@ -1,25 +0,0 @@
---
import DividedList from '@/components/divided-list.astro';
import Divider from '@/components/divider.astro';
import SectionCard from '@/components/section-card.astro';
import type { SectionKey } from '@/types/data';
import type { TestimonialsSection } from '@/types/testimonials-section';
import removeLast from '@/utils/remove-last';
import Testimonial from './testimonial.astro';
export interface Props extends TestimonialsSection {}
const {
testimonials,
config: { title },
} = Astro.props;
const section: SectionKey = 'testimonials';
---
<SectionCard section={section} title={title}>
<DividedList>
{removeLast(testimonials.flatMap((testimonial) => [<Testimonial testimonial={testimonial} />, <Divider />]))}
</DividedList>
</SectionCard>

View file

@ -1,32 +0,0 @@
import type { IconName } from './icon';
export type Photo = Promise<{ default: ImageMetadata }> | string;
export interface Detail {
label: string;
value: string | string[];
}
export interface PdfDetail extends Detail {
url?: string;
fullRow?: boolean;
}
export interface Social {
name: string;
icon: IconName;
url: string;
}
export interface Tag {
name: string;
icon?: IconName;
iconColor?: string;
url?: string;
description?: string;
}
export interface SectionConfig {
title: string;
icon: IconName;
}

View file

@ -0,0 +1,9 @@
import type { Locale } from 'date-fns';
export interface I18nConfig {
locale: Locale;
dateFormat: string;
translations: {
now: string;
};
}

View file

@ -0,0 +1,3 @@
export interface PdfConfig {
footer?: string;
}

View file

@ -0,0 +1,8 @@
export interface SeoConfig {
title: string;
description: string;
favicon: string;
ogTitle?: string;
ogDescription?: string;
ogImage?: string;
}

View file

@ -1,23 +1,31 @@
import type { EducationSection } from './education-section'; import type { I18nConfig } from './config/i18n-config.types';
import type { ExperienceSection } from './experience-section'; import type { PdfConfig } from './config/pdf-config.types';
import type { FavoritesSection } from './favorites-section'; import type { SeoConfig } from './config/seo-config.types';
import type { I18n } from './i18n'; import type { EducationSection } from './sections/education-section.types';
import type { MainSection } from './main-section'; import type { ExperienceSection } from './sections/experience-section.types';
import type { PortfolioSection } from './portfolio-section'; import type { FavoritesSection } from './sections/favorites-section.types';
import type { Seo } from './seo'; import type { MainSection } from './sections/main-section.types';
import type { SkillsSection } from './skills-section'; import type { PortfolioSection } from './sections/portfolio-section.types';
import type { TestimonialsSection } from './testimonials-section'; import type { SkillsSection } from './sections/skills-section.types';
import type { TestimonialsSection } from './sections/testimonials-section.types';
export interface Data { export interface Config {
i18n: I18n; seo: SeoConfig;
seo: Seo; i18n: I18nConfig;
main: MainSection; pdf?: PdfConfig;
skills?: SkillsSection;
experience?: ExperienceSection;
portfolio?: PortfolioSection;
education?: EducationSection;
testimonials?: TestimonialsSection;
favorites?: FavoritesSection;
} }
export type SectionKey = Exclude<keyof Data, 'seo' | 'i18n'>; export interface Sections {
main: MainSection;
skills: SkillsSection;
experience: ExperienceSection;
portfolio: PortfolioSection;
education: EducationSection;
testimonials: TestimonialsSection;
favorites: FavoritesSection;
}
export interface Data {
config: Config;
sections: Sections;
}

View file

@ -1,15 +0,0 @@
import type { SectionConfig, Social } from './common';
export interface EducationItem {
title: string;
institution: string;
startDate: Date;
endDate: Date | null;
description: string;
socials: Social[];
}
export interface EducationSection {
educationItems: EducationItem[];
config: SectionConfig;
}

View file

@ -1,16 +0,0 @@
import type { SectionConfig, Social, Tag } from './common';
export interface Job {
role: string;
company: string;
startDate: Date;
endDate: Date | null;
description: string | string[];
tags: Tag[];
socials: Social[];
}
export interface ExperienceSection {
jobs: Job[];
config: SectionConfig;
}

View file

@ -1,8 +0,0 @@
import type { Locales } from 'locales-ts/types';
export interface I18n {
locale: Locales;
translations: {
now: string;
};
}

View file

@ -1,3 +0,0 @@
import type { CircleFlags, Fa6Brands, Fa6Solid, Ri, SimpleIcons } from 'iconify-icon-names';
export type IconName = Fa6Brands | Fa6Solid | SimpleIcons | CircleFlags | Ri;

View file

@ -1,18 +0,0 @@
import type { Detail, PdfDetail, Photo, SectionConfig, Social, Tag } from './common';
export interface MainSection {
image: Photo;
fullName: string;
role: string;
details: Detail[];
pdfDetails?: PdfDetail[];
description: string;
tags: Tag[];
action: {
label: string;
url: string;
downloadedFileName?: string;
};
socials: Social[];
config: SectionConfig;
}

View file

@ -1,3 +0,0 @@
export interface Pdf {
footer: string;
}

View file

@ -1,17 +0,0 @@
import type { Detail, PdfDetail, Photo, SectionConfig, Social, Tag } from './common';
export interface Project {
name: string;
image: Photo;
startDate: Date;
endDate: Date | null;
details: Detail[];
pdfDetails?: PdfDetail[];
description: string;
tags: Tag[];
socials: Social[];
}
export interface PortfolioSection {
projects: Project[];
config: SectionConfig;
}

View file

@ -0,0 +1,13 @@
import type { DateRange, Description, LinkButton, Section } from '../shared';
export interface Diploma {
title: string;
institution: string;
dates: DateRange;
description: Description;
links: LinkButton[];
}
export interface EducationSection extends Section {
diplomas: Diploma[];
}

View file

@ -0,0 +1,14 @@
import type { DateRange, Description, LinkButton, Section, TagsList } from '../shared';
export interface Job {
role: string;
company: string;
dates: DateRange;
description: Description;
tagsList: TagsList;
links: LinkButton[];
}
export interface ExperienceSection extends Section {
jobs: Job[];
}

View file

@ -1,10 +1,10 @@
import type { Photo, SectionConfig } from './common'; import type { Photo, Section } from '../shared';
export interface Book { export interface Book {
title: string; title: string;
cover: Photo; image: Photo;
author: string; author: string;
url?: string; url: string;
} }
export interface Person { export interface Person {
@ -15,7 +15,7 @@ export interface Person {
export interface Video { export interface Video {
title: string; title: string;
thumbnail: Photo; image: Photo;
url: string; url: string;
} }
@ -26,15 +26,14 @@ export interface Media {
url: string; url: string;
} }
interface SubSection<Data> { export interface SubSection<Data = unknown> {
title: string; title: string;
data: Data[]; data: Data[];
} }
export interface FavoritesSection { export interface FavoritesSection extends Section {
config: SectionConfig; books?: SubSection<Book>;
books: SubSection<Book>; people?: SubSection<Person>;
people: SubSection<Person>; videos?: SubSection<Video>;
videos: SubSection<Video>; medias?: SubSection<Media>;
medias: SubSection<Media>;
} }

View file

@ -0,0 +1,13 @@
import type { DownloadButton, Photo, LabelledValue, LinkButton, Section, Tag } from '../shared';
export interface MainSection extends Section {
image: Photo;
fullName: string;
role: string;
details: LabelledValue[];
pdfDetails?: LabelledValue[];
description: string;
tags: Tag[];
action: DownloadButton;
links: LinkButton[];
}

View file

@ -0,0 +1,16 @@
import type { DateRange, Photo, LabelledValue, LinkButton, Section, TagsList, Description } from '../shared';
export interface Project {
name: string;
image: Photo;
dates: DateRange;
details: LabelledValue[];
pdfDetails?: LabelledValue[];
description: Description;
tagsList: TagsList;
links: LinkButton[];
}
export interface PortfolioSection extends Section {
projects: Project[];
}

View file

@ -0,0 +1,18 @@
import type { Section, Tag } from '../shared';
export interface Skill extends Tag {}
export type SkillLevel = 1 | 2 | 3 | 4 | 5;
export interface LevelledSkill extends Skill {
level: SkillLevel;
}
export interface SkillSet {
title: string;
skills: Skill[] | LevelledSkill[];
}
export interface SkillsSection extends Section {
skillSets: SkillSet[];
}

View file

@ -0,0 +1,13 @@
import type { Photo, LinkButton, Section } from '../shared';
export interface Testimonial {
image: Photo;
author: string;
relation: string;
content: string;
links: LinkButton[];
}
export interface TestimonialsSection extends Section {
testimonials: Testimonial[];
}

View file

@ -1,15 +0,0 @@
export interface Seo {
/**
* Title that will be displayed in Google search results and as tab name. To be fully visible in search results, it should be no longer than 60 characters.
*/
title: string;
/**
* Description that will be displayed in Google search results. To be fully visible in search results, it should be no longer than 160 characters.
*/
description: string;
/**
* Path to the image inside the public folder that will be used as page icon and as a preview image in site links.
*/
image?: string;
}

52
src/types/shared.ts Normal file
View file

@ -0,0 +1,52 @@
import type { CircleFlags, Fa6Brands, Fa6Solid, Ri, SimpleIcons } from 'iconify-icon-names';
export type IconName = Fa6Brands | Fa6Solid | SimpleIcons | CircleFlags | Ri;
export type Photo = Promise<{ default: ImageMetadata }> | string;
export type DateRange = [from: Date, to: Date | null];
export type Description = string | string[];
export interface SectionConfig {
title: string;
slug: string;
icon: IconName;
visible: boolean;
}
export interface Section {
config: SectionConfig;
}
export interface LabelledValue {
label: string;
value: string | string[];
url?: string;
fullRow?: boolean;
}
export interface Tag {
name: string;
icon?: IconName;
iconColor?: string;
url?: string;
description?: string;
}
export interface TagsList {
title: string;
tags: Tag[];
}
export interface DownloadButton {
label: string;
url: string;
downloadedFileName?: string;
}
export interface LinkButton {
name: string;
icon: IconName;
url: string;
}

View file

@ -1,17 +0,0 @@
import type { SectionConfig, Tag } from './common';
export interface LevelledSkill extends Tag {
level: 1 | 2 | 3 | 4 | 5;
}
export interface SkillSet<SkillType> {
title: string;
skills: SkillType[];
pdfTitle?: string;
excludeFromPdf?: boolean;
}
export interface SkillsSection {
skillSets: (SkillSet<Tag> | SkillSet<LevelledSkill>)[];
config: SectionConfig;
}

View file

@ -1,14 +0,0 @@
import type { Photo, SectionConfig, Social } from './common';
export interface Testimonial {
image: Photo;
author: string;
relation: string;
content: string;
socials: Social[];
}
export interface TestimonialsSection {
testimonials: Testimonial[];
config: SectionConfig;
}

View file

@ -0,0 +1,15 @@
import type { LinkButton } from '@/types/shared';
import type { Merge } from 'type-fest';
type LinkWithoutUrl = Omit<LinkButton, 'url'>;
type PartialLinkWithUrl = Partial<LinkButton> & { url: string };
const createLinkFactory =
<Link extends LinkWithoutUrl>(defaultData: Readonly<Link>) =>
<Override extends PartialLinkWithUrl>(override: Readonly<Override>) =>
({
...defaultData,
...override,
} as Readonly<Merge<Link, Override>>);
export default createLinkFactory;

Some files were not shown because too many files have changed in this diff Show more