Add PDF CV generation script (#156)
Co-authored-by: Szymon Kin <68154191+hoolek77@users.noreply.github.com>
This commit is contained in:
parent
6a452a457f
commit
af7e6c285d
29 changed files with 1461 additions and 27 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -7,3 +7,6 @@ node_modules
|
||||||
# Build output
|
# Build output
|
||||||
dist
|
dist
|
||||||
stats.html
|
stats.html
|
||||||
|
|
||||||
|
# Generated CV
|
||||||
|
public/cv.pdf
|
||||||
|
|
|
||||||
7
.vscode/extensions.json
vendored
7
.vscode/extensions.json
vendored
|
|
@ -1,10 +1,11 @@
|
||||||
{
|
{
|
||||||
"recommendations": [
|
"recommendations": [
|
||||||
"astro-build.astro-vscode",
|
|
||||||
"mgmcdermott.vscode-language-babel",
|
|
||||||
"EditorConfig.EditorConfig",
|
"EditorConfig.EditorConfig",
|
||||||
"esbenp.prettier-vscode",
|
"astro-build.astro-vscode",
|
||||||
"bradlc.vscode-tailwindcss",
|
"bradlc.vscode-tailwindcss",
|
||||||
|
"esbenp.prettier-vscode",
|
||||||
|
"foxundermoon.shell-format",
|
||||||
|
"mgmcdermott.vscode-language-babel",
|
||||||
"redhat.vscode-yaml"
|
"redhat.vscode-yaml"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
|
|
@ -9,5 +9,6 @@
|
||||||
"tailwindCSS.experimental.classRegex": [["/\\* tw \\*/ ([^;]*);", "'([^']*)'"]],
|
"tailwindCSS.experimental.classRegex": [["/\\* tw \\*/ ([^;]*);", "'([^']*)'"]],
|
||||||
"tailwindCSS.includeLanguages": { "javascript": "javascriptreact", "astro": "javascriptreact" },
|
"tailwindCSS.includeLanguages": { "javascript": "javascriptreact", "astro": "javascriptreact" },
|
||||||
"typescript.tsdk": "node_modules/typescript/lib",
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
"typescript.enablePromptUseWorkspaceTsdk": true
|
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||||
|
"[ignore]": { "editor.defaultFormatter": "foxundermoon.shell-format" }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
998
package-lock.json
generated
998
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -13,6 +13,7 @@
|
||||||
"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",
|
||||||
"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 .",
|
||||||
"ts:check": "tsc --jsx preserve --skipLibCheck",
|
"ts:check": "tsc --jsx preserve --skipLibCheck",
|
||||||
|
|
@ -36,6 +37,8 @@
|
||||||
"prettier": "2.8.2",
|
"prettier": "2.8.2",
|
||||||
"prettier-plugin-astro": "0.7.2",
|
"prettier-plugin-astro": "0.7.2",
|
||||||
"prettier-plugin-tailwindcss": "0.2.1",
|
"prettier-plugin-tailwindcss": "0.2.1",
|
||||||
|
"puppeteer": "19.5.2",
|
||||||
|
"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",
|
||||||
"typescript": "4.9.4"
|
"typescript": "4.9.4"
|
||||||
|
|
|
||||||
54
scripts/generate-cv.cjs
Normal file
54
scripts/generate-cv.cjs
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
const { exec } = require('node:child_process');
|
||||||
|
const path = require('node:path');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const puppeteer = require('puppeteer');
|
||||||
|
const report = require('puppeteer-report');
|
||||||
|
|
||||||
|
const waitFor = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
const retry = async ({ promise, retries, retryTime }) => {
|
||||||
|
try {
|
||||||
|
return await promise();
|
||||||
|
} catch (error) {
|
||||||
|
if (retries <= 0) throw error;
|
||||||
|
|
||||||
|
await waitFor(retryTime);
|
||||||
|
|
||||||
|
return await retry({ promise, retries: retries - 1, retryTime });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
path: path.join(__dirname, '..', 'public', 'cv.pdf'),
|
||||||
|
format: 'A4',
|
||||||
|
printBackground: true,
|
||||||
|
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 child = exec('npm run dev');
|
||||||
|
|
||||||
|
const browser = await puppeteer.launch({ headless: true });
|
||||||
|
|
||||||
|
const page = await browser.newPage();
|
||||||
|
|
||||||
|
await page.setViewport({ width: 794, height: 1122, deviceScaleFactor: 2 });
|
||||||
|
|
||||||
|
await retry({
|
||||||
|
promise: () => page.goto(url, { waitUntil: 'networkidle0' }),
|
||||||
|
retries: 5,
|
||||||
|
retryTime: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await report.pdfPage(page, config);
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
|
||||||
|
child.kill();
|
||||||
|
};
|
||||||
|
|
||||||
|
main();
|
||||||
|
|
@ -7,9 +7,10 @@ export interface Props {
|
||||||
src: Photo;
|
src: Photo;
|
||||||
alt: string;
|
alt: string;
|
||||||
class?: string;
|
class?: string;
|
||||||
|
loading?: 'eager' | 'lazy';
|
||||||
}
|
}
|
||||||
|
|
||||||
const { src, alt } = Astro.props;
|
const { src, alt, loading } = Astro.props;
|
||||||
const className = Astro.props.class ?? '';
|
const className = Astro.props.class ?? '';
|
||||||
|
|
||||||
const isRemoteImage = typeof src === 'string';
|
const isRemoteImage = typeof src === 'string';
|
||||||
|
|
@ -19,6 +20,6 @@ const isRemoteImage = typeof src === 'string';
|
||||||
isRemoteImage ? (
|
isRemoteImage ? (
|
||||||
<img class={className} src={src} alt={alt} />
|
<img class={className} src={src} alt={alt} />
|
||||||
) : (
|
) : (
|
||||||
<Image class={className} format="webp" fit={'cover'} src={src} alt={alt} />
|
<Image class={className} format="webp" fit={'cover'} src={src} alt={alt} loading={loading} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
---
|
---
|
||||||
|
import getDateFormatter from '@/utils/date-formatter';
|
||||||
import Typography from './typography.astro';
|
import Typography from './typography.astro';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
|
|
@ -8,8 +9,7 @@ export interface Props {
|
||||||
translationForNow: string;
|
translationForNow: string;
|
||||||
}
|
}
|
||||||
const { startDate, endDate, locale, translationForNow } = Astro.props;
|
const { startDate, endDate, locale, translationForNow } = Astro.props;
|
||||||
const getFormattedDate = (date: Date): string =>
|
const getFormattedDate = getDateFormatter(locale);
|
||||||
new Intl.DateTimeFormat(locale, { month: 'long' }).format(date).concat(' ', date.getFullYear().toString());
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<Typography variant="item-subtitle">
|
<Typography variant="item-subtitle">
|
||||||
|
|
|
||||||
|
|
@ -12,16 +12,23 @@ const mainData: MainSection = {
|
||||||
role: 'Senior React Developer',
|
role: 'Senior React Developer',
|
||||||
details: [
|
details: [
|
||||||
{ label: 'Phone', value: '+48 604 343 212' },
|
{ label: 'Phone', value: '+48 604 343 212' },
|
||||||
{ label: 'Email', value: 'veeeery.long.email.address@gmail.com' },
|
{ label: 'Email', value: 'mark.freeman.dev@gmail.com' },
|
||||||
{ label: 'From', value: 'Warsaw, Poland' },
|
{ label: 'From', value: 'Warsaw, Poland' },
|
||||||
{ label: 'Salary range', value: '18 000 - 25 000 PLN' },
|
{ label: 'Salary range', value: '18 000 - 25 000 PLN' },
|
||||||
],
|
],
|
||||||
|
pdfDetails: [
|
||||||
|
{ label: 'Phone', value: '+48 604 343 212' },
|
||||||
|
{ label: 'Email', value: 'mark.freeman.dev@gmail.com' },
|
||||||
|
{ label: 'LinkedIn', value: '/in/mark-freeman', url: 'https://linkedin.com' },
|
||||||
|
{ label: 'GitHub', value: '/mark-freeman', url: 'https://github.com' },
|
||||||
|
{ label: 'Website', value: 'mark-freeman-personal-website.com', url: '#', fullRow: true },
|
||||||
|
],
|
||||||
description:
|
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.',
|
'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' }],
|
tags: [{ name: 'Open for freelance' }, { name: 'Available for mentoring' }, { name: 'Working on side project' }],
|
||||||
action: {
|
action: {
|
||||||
label: 'Download CV',
|
label: 'Download CV',
|
||||||
url: '#',
|
url: '/cv.pdf',
|
||||||
},
|
},
|
||||||
socials: [facebook('#'), github('#'), linkedin('#'), twitter('#')],
|
socials: [facebook('#'), github('#'), linkedin('#'), twitter('#')],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ const portfolioData: PortfolioSection = {
|
||||||
config: {
|
config: {
|
||||||
title: 'Projects',
|
title: 'Projects',
|
||||||
icon: 'fa6-solid:rocket',
|
icon: 'fa6-solid:rocket',
|
||||||
filter: null,
|
|
||||||
},
|
},
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
|
|
@ -36,6 +35,10 @@ const portfolioData: PortfolioSection = {
|
||||||
{ label: 'Company', value: 'None' },
|
{ label: 'Company', value: 'None' },
|
||||||
{ label: 'Category', value: ['Web app', 'Open source'] },
|
{ label: 'Category', value: ['Web app', 'Open source'] },
|
||||||
],
|
],
|
||||||
|
pdfDetails: [
|
||||||
|
{ label: 'Demo', value: 'https://golden-bulls-d73jd7.netlify.app', url: '#' },
|
||||||
|
{ label: 'Repository', value: 'https://github.com/mark-freeman/golden-bulls', url: '#' },
|
||||||
|
],
|
||||||
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()],
|
tags: [nextJs(), sass(), pnpm(), eslint(), prettier()],
|
||||||
|
|
@ -52,6 +55,10 @@ const portfolioData: PortfolioSection = {
|
||||||
{ label: 'Company', value: 'Facebook' },
|
{ label: 'Company', value: 'Facebook' },
|
||||||
{ label: 'Category', value: ['Web app', 'Mobile app'] },
|
{ label: 'Category', value: ['Web app', 'Mobile app'] },
|
||||||
],
|
],
|
||||||
|
pdfDetails: [
|
||||||
|
{ label: 'Demo', value: 'https://tru-quest-ck7ea3.netlify.app', url: '#' },
|
||||||
|
{ label: 'Repository', value: 'https://github.com/mark-freeman/tru-quest', url: '#' },
|
||||||
|
],
|
||||||
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()],
|
tags: [react(), tailwindCss(), nestJs(), postgreSql()],
|
||||||
|
|
@ -68,6 +75,10 @@ const portfolioData: PortfolioSection = {
|
||||||
{ label: 'Company', value: 'None' },
|
{ label: 'Company', value: 'None' },
|
||||||
{ label: 'Category', value: ['Web app', 'Open source'] },
|
{ label: 'Category', value: ['Web app', 'Open source'] },
|
||||||
],
|
],
|
||||||
|
pdfDetails: [
|
||||||
|
{ label: 'Demo', value: 'https://software-chasers-e82l8e.netlify.app', url: '#' },
|
||||||
|
{ label: 'Repository', value: 'https://github.com/mark-freeman/software-chasers', url: '#' },
|
||||||
|
],
|
||||||
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()],
|
tags: [react(), chakraUi(), typescript(), nx(), pnpm()],
|
||||||
|
|
@ -84,6 +95,10 @@ const portfolioData: PortfolioSection = {
|
||||||
{ label: 'Company', value: 'Google' },
|
{ label: 'Company', value: 'Google' },
|
||||||
{ label: 'Category', value: ['Mobile app', 'Open source'] },
|
{ label: 'Category', value: ['Mobile app', 'Open source'] },
|
||||||
],
|
],
|
||||||
|
pdfDetails: [
|
||||||
|
{ label: 'Demo', value: 'https://disco-ninjas-g321ol.netlify.app', url: '#' },
|
||||||
|
{ label: 'Repository', value: 'https://github.com/mark-freeman/disco-ninjas', url: '#' },
|
||||||
|
],
|
||||||
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()],
|
tags: [typescript(), jest(), firebase()],
|
||||||
|
|
|
||||||
29
src/pages/pdf.astro
Normal file
29
src/pages/pdf.astro
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
---
|
||||||
|
import Footer from '@/pdf/footer.astro';
|
||||||
|
import EducationSection from '@/pdf/sections/education-section.pdf.astro';
|
||||||
|
import ExperienceSection from '@/pdf/sections/experience-section.pdf.astro';
|
||||||
|
import MainSection from '@/pdf/sections/main-section.pdf.astro';
|
||||||
|
import PortfolioSection from '@/pdf/sections/portfolio-section.pdf.astro';
|
||||||
|
import SkillsSection from '@/pdf/sections/skills-section.pdf.astro';
|
||||||
|
|
||||||
|
import data from '../data';
|
||||||
|
|
||||||
|
const { i18n } = data;
|
||||||
|
---
|
||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width" />
|
||||||
|
<title>PDF preview</title>
|
||||||
|
</head>
|
||||||
|
<body class="flex flex-col bg-white p-[10mm] print:p-0">
|
||||||
|
<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} />}
|
||||||
|
<Footer />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3
src/pdf/components/dashed-divider.astro
Normal file
3
src/pdf/components/dashed-divider.astro
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
<div class="flex gap-1.5 overflow-hidden py-5 last:hidden">
|
||||||
|
{[...Array(40).keys()].map(() => <hr class="min-w-[12px] bg-gray-200" />)}
|
||||||
|
</div>
|
||||||
25
src/pdf/components/date-range-tag.astro
Normal file
25
src/pdf/components/date-range-tag.astro
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
---
|
||||||
|
import type { I18n } from '@/types/i18n';
|
||||||
|
import getDateFormatter from '@/utils/date-formatter';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
i18n: I18n;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date | null;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { startDate, endDate, i18n, ...props } = Astro.props;
|
||||||
|
|
||||||
|
const getFormattedDate = getDateFormatter(i18n.locale);
|
||||||
|
---
|
||||||
|
|
||||||
|
<div
|
||||||
|
class:list={[
|
||||||
|
'flex h-6 w-fit items-center whitespace-nowrap rounded bg-gray-800 px-2.5 text-sm font-medium text-white',
|
||||||
|
props.class,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{getFormattedDate(startDate)} -{' '}
|
||||||
|
{endDate ? getFormattedDate(endDate) : i18n.translations.now}
|
||||||
|
</div>
|
||||||
21
src/pdf/components/description.astro
Normal file
21
src/pdf/components/description.astro
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
---
|
||||||
|
export interface Props {
|
||||||
|
content: string | string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { content } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="text-base font-normal text-gray-500">
|
||||||
|
{
|
||||||
|
Array.isArray(content) ? (
|
||||||
|
<ul class="list-disc pl-5">
|
||||||
|
{content.map((line) => (
|
||||||
|
<li>{line}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<div class="text-justify">{content}</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
22
src/pdf/components/labelled-value.astro
Normal file
22
src/pdf/components/labelled-value.astro
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
---
|
||||||
|
import type { PdfDetail } from '@/types/common';
|
||||||
|
|
||||||
|
export interface Props extends PdfDetail {
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { label, value, url, ...props } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class:list={['flex gap-1 text-base font-normal text-gray-500', props.class]}>
|
||||||
|
<div class="text-base font-medium text-gray-700">{label}:</div>
|
||||||
|
{
|
||||||
|
url ? (
|
||||||
|
<a href={url} class="underline">
|
||||||
|
{value}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<div>{value}</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
22
src/pdf/components/list-item-heading.astro
Normal file
22
src/pdf/components/list-item-heading.astro
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
---
|
||||||
|
import type { I18n } from '@/types/i18n';
|
||||||
|
import DateRangeTag from './date-range-tag.astro';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date | null;
|
||||||
|
i18n: I18n;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, subtitle, i18n, startDate, endDate } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="text-lg font-extrabold text-gray-900">{title}</div>
|
||||||
|
<DateRangeTag class="mt-0.5" i18n={i18n} startDate={startDate} endDate={endDate} />
|
||||||
|
</div>
|
||||||
|
{subtitle && <div class="text-md -mt-0.5 font-medium text-gray-700">{subtitle}</div>}
|
||||||
|
</div>
|
||||||
4
src/pdf/components/section-heading.astro
Normal file
4
src/pdf/components/section-heading.astro
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<div class="flex items-center gap-4 pt-10 pb-5">
|
||||||
|
<div class="whitespace-nowrap text-2xl font-extrabold text-gray-900"><slot /></div>
|
||||||
|
<hr class="w-full bg-gray-300" />
|
||||||
|
</div>
|
||||||
15
src/pdf/components/tags-list.astro
Normal file
15
src/pdf/components/tags-list.astro
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
---
|
||||||
|
import type { Tag } from '@/types/common';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
tags: Tag[];
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { tags, label } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="text-base">
|
||||||
|
<span class="font-medium text-gray-700">{label}:</span>
|
||||||
|
<span class="font-normal text-gray-500">{tags.map((t) => t.name).join(', ')}</span>
|
||||||
|
</div>
|
||||||
12
src/pdf/footer.astro
Normal file
12
src/pdf/footer.astro
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<div id="footer" class="mt-4 w-full rounded border border-gray-100 bg-gray-50 px-2 py-1 text-center text-[11px]">
|
||||||
|
I hereby give consent for my personal data included in my application to be processed for the purposes of the
|
||||||
|
recruitment process.
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const footer = document.getElementById('footer')!;
|
||||||
|
const withClause = window.location.search.includes('clause');
|
||||||
|
|
||||||
|
if (!withClause) {
|
||||||
|
footer.remove();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
35
src/pdf/sections/education-section.pdf.astro
Normal file
35
src/pdf/sections/education-section.pdf.astro
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
---
|
||||||
|
import type { EducationSection } from '@/types/education-section';
|
||||||
|
import type { I18n } from '@/types/i18n';
|
||||||
|
import DashedDivider from '../components/dashed-divider.astro';
|
||||||
|
import Description from '../components/description.astro';
|
||||||
|
import ListItemHeading from '../components/list-item-heading.astro';
|
||||||
|
import SectionHeading from '../components/section-heading.astro';
|
||||||
|
|
||||||
|
export interface Props extends EducationSection {
|
||||||
|
i18n: I18n;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
config: { title },
|
||||||
|
educationItems,
|
||||||
|
i18n,
|
||||||
|
} = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<SectionHeading>{title}</SectionHeading>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
{
|
||||||
|
educationItems.map(({ title, description, institution, startDate, endDate }) => () => (
|
||||||
|
<>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<ListItemHeading title={title} subtitle={institution} startDate={startDate} endDate={endDate} i18n={i18n} />
|
||||||
|
<Description content={description} />
|
||||||
|
</div>
|
||||||
|
<DashedDivider />
|
||||||
|
</>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
38
src/pdf/sections/experience-section.pdf.astro
Normal file
38
src/pdf/sections/experience-section.pdf.astro
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
---
|
||||||
|
import type { ExperienceSection, Job } from '@/types/experience-section';
|
||||||
|
import type { I18n } from '@/types/i18n';
|
||||||
|
import DashedDivider from '../components/dashed-divider.astro';
|
||||||
|
import Description from '../components/description.astro';
|
||||||
|
import ListItemHeading from '../components/list-item-heading.astro';
|
||||||
|
import SectionHeading from '../components/section-heading.astro';
|
||||||
|
import TagsList from '../components/tags-list.astro';
|
||||||
|
|
||||||
|
export interface Props extends ExperienceSection {
|
||||||
|
jobs: Job[];
|
||||||
|
i18n: I18n;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
config: { title },
|
||||||
|
i18n,
|
||||||
|
jobs,
|
||||||
|
} = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<SectionHeading>{title}</SectionHeading>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
{
|
||||||
|
jobs.map(({ company, role, description, tags, startDate, endDate }) => () => (
|
||||||
|
<>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<ListItemHeading title={role} subtitle={company} startDate={startDate} endDate={endDate} i18n={i18n} />
|
||||||
|
<Description content={description} />
|
||||||
|
<TagsList label="Technologies" tags={tags} />
|
||||||
|
</div>
|
||||||
|
<DashedDivider />
|
||||||
|
</>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
29
src/pdf/sections/main-section.pdf.astro
Normal file
29
src/pdf/sections/main-section.pdf.astro
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
---
|
||||||
|
import Photo from '@/components/photo.astro';
|
||||||
|
import type { PdfDetail } from '@/types/common';
|
||||||
|
import type { MainSection } from '@/types/main-section';
|
||||||
|
import Description from '../components/description.astro';
|
||||||
|
import LabelledValue from '../components/labelled-value.astro';
|
||||||
|
|
||||||
|
export interface Props extends MainSection {}
|
||||||
|
|
||||||
|
const { image, fullName, role, pdfDetails, details, description } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<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" />
|
||||||
|
<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="grid grid-cols-[auto_auto] gap-x-4 gap-y-1 pt-4">
|
||||||
|
{
|
||||||
|
(pdfDetails || details).map((detail: PdfDetail) => (
|
||||||
|
<LabelledValue {...detail} class={detail.fullRow ? 'col-span-2' : undefined} />
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Description content={description} />
|
||||||
|
</div>
|
||||||
44
src/pdf/sections/portfolio-section.pdf.astro
Normal file
44
src/pdf/sections/portfolio-section.pdf.astro
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
---
|
||||||
|
import type { PdfDetail } from '@/types/common';
|
||||||
|
import type { I18n } from '@/types/i18n';
|
||||||
|
import type { PortfolioSection } from '@/types/portfolio-section';
|
||||||
|
import DashedDivider from '../components/dashed-divider.astro';
|
||||||
|
import Description from '../components/description.astro';
|
||||||
|
import LabelledValue from '../components/labelled-value.astro';
|
||||||
|
import ListItemHeading from '../components/list-item-heading.astro';
|
||||||
|
import SectionHeading from '../components/section-heading.astro';
|
||||||
|
import TagsList from '../components/tags-list.astro';
|
||||||
|
|
||||||
|
export interface Props extends PortfolioSection {
|
||||||
|
i18n: I18n;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
config: { title },
|
||||||
|
projects,
|
||||||
|
i18n,
|
||||||
|
} = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<SectionHeading>{title}</SectionHeading>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
{
|
||||||
|
projects.map(({ name, description, pdfDetails, details, tags, startDate, endDate }) => () => (
|
||||||
|
<>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<ListItemHeading title={name} startDate={startDate} endDate={endDate} i18n={i18n} />
|
||||||
|
<Description content={description} />
|
||||||
|
<div>
|
||||||
|
{(pdfDetails || details).map((detail: PdfDetail) => (
|
||||||
|
<LabelledValue {...detail} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<TagsList label="Technologies" tags={tags} />
|
||||||
|
</div>
|
||||||
|
<DashedDivider />
|
||||||
|
</>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
51
src/pdf/sections/skills-section.pdf.astro
Normal file
51
src/pdf/sections/skills-section.pdf.astro
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
---
|
||||||
|
import type { SkillsSection } from '@/types/skills-section';
|
||||||
|
import SectionHeading from '../components/section-heading.astro';
|
||||||
|
|
||||||
|
export interface Props extends SkillsSection {}
|
||||||
|
|
||||||
|
const {
|
||||||
|
config: { title },
|
||||||
|
skillSets,
|
||||||
|
} = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<SectionHeading>{title}</SectionHeading>
|
||||||
|
<div class="flex flex-col gap-5">
|
||||||
|
{
|
||||||
|
skillSets.map((skillSet) => (
|
||||||
|
<div>
|
||||||
|
<div class="text-base font-extrabold text-gray-900">{skillSet.title}</div>
|
||||||
|
<div class="mt-2 flex flex-wrap gap-3.5 text-sm text-gray-700">
|
||||||
|
{skillSet.skills.map((skill) => {
|
||||||
|
if ('level' in skill) {
|
||||||
|
return (
|
||||||
|
<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">{skill.name}</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(' - ')) {
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
{skill.name.split(' - ')[0]}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center bg-gray-200 pr-2.5 pl-2 font-normal">
|
||||||
|
{skill.name.split(' - ')[1]}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <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>
|
||||||
|
|
@ -7,6 +7,11 @@ export interface Detail {
|
||||||
value: string | string[];
|
value: string | string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PdfDetail extends Detail {
|
||||||
|
url?: string;
|
||||||
|
fullRow?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Social {
|
export interface Social {
|
||||||
name: string;
|
name: string;
|
||||||
icon: IconName;
|
icon: IconName;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import type { Detail, Photo, SectionConfig, Social, Tag } from './common';
|
import type { Detail, PdfDetail, Photo, SectionConfig, Social, Tag } from './common';
|
||||||
|
|
||||||
export interface MainSection {
|
export interface MainSection {
|
||||||
image: Photo;
|
image: Photo;
|
||||||
fullName: string;
|
fullName: string;
|
||||||
role: string;
|
role: string;
|
||||||
details: Detail[];
|
details: Detail[];
|
||||||
|
pdfDetails?: PdfDetail[];
|
||||||
description: string;
|
description: string;
|
||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
action: {
|
action: {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { Detail, Photo, SectionConfig, Social, Tag } from './common';
|
import type { Detail, PdfDetail, Photo, SectionConfig, Social, Tag } from './common';
|
||||||
|
|
||||||
export interface Project {
|
export interface Project {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -6,23 +6,12 @@ export interface Project {
|
||||||
startDate: Date;
|
startDate: Date;
|
||||||
endDate: Date | null;
|
endDate: Date | null;
|
||||||
details: Detail[];
|
details: Detail[];
|
||||||
|
pdfDetails?: PdfDetail[];
|
||||||
description: string;
|
description: string;
|
||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
socials: Social[];
|
socials: Social[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PortfolioFilter {
|
|
||||||
byDetail?: string[];
|
|
||||||
byTechnology?: boolean;
|
|
||||||
byStartDate?: boolean;
|
|
||||||
byEndDate?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PortfolioConfig extends SectionConfig {
|
|
||||||
filter: PortfolioFilter | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PortfolioSection {
|
export interface PortfolioSection {
|
||||||
projects: Project[];
|
projects: Project[];
|
||||||
config: PortfolioConfig;
|
config: SectionConfig;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
6
src/utils/date-formatter.ts
Normal file
6
src/utils/date-formatter.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
const getDateFormatter =
|
||||||
|
(locale: string) =>
|
||||||
|
(date: Date): string =>
|
||||||
|
new Intl.DateTimeFormat(locale, { month: 'long' }).format(date).concat(' ', date.getFullYear().toString());
|
||||||
|
|
||||||
|
export default getDateFormatter;
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
// Force functions designed to override their parent class to be specified as `override`.
|
// Force functions designed to override their parent class to be specified as `override`.
|
||||||
"noImplicitOverride": true,
|
"noImplicitOverride": true,
|
||||||
// Force functions to specify that they can return `undefined` if a possibe code path does not return a value.
|
// Force functions to specify that they can return `undefined` if some code path does not return a value.
|
||||||
"noImplicitReturns": true,
|
"noImplicitReturns": true,
|
||||||
// Report an error when a variable is declared but never used.
|
// Report an error when a variable is declared but never used.
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue