This commit is contained in:
@@ -103,7 +103,6 @@ npm run format # Format all files with Prettier
|
|||||||
- Dark mode via `light-dark()` CSS function
|
- Dark mode via `light-dark()` CSS function
|
||||||
- Cyan accent color for interactive elements
|
- Cyan accent color for interactive elements
|
||||||
- Zinc-based neutral colors
|
- Zinc-based neutral colors
|
||||||
- Print styles: `.no-print` class hides elements in PDF
|
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
|
|||||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -11,7 +11,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/roboto-condensed": "^5.2.8",
|
"@fontsource-variable/roboto-condensed": "^5.2.8",
|
||||||
"@fontsource/iosevka": "^5.2.5",
|
"@fontsource/iosevka": "^5.2.5",
|
||||||
"@sveltia/cms": "^0.140.3"
|
"@sveltia/cms": "^0.140.3",
|
||||||
|
"remeda": "^2.33.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-static": "^3.0.10",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
@@ -3581,6 +3582,15 @@
|
|||||||
"url": "https://paulmillr.com/funding/"
|
"url": "https://paulmillr.com/funding/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/remeda": {
|
||||||
|
"version": "2.33.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.6.tgz",
|
||||||
|
"integrity": "sha512-tazDGH7s75kUPGBKLvhgBEHMgW+TdDFhjUAMdQj57IoWz6HsGa5D2RX5yDUz6IIqiRRvZiaEHzCzWdTeixc/Kg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/remeda"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/require-directory": {
|
"node_modules/require-directory": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
|
|||||||
@@ -13,7 +13,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/roboto-condensed": "^5.2.8",
|
"@fontsource-variable/roboto-condensed": "^5.2.8",
|
||||||
"@fontsource/iosevka": "^5.2.5",
|
"@fontsource/iosevka": "^5.2.5",
|
||||||
"@sveltia/cms": "^0.140.3"
|
"@sveltia/cms": "^0.140.3",
|
||||||
|
"remeda": "^2.33.6"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"cv",
|
"cv",
|
||||||
|
|||||||
12
src/app.css
12
src/app.css
@@ -14,6 +14,18 @@ body {
|
|||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
body {
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
print-color-adjust: exact;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
-webkit-print-color-adjust: exact !important;
|
||||||
|
print-color-adjust: exact !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Theme configuration - 5 colors per layout */
|
/* Theme configuration - 5 colors per layout */
|
||||||
@theme {
|
@theme {
|
||||||
/* Font families */
|
/* Font families */
|
||||||
|
|||||||
@@ -10,6 +10,6 @@
|
|||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
%sveltekit.body%
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
9
src/lib/components/BracketLabel.svelte
Normal file
9
src/lib/components/BracketLabel.svelte
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { label }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span class="text-accent before:content-['['] after:content-[']']">{label}</span>
|
||||||
@@ -3,7 +3,11 @@
|
|||||||
import Section from './Section.svelte';
|
import Section from './Section.svelte';
|
||||||
import TimelineItem from './TimelineItem.svelte';
|
import TimelineItem from './TimelineItem.svelte';
|
||||||
|
|
||||||
let { education }: { education: Education[] } = $props();
|
interface Props {
|
||||||
|
education: Education[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let { education }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Section title="Education">
|
<Section title="Education">
|
||||||
@@ -15,7 +19,6 @@
|
|||||||
location={edu.location}
|
location={edu.location}
|
||||||
startDate={edu.startDate}
|
startDate={edu.startDate}
|
||||||
endDate={edu.endDate}
|
endDate={edu.endDate}
|
||||||
current={edu.current}
|
|
||||||
description={edu.description}
|
description={edu.description}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -3,7 +3,11 @@
|
|||||||
import Section from './Section.svelte';
|
import Section from './Section.svelte';
|
||||||
import TimelineItem from './TimelineItem.svelte';
|
import TimelineItem from './TimelineItem.svelte';
|
||||||
|
|
||||||
let { experience }: { experience: Experience[] } = $props();
|
interface Props {
|
||||||
|
experience: Experience[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let { experience }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Section title="Experience">
|
<Section title="Experience">
|
||||||
@@ -15,7 +19,6 @@
|
|||||||
location={job.location}
|
location={job.location}
|
||||||
startDate={job.startDate}
|
startDate={job.startDate}
|
||||||
endDate={job.endDate}
|
endDate={job.endDate}
|
||||||
current={job.current}
|
|
||||||
description={job.description}
|
description={job.description}
|
||||||
tags={job.technologies}
|
tags={job.technologies}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Profile } from '$lib/types.js';
|
import type { Profile } from '$lib/types.js';
|
||||||
|
import BracketLabel from './BracketLabel.svelte';
|
||||||
import EncodedEmail from './EncodedEmail.svelte';
|
import EncodedEmail from './EncodedEmail.svelte';
|
||||||
|
|
||||||
let { profile }: { profile: Profile } = $props();
|
interface Props {
|
||||||
|
profile: Profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { profile }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header class="border-b-2 border-fg/10 pb-8 mb-8">
|
<header class="border-b-2 border-fg/10 pb-8 mb-8">
|
||||||
@@ -28,12 +33,12 @@
|
|||||||
|
|
||||||
<div class="flex flex-col gap-2 text-sm">
|
<div class="flex flex-col gap-2 text-sm">
|
||||||
<div class="flex items-center gap-2 text-muted hover:text-accent transition-colors">
|
<div class="flex items-center gap-2 text-muted hover:text-accent transition-colors">
|
||||||
<span class="text-accent">[E]</span>
|
<BracketLabel label="E" />
|
||||||
<EncodedEmail email={profile.email} class="hover:text-accent transition-colors" />
|
<EncodedEmail email={profile.email} class="hover:text-accent transition-colors" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 text-muted">
|
<div class="flex items-center gap-2 text-muted">
|
||||||
<span class="text-accent">[L]</span>
|
<BracketLabel label="L" />
|
||||||
{profile.location}
|
{profile.location}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -43,7 +48,7 @@
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="flex items-center gap-2 text-muted hover:text-accent transition-colors"
|
class="flex items-center gap-2 text-muted hover:text-accent transition-colors"
|
||||||
>
|
>
|
||||||
<span class="text-accent">[G]</span>
|
<BracketLabel label="G" />
|
||||||
github.com/{profile.github}
|
github.com/{profile.github}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@@ -54,7 +59,7 @@
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="flex items-center gap-2 text-muted hover:text-accent transition-colors"
|
class="flex items-center gap-2 text-muted hover:text-accent transition-colors"
|
||||||
>
|
>
|
||||||
<span class="text-accent">[W]</span>
|
<BracketLabel label="W" />
|
||||||
{profile.website.replace(/^https?:\/\//, '')}
|
{profile.website.replace(/^https?:\/\//, '')}
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -4,24 +4,18 @@
|
|||||||
import PDFSection from './PDFSection.svelte';
|
import PDFSection from './PDFSection.svelte';
|
||||||
import PDFTags from './PDFTags.svelte';
|
import PDFTags from './PDFTags.svelte';
|
||||||
import PDFTimelineItem from './PDFTimelineItem.svelte';
|
import PDFTimelineItem from './PDFTimelineItem.svelte';
|
||||||
import profilePicture from '$lib/media/profile-picture.jpg';
|
|
||||||
import { MailIcon, MapPinIcon, GithubIcon, GlobeIcon } from 'svelte-feather-icons';
|
import { MailIcon, MapPinIcon, GithubIcon, GlobeIcon } from 'svelte-feather-icons';
|
||||||
|
|
||||||
let {
|
interface Props {
|
||||||
profile,
|
|
||||||
experience,
|
|
||||||
education,
|
|
||||||
skills,
|
|
||||||
ownProjects,
|
|
||||||
contributions,
|
|
||||||
}: {
|
|
||||||
profile: Profile;
|
profile: Profile;
|
||||||
experience: Experience[];
|
experience: Experience[];
|
||||||
education: Education[];
|
education: Education[];
|
||||||
skills: Skill[];
|
skills: Skill[];
|
||||||
ownProjects: Project[];
|
ownProjects: Project[];
|
||||||
contributions: Project[];
|
contributions: Project[];
|
||||||
} = $props();
|
}
|
||||||
|
|
||||||
|
let { profile, experience, education, skills, ownProjects, contributions }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -63,9 +57,15 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-full p-2 inset-shadow-sm">
|
{#if profile.profilePicture}
|
||||||
<img src={profilePicture} alt={profile.name} class="w-26 h-26 rounded-full object-cover" />
|
<div class="rounded-full p-2 inset-shadow-sm">
|
||||||
</div>
|
<img
|
||||||
|
src={profile.profilePicture}
|
||||||
|
alt={profile.name}
|
||||||
|
class="w-26 h-26 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let { title, projects, seeAllLink }: Props = $props();
|
let { title, projects, seeAllLink }: Props = $props();
|
||||||
$effect(() => console.log({ seeAllLink }));
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if projects.length > 0}
|
{#if projects.length > 0}
|
||||||
|
|||||||
@@ -3,11 +3,13 @@
|
|||||||
import ProjectList from './ProjectList.svelte';
|
import ProjectList from './ProjectList.svelte';
|
||||||
import Section from './Section.svelte';
|
import Section from './Section.svelte';
|
||||||
|
|
||||||
let {
|
interface Props {
|
||||||
ownProjects,
|
ownProjects: Project[];
|
||||||
contributions,
|
contributions: Project[];
|
||||||
username,
|
username: string;
|
||||||
}: { ownProjects: Project[]; contributions: Project[]; username: string } = $props();
|
}
|
||||||
|
|
||||||
|
let { ownProjects, contributions, username }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Section title="Projects">
|
<Section title="Projects">
|
||||||
|
|||||||
@@ -3,7 +3,11 @@
|
|||||||
import Section from './Section.svelte';
|
import Section from './Section.svelte';
|
||||||
import Tags from './Tags.svelte';
|
import Tags from './Tags.svelte';
|
||||||
|
|
||||||
let { skills }: { skills: Skill[] } = $props();
|
interface Props {
|
||||||
|
skills: Skill[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let { skills }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Section title="Skills">
|
<Section title="Skills">
|
||||||
|
|||||||
@@ -8,21 +8,11 @@
|
|||||||
location?: string;
|
location?: string;
|
||||||
startDate: string;
|
startDate: string;
|
||||||
endDate?: string;
|
endDate?: string;
|
||||||
current?: boolean;
|
|
||||||
description?: string;
|
description?: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let { title, subtitle, location, startDate, endDate, description, tags = [] }: Props = $props();
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
location,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
current = false,
|
|
||||||
description,
|
|
||||||
tags = [],
|
|
||||||
}: Props = $props();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<article>
|
<article>
|
||||||
@@ -41,7 +31,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<time class="text-sm text-muted font-mono whitespace-nowrap">
|
<time class="text-sm text-muted font-mono whitespace-nowrap">
|
||||||
{formatDate(startDate)} — {current || !endDate ? 'Present' : formatDate(endDate)}
|
{formatDate(startDate)} — {!endDate ? 'Present' : formatDate(endDate)}
|
||||||
</time>
|
</time>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
132
src/lib/content/config.ts
Normal file
132
src/lib/content/config.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import type { CmsConfig, FileCollection, Field, EntryCollection } from '@sveltia/cms';
|
||||||
|
import type { Profile, Experience, Education, Skill } from './types';
|
||||||
|
|
||||||
|
type RequiredKeys<T> = keyof {
|
||||||
|
[key in keyof T as undefined extends T[key] ? never : key]: T[key];
|
||||||
|
};
|
||||||
|
|
||||||
|
type StringWidget = 'string' | 'image' | 'datetime' | 'markdown' | 'text';
|
||||||
|
type ListWidget = 'list';
|
||||||
|
|
||||||
|
type Fields<T> = {
|
||||||
|
[key in keyof T]: Field & {
|
||||||
|
name: key;
|
||||||
|
widget: string extends T[key] ? StringWidget : T[key] extends string[] ? ListWidget : never;
|
||||||
|
} & (key extends RequiredKeys<T> ? { required?: true } : { required: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
const profileFields: Fields<Profile> = {
|
||||||
|
name: { name: 'name', label: 'Name', widget: 'string' },
|
||||||
|
title: { name: 'title', label: 'Title', widget: 'string' },
|
||||||
|
email: { name: 'email', label: 'Email', widget: 'string' },
|
||||||
|
phone: { name: 'phone', label: 'Phone', widget: 'string', required: false },
|
||||||
|
location: { name: 'location', label: 'Location', widget: 'string' },
|
||||||
|
website: { name: 'website', label: 'Website', widget: 'string', required: false },
|
||||||
|
github: { name: 'github', label: 'GitHub', widget: 'string' },
|
||||||
|
avatar: { name: 'avatar', label: 'Avatar', widget: 'image', required: false },
|
||||||
|
profilePicture: {
|
||||||
|
name: 'profilePicture',
|
||||||
|
label: 'Profile Picture (for PDF)',
|
||||||
|
widget: 'image',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
summary: { name: 'summary', label: 'Summary', widget: 'text', required: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
const experienceFields: Fields<Experience> = {
|
||||||
|
company: { name: 'company', label: 'Company', widget: 'string' },
|
||||||
|
position: { name: 'position', label: 'Position', widget: 'string' },
|
||||||
|
location: { name: 'location', label: 'Location', widget: 'string', required: false },
|
||||||
|
startDate: { name: 'startDate', label: 'Start Date', widget: 'datetime', time_format: false },
|
||||||
|
endDate: {
|
||||||
|
name: 'endDate',
|
||||||
|
label: 'End Date',
|
||||||
|
widget: 'datetime',
|
||||||
|
time_format: false,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
description: { name: 'description', label: 'Description', widget: 'markdown' },
|
||||||
|
technologies: {
|
||||||
|
name: 'technologies',
|
||||||
|
label: 'Technologies',
|
||||||
|
widget: 'list',
|
||||||
|
field: { label: 'Technology', widget: 'string' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const educationFields: Fields<Education> = {
|
||||||
|
institution: { name: 'institution', label: 'Institution', widget: 'string' },
|
||||||
|
degree: { name: 'degree', label: 'Degree', widget: 'string' },
|
||||||
|
field: { name: 'field', label: 'Field', widget: 'string', required: false },
|
||||||
|
location: { name: 'location', label: 'Location', widget: 'string', required: false },
|
||||||
|
startDate: { name: 'startDate', label: 'Start Date', widget: 'datetime', time_format: false },
|
||||||
|
endDate: {
|
||||||
|
name: 'endDate',
|
||||||
|
label: 'End Date',
|
||||||
|
widget: 'datetime',
|
||||||
|
time_format: false,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
description: { name: 'description', label: 'Description', widget: 'markdown', required: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
const skillFields: Fields<Skill> = {
|
||||||
|
category: { name: 'category', label: 'Category', widget: 'string' },
|
||||||
|
items: {
|
||||||
|
name: 'items',
|
||||||
|
label: 'Items',
|
||||||
|
widget: 'list',
|
||||||
|
field: { label: 'Item', widget: 'string' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const profile: FileCollection = {
|
||||||
|
name: 'profile',
|
||||||
|
label: 'Profile',
|
||||||
|
format: 'json',
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
name: 'profile',
|
||||||
|
label: 'Profile',
|
||||||
|
file: 'src/lib/content/profile.json',
|
||||||
|
fields: Object.values(profileFields),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const experience: EntryCollection = {
|
||||||
|
name: 'experience',
|
||||||
|
label: 'Experience',
|
||||||
|
format: 'json',
|
||||||
|
folder: 'src/lib/content/experience',
|
||||||
|
fields: Object.values(experienceFields),
|
||||||
|
};
|
||||||
|
|
||||||
|
const education: EntryCollection = {
|
||||||
|
name: 'education',
|
||||||
|
label: 'Education',
|
||||||
|
format: 'json',
|
||||||
|
folder: 'src/lib/content/education',
|
||||||
|
fields: Object.values(educationFields),
|
||||||
|
};
|
||||||
|
|
||||||
|
const skills: EntryCollection = {
|
||||||
|
name: 'skills',
|
||||||
|
label: 'Skills',
|
||||||
|
format: 'json',
|
||||||
|
folder: 'src/lib/content/skills',
|
||||||
|
fields: Object.values(skillFields),
|
||||||
|
};
|
||||||
|
|
||||||
|
const config: CmsConfig = {
|
||||||
|
load_config_file: false,
|
||||||
|
media_folder: 'src/lib/media',
|
||||||
|
backend: {
|
||||||
|
name: 'gitea',
|
||||||
|
app_id: 'a046b53c-787a-4b76-bd3a-633221a38954',
|
||||||
|
repo: 'repomaa/cv',
|
||||||
|
},
|
||||||
|
collections: [profile, experience, education, skills],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -1,41 +1,11 @@
|
|||||||
import type { CVData, Profile, Experience, Education, Skill } from './types.js';
|
import type { Experience, Education, Skill, Profile } from './types';
|
||||||
import { readFileSync, readdirSync } from 'fs';
|
|
||||||
import { join } from 'path';
|
|
||||||
|
|
||||||
const CONTENT_DIR = join(process.cwd(), 'src', 'lib', 'content');
|
async function loadJsonFilesFromGlob<T>(
|
||||||
|
modules: Record<string, () => Promise<{ default: T }>>
|
||||||
function loadJsonFile<T>(filepath: string): T | null {
|
): Promise<T[]> {
|
||||||
try {
|
return Promise.all(
|
||||||
const content = readFileSync(filepath, 'utf-8');
|
Object.entries(modules).map(async ([_, module]) => await module().then((m) => m.default))
|
||||||
return JSON.parse(content) as T;
|
);
|
||||||
} catch (error) {
|
|
||||||
console.warn(`Could not load ${filepath}:`, error instanceof Error ? error.message : error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadJsonFilesFromDir<T>(dirPath: string): T[] {
|
|
||||||
try {
|
|
||||||
const files = readdirSync(dirPath, { withFileTypes: true });
|
|
||||||
const items: T[] = [];
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
if (file.isFile() && file.name.endsWith('.json')) {
|
|
||||||
const item = loadJsonFile<T>(join(dirPath, file.name));
|
|
||||||
if (item) {
|
|
||||||
items.push(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(
|
|
||||||
`Could not read directory ${dirPath}:`,
|
|
||||||
error instanceof Error ? error.message : error
|
|
||||||
);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeBase64Email(email: string): string {
|
function decodeBase64Email(email: string): string {
|
||||||
@@ -46,42 +16,50 @@ function decodeBase64Email(email: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadProfile(): Profile {
|
export async function loadProfile() {
|
||||||
const profile = loadJsonFile<Profile>(join(CONTENT_DIR, 'profile.json')) ?? {
|
const profile: Profile = await import('$lib/content/profile.json');
|
||||||
name: 'Your Name',
|
const media = import.meta.glob<{ default: string }>('$lib/media/*');
|
||||||
title: 'Developer',
|
const profilePictureModule = await (profile.profilePicture
|
||||||
email: 'email@example.com',
|
? media[`/${profile.profilePicture}`]?.()
|
||||||
phone: undefined,
|
: undefined);
|
||||||
location: 'Location',
|
const avatarModule = await (profile.avatar ? media[`/${profile.avatar}`]?.() : undefined);
|
||||||
website: undefined,
|
|
||||||
github: 'username',
|
|
||||||
summary: 'A passionate developer.',
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...profile,
|
...profile,
|
||||||
email: decodeBase64Email(profile.email),
|
email: decodeBase64Email(profile.email),
|
||||||
|
profilePicture: profilePictureModule?.default,
|
||||||
|
avatar: avatarModule?.default,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadExperience(): Experience[] {
|
export async function loadExperience() {
|
||||||
return loadJsonFilesFromDir<Experience>(join(CONTENT_DIR, 'experience'));
|
return loadJsonFilesFromGlob(
|
||||||
|
import.meta.glob<{ default: Experience }>('$lib/content/experience/*.json')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadEducation(): Education[] {
|
export function loadEducation() {
|
||||||
return loadJsonFilesFromDir<Education>(join(CONTENT_DIR, 'education'));
|
return loadJsonFilesFromGlob(
|
||||||
|
import.meta.glob<{ default: Education }>('$lib/content/education/*.json')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadSkills(): Skill[] {
|
export function loadSkills() {
|
||||||
return loadJsonFilesFromDir<Skill>(join(CONTENT_DIR, 'skills'));
|
return loadJsonFilesFromGlob(import.meta.glob<{ default: Skill }>('$lib/content/skills/*.json'));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadAllContent(): CVData {
|
export async function loadAllContent() {
|
||||||
|
const [profile, experience, education, skills] = await Promise.all([
|
||||||
|
loadProfile(),
|
||||||
|
loadExperience(),
|
||||||
|
loadEducation(),
|
||||||
|
loadSkills(),
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
profile: loadProfile(),
|
profile,
|
||||||
experience: loadExperience(),
|
experience,
|
||||||
education: loadEducation(),
|
education,
|
||||||
skills: loadSkills(),
|
skills,
|
||||||
projects: [],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"location": "Espoo - Finnland",
|
"location": "Espoo - Finnland",
|
||||||
"website": "https://joakim.repomaa.com",
|
"website": "https://joakim.repomaa.com",
|
||||||
"github": "repomaa",
|
"github": "repomaa",
|
||||||
"avatar": "src/lib/media/avatar.jpg",
|
"avatar": "src/lib/media/avatar.png",
|
||||||
"profilePicture": "src/lib/media/profile-picture.jpg",
|
"profilePicture": "src/lib/media/profile-picture.jpg",
|
||||||
"summary": "Senior Full-Stack Engineer with deep expertise in Ruby on Rails backends, React/TypeScript frontends, and GraphQL/REST API design. Experienced in AI integration, third-party API integrations, and analyzing large-scale data systems. Fluent in German, Finnish, and English. Passionate about system architecture, performance optimization, clean code, and pragmatic technical decision-making in fast-paced product environments."
|
"summary": "Senior Full-Stack Engineer with deep expertise in Ruby on Rails backends, React/TypeScript frontends, and GraphQL/REST API design. Experienced in AI integration, third-party API integrations, and analyzing large-scale data systems. Fluent in German, Finnish, and English. Passionate about system architecture, performance optimization, clean code, and pragmatic technical decision-making in fast-paced product environments."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
// Content data types - clean interface definitions
|
|
||||||
|
|
||||||
export interface Profile {
|
export interface Profile {
|
||||||
name: string;
|
name: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -19,7 +17,6 @@ export interface Experience {
|
|||||||
location?: string;
|
location?: string;
|
||||||
startDate: string;
|
startDate: string;
|
||||||
endDate?: string;
|
endDate?: string;
|
||||||
current: boolean;
|
|
||||||
description: string;
|
description: string;
|
||||||
technologies: string[];
|
technologies: string[];
|
||||||
}
|
}
|
||||||
@@ -31,7 +28,6 @@ export interface Education {
|
|||||||
location?: string;
|
location?: string;
|
||||||
startDate: string;
|
startDate: string;
|
||||||
endDate?: string;
|
endDate?: string;
|
||||||
current: boolean;
|
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
|
import { filter, map, pipe, sortBy, take, uniqueBy } from 'remeda';
|
||||||
import type { Project } from './content/types.js';
|
import type { Project } from './content/types.js';
|
||||||
|
|
||||||
const GITHUB_API_BASE = 'https://api.github.com';
|
const GITHUB_API_BASE = 'https://api.github.com';
|
||||||
|
const MAX_RETRIES = 3;
|
||||||
|
const INITIAL_RETRY_DELAY = 1000;
|
||||||
|
const MAX_RETRY_DELAY = 10000;
|
||||||
|
const RATE_LIMIT_DELAY = 60000;
|
||||||
|
|
||||||
interface SearchIssue {
|
const EXCLUDED_OWNERS = new Set(['everii-Group', 'hundertzehn', 'meso-unimpressed']);
|
||||||
|
|
||||||
|
interface SearchItem {
|
||||||
repository_url: string;
|
repository_url: string;
|
||||||
pull_request?: {
|
pull_request?: { merged_at: string | null };
|
||||||
merged_at: string | null;
|
|
||||||
url: string;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SearchResponse {
|
interface SearchResponse {
|
||||||
items: SearchIssue[];
|
items: SearchItem[];
|
||||||
total_count: number;
|
total_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,211 +29,167 @@ interface RepoInfo {
|
|||||||
language: string | null;
|
language: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retry configuration
|
|
||||||
const MAX_RETRIES = 3;
|
|
||||||
const INITIAL_RETRY_DELAY = 1000; // 1 second
|
|
||||||
const MAX_RETRY_DELAY = 10000; // 10 seconds
|
|
||||||
const RATE_LIMIT_DELAY = 60000; // 1 minute for rate limit (403/429)
|
|
||||||
|
|
||||||
// Owners to exclude from contributed repos
|
|
||||||
const EXCLUDED_REPO_OWNERS = new Set(['everii-Group', 'hundertzehn', 'meso-unimpressed']);
|
|
||||||
|
|
||||||
function getHeaders(): Record<string, string> {
|
function getHeaders(): Record<string, string> {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
Accept: 'application/vnd.github.v3+json',
|
Accept: 'application/vnd.github.v3+json',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Use GitHub token if available (for higher rate limits during build)
|
|
||||||
const token = process.env.GITHUB_TOKEN;
|
const token = process.env.GITHUB_TOKEN;
|
||||||
if (token) {
|
if (token) {
|
||||||
headers.Authorization = `token ${token}`;
|
headers.Authorization = `token ${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exponential backoff retry for fetch
|
async function fetchWithRetry(url: string, retryCount = 0): Promise<Response> {
|
||||||
async function fetchWithRetry(
|
|
||||||
url: string,
|
|
||||||
options: RequestInit = {},
|
|
||||||
retryCount = 0
|
|
||||||
): Promise<Response> {
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, { ...options, headers: getHeaders() });
|
const response = await fetch(url, { headers: getHeaders() });
|
||||||
|
|
||||||
if (response.status === 429) {
|
if (response.status === 429 && retryCount < MAX_RETRIES) {
|
||||||
if (retryCount < MAX_RETRIES) {
|
const retryAfter = response.headers.get('retry-after');
|
||||||
const retryAfter = response.headers.get('retry-after');
|
const delay = retryAfter
|
||||||
const delay = retryAfter
|
? parseInt(retryAfter, 10) * 1000
|
||||||
? parseInt(retryAfter, 10) * 1000
|
: Math.min(RATE_LIMIT_DELAY, INITIAL_RETRY_DELAY * Math.pow(2, retryCount));
|
||||||
: Math.min(RATE_LIMIT_DELAY, INITIAL_RETRY_DELAY * Math.pow(2, retryCount));
|
|
||||||
|
|
||||||
console.warn(
|
console.warn(
|
||||||
`Rate limited for ${url}, waiting ${delay}ms before retry ${retryCount + 1}/${MAX_RETRIES}`
|
`Rate limited for ${url}, waiting ${delay}ms before retry ${retryCount + 1}/${MAX_RETRIES}`
|
||||||
);
|
);
|
||||||
return new Promise((resolve) =>
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
setTimeout(() => resolve(fetchWithRetry(url, options, retryCount + 1)), delay)
|
return fetchWithRetry(url, retryCount + 1);
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Network errors (timeout, connection refused, etc.)
|
|
||||||
if (retryCount < MAX_RETRIES) {
|
if (retryCount < MAX_RETRIES) {
|
||||||
const delay = Math.min(MAX_RETRY_DELAY, INITIAL_RETRY_DELAY * Math.pow(2, retryCount));
|
const delay = Math.min(MAX_RETRY_DELAY, INITIAL_RETRY_DELAY * Math.pow(2, retryCount));
|
||||||
console.warn(
|
console.warn(
|
||||||
`Network error for ${url}, retrying in ${delay}ms (${retryCount + 1}/${MAX_RETRIES}):`,
|
`Network error for ${url}, retrying in ${delay}ms (${retryCount + 1}/${MAX_RETRIES}):`,
|
||||||
error
|
error
|
||||||
);
|
);
|
||||||
return new Promise((resolve) =>
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
setTimeout(() => resolve(fetchWithRetry(url, options, retryCount + 1)), delay)
|
return fetchWithRetry(url, retryCount + 1);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleApiError(response: Response, context: string): null {
|
||||||
|
if (response.status === 403 || response.status === 429) {
|
||||||
|
console.warn(
|
||||||
|
`GitHub API rate limit exceeded for ${context}. Set GITHUB_TOKEN env var for higher limits.`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.error(`GitHub API error: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapRepoToProject(repo: {
|
||||||
|
name: string;
|
||||||
|
full_name?: string;
|
||||||
|
description: string | null;
|
||||||
|
html_url: string;
|
||||||
|
stargazers_count: number;
|
||||||
|
forks_count: number;
|
||||||
|
language: string | null;
|
||||||
|
}): Project {
|
||||||
|
return {
|
||||||
|
name: repo.full_name ?? repo.name,
|
||||||
|
description: repo.description ?? '',
|
||||||
|
url: repo.html_url,
|
||||||
|
stars: repo.stargazers_count,
|
||||||
|
forks: repo.forks_count,
|
||||||
|
language: repo.language ?? undefined,
|
||||||
|
isFork: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchGitHubProjects(username: string): Promise<Project[]> {
|
export async function fetchGitHubProjects(username: string): Promise<Project[]> {
|
||||||
try {
|
try {
|
||||||
// Use search API to filter non-forks and sort by stars
|
const query = encodeURIComponent(`user:${username} fork:false`);
|
||||||
const response = await fetchWithRetry(
|
const response = await fetchWithRetry(
|
||||||
`${GITHUB_API_BASE}/search/repositories?q=${encodeURIComponent(`user:${username} fork:false`)}&sort=stars&order=desc&per_page=100`,
|
`${GITHUB_API_BASE}/search/repositories?q=${query}&sort=stars&order=desc&per_page=6`
|
||||||
{}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (response.status === 403 || response.status === 429) {
|
handleApiError(response, 'user repos');
|
||||||
console.warn(
|
|
||||||
`GitHub API rate limit exceeded for user repos. Set GITHUB_TOKEN env var for higher limits.`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.error(`GitHub API error: ${response.status} ${response.statusText}`);
|
|
||||||
}
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchData = await response.json();
|
const data = await response.json();
|
||||||
|
return data.items.map(mapRepoToProject);
|
||||||
return searchData.items.map(
|
|
||||||
(repo: {
|
|
||||||
name: string;
|
|
||||||
description: string | null;
|
|
||||||
html_url: string;
|
|
||||||
stargazers_count: number;
|
|
||||||
forks_count: number;
|
|
||||||
language: string | null;
|
|
||||||
pushed_at: string;
|
|
||||||
}) => ({
|
|
||||||
name: repo.name,
|
|
||||||
description: repo.description ?? '',
|
|
||||||
url: repo.html_url,
|
|
||||||
stars: repo.stargazers_count,
|
|
||||||
forks: repo.forks_count,
|
|
||||||
language: repo.language ?? undefined,
|
|
||||||
isFork: false,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching GitHub projects:', error);
|
console.error('Error fetching GitHub projects:', error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRepoOwner(repoUrl: string): string | null {
|
||||||
|
const match = repoUrl.match(/\/repos\/([^\/]+)\/([^\/]+)$/);
|
||||||
|
return match?.[1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNotExcluded(item: SearchItem): boolean {
|
||||||
|
const owner = getRepoOwner(item.repository_url);
|
||||||
|
return owner !== null && !EXCLUDED_OWNERS.has(owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRepoAsProject(repoUrl: string, username: string): Promise<Project | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetchWithRetry(repoUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn(`Could not fetch repo ${repoUrl}: ${response.status}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const repo: RepoInfo = await response.json();
|
||||||
|
const [owner, name] = repo.full_name.split('/');
|
||||||
|
const prsUrl = `https://github.com/${owner}/${name}/pulls?q=is:pr+author:${encodeURIComponent(username)}+is:merged`;
|
||||||
|
|
||||||
|
return { ...mapRepoToProject(repo), url: prsUrl };
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Error fetching repo details for ${repoUrl}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchContributedRepos(username: string): Promise<Project[]> {
|
export async function fetchContributedRepos(username: string): Promise<Project[]> {
|
||||||
try {
|
try {
|
||||||
// Search for merged PRs by this user
|
const query = encodeURIComponent(`type:pr author:${username} is:merged`);
|
||||||
const searchResponse = await fetchWithRetry(
|
const response = await fetchWithRetry(
|
||||||
`${GITHUB_API_BASE}/search/issues?q=${encodeURIComponent(`type:pr author:${username} is:merged`)}&per_page=100`,
|
`${GITHUB_API_BASE}/search/issues?q=${query}&per_page=100`
|
||||||
{}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!searchResponse.ok) {
|
if (!response.ok) {
|
||||||
if (searchResponse.status === 403 || searchResponse.status === 429) {
|
handleApiError(response, 'search');
|
||||||
console.warn(
|
|
||||||
`GitHub Search API rate limit exceeded. Set GITHUB_TOKEN env var for higher limits.`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.error(
|
|
||||||
`GitHub Search API error: ${searchResponse.status} ${searchResponse.statusText}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchData: SearchResponse = await searchResponse.json();
|
const { total_count, items }: SearchResponse = await response.json();
|
||||||
|
if (!total_count || !items?.length) return [];
|
||||||
|
|
||||||
if (searchData.total_count === 0 || !searchData.items || searchData.items.length === 0) {
|
const repoUrls = pipe(
|
||||||
return [];
|
items,
|
||||||
}
|
filter(isNotExcluded),
|
||||||
|
uniqueBy((item) => item.repository_url),
|
||||||
// Extract unique repositories from closed PRs
|
map((item) => item.repository_url)
|
||||||
const repoData = new Map<string, { owner: string; name: string; stars: number }>();
|
|
||||||
for (const item of searchData.items) {
|
|
||||||
if (item.pull_request?.merged_at && item.repository_url) {
|
|
||||||
const repoUrl = item.repository_url;
|
|
||||||
if (!repoData.has(repoUrl)) {
|
|
||||||
// Parse owner and repo name from API URL: https://api.github.com/repos/owner/name
|
|
||||||
const match = repoUrl.match(/\/repos\/([^\/]+)\/([^\/]+)$/);
|
|
||||||
if (match) {
|
|
||||||
const [, owner, name] = match;
|
|
||||||
// Skip repos from excluded owners
|
|
||||||
if (!EXCLUDED_REPO_OWNERS.has(owner)) {
|
|
||||||
repoData.set(repoUrl, { owner, name, stars: 0 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (repoData.size === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const repos = await Promise.all(
|
|
||||||
[...repoData.entries()].map(async ([repoUrl, data]) => {
|
|
||||||
try {
|
|
||||||
const repoResponse = await fetchWithRetry(repoUrl, {});
|
|
||||||
|
|
||||||
if (!repoResponse.ok) {
|
|
||||||
console.warn(`Could not fetch repo ${repoUrl}: ${repoResponse.status}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const repo: RepoInfo = await repoResponse.json();
|
|
||||||
|
|
||||||
// Create URL to user's closed PRs in this repo
|
|
||||||
const prsUrl = `https://github.com/${data.owner}/${data.name}/pulls?q=is:pr+author:${encodeURIComponent(username)}+is:merged`;
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: repo.full_name,
|
|
||||||
description: repo.description ?? '',
|
|
||||||
url: prsUrl,
|
|
||||||
stars: repo.stargazers_count,
|
|
||||||
forks: repo.forks_count,
|
|
||||||
language: repo.language ?? undefined,
|
|
||||||
isFork: false,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`Error fetching repo details for ${repoUrl}:`, error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Sort by stars descending and take top 5
|
if (repoUrls.length === 0) return [];
|
||||||
return repos
|
|
||||||
.filter((repo) => repo !== null)
|
const projects = await Promise.all(repoUrls.map((url) => fetchRepoAsProject(url, username)));
|
||||||
.sort((a, b) => b.stars - a.stars)
|
|
||||||
.slice(0, 5);
|
return pipe(
|
||||||
|
projects,
|
||||||
|
filter((p): p is Project => p !== null),
|
||||||
|
sortBy([(p) => p.stars, 'desc']),
|
||||||
|
take(6)
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching contributed repos:', error);
|
console.error('Error fetching contributed repos:', error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to get top projects from array
|
|
||||||
export function getTopProjects(projects: Project[], limit: number): Project[] {
|
export function getTopProjects(projects: Project[], limit: number): Project[] {
|
||||||
return projects.slice(0, limit);
|
return projects.slice(0, limit);
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -1,6 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
import '../../app.css';
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="font-mono bg-background text-foreground">
|
<div class="font-mono bg-background text-foreground">
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
<a
|
<a
|
||||||
href="/joakim-repomaa-cv.pdf"
|
href="/joakim-repomaa-cv.pdf"
|
||||||
download
|
download
|
||||||
class="no-print fixed top-0 right-0 w-24 h-24 z-50 group"
|
class="fixed top-0 right-0 w-24 h-24 z-50 group"
|
||||||
style="clip-path: polygon(0 0, 100% 0, 100% 100%);"
|
style="clip-path: polygon(0 0, 100% 0, 100% 100%);"
|
||||||
data-sveltekit-preload-data="off"
|
data-sveltekit-preload-data="off"
|
||||||
aria-label="Download PDF"
|
aria-label="Download PDF"
|
||||||
@@ -51,12 +51,3 @@
|
|||||||
|
|
||||||
<Footer />
|
<Footer />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<style>
|
|
||||||
/* Ensure proper print styling */
|
|
||||||
@media print {
|
|
||||||
main {
|
|
||||||
background: white !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
21
src/routes/(content)/+layout.server.ts
Normal file
21
src/routes/(content)/+layout.server.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { loadAllContent } from '$lib/content/loader';
|
||||||
|
import { fetchContributedRepos, fetchGitHubProjects } from '$lib/github';
|
||||||
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: LayoutServerLoad = async () => {
|
||||||
|
const content = await loadAllContent();
|
||||||
|
|
||||||
|
const [ownProjects, contributions] = await Promise.all([
|
||||||
|
fetchGitHubProjects(content.profile.github),
|
||||||
|
fetchContributedRepos(content.profile.github),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
profile: content.profile,
|
||||||
|
experience: content.experience,
|
||||||
|
education: content.education,
|
||||||
|
skills: content.skills,
|
||||||
|
ownProjects,
|
||||||
|
contributions,
|
||||||
|
};
|
||||||
|
};
|
||||||
14
src/routes/(content)/print/+layout.svelte
Normal file
14
src/routes/(content)/print/+layout.svelte
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<script>
|
||||||
|
let { children } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main class="min-h-screen bg-pdf-bg text-pdf-fg">
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 15mm;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
18
src/routes/(content)/print/+page.svelte
Normal file
18
src/routes/(content)/print/+page.svelte
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import PDFContent from '$lib/components/PDFContent.svelte';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{data.profile.name} — CV</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<PDFContent
|
||||||
|
profile={data.profile}
|
||||||
|
experience={data.experience}
|
||||||
|
education={data.education}
|
||||||
|
skills={data.skills}
|
||||||
|
ownProjects={data.ownProjects}
|
||||||
|
contributions={data.contributions}
|
||||||
|
/>
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import type { PageServerLoad } from './$types.js';
|
|
||||||
import { loadAllContent } from '$lib/content/loader.js';
|
|
||||||
import { fetchGitHubProjects, fetchContributedRepos } from '$lib/github.js';
|
|
||||||
|
|
||||||
export const prerender = true;
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async () => {
|
|
||||||
const content = loadAllContent();
|
|
||||||
|
|
||||||
// Fetch GitHub projects and contributions in parallel
|
|
||||||
const [ownProjects, contributions] = await Promise.all([
|
|
||||||
fetchGitHubProjects(content.profile.github),
|
|
||||||
fetchContributedRepos(content.profile.github),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
profile: content.profile,
|
|
||||||
experience: content.experience,
|
|
||||||
education: content.education,
|
|
||||||
skills: content.skills,
|
|
||||||
ownProjects: ownProjects.slice(0, 6),
|
|
||||||
contributions: contributions.slice(0, 5),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<script>
|
|
||||||
let { children } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{@render children()}
|
|
||||||
@@ -1,113 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import config from '$lib/content/config';
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const CMS = await import('@sveltia/cms');
|
const CMS = await import('@sveltia/cms');
|
||||||
|
CMS.init({ config });
|
||||||
CMS.init({
|
|
||||||
config: {
|
|
||||||
load_config_file: false,
|
|
||||||
media_folder: 'src/lib/media',
|
|
||||||
backend: {
|
|
||||||
name: 'gitea',
|
|
||||||
app_id: 'a046b53c-787a-4b76-bd3a-633221a38954',
|
|
||||||
repo: 'repomaa/cv',
|
|
||||||
},
|
|
||||||
collections: [
|
|
||||||
{
|
|
||||||
name: 'profile',
|
|
||||||
label: 'Profile',
|
|
||||||
format: 'json',
|
|
||||||
files: [
|
|
||||||
{
|
|
||||||
name: 'profile',
|
|
||||||
label: 'Profile',
|
|
||||||
file: 'src/lib/content/profile.json',
|
|
||||||
fields: [
|
|
||||||
{ name: 'name', label: 'Name', widget: 'string' },
|
|
||||||
{ name: 'title', label: 'Title', widget: 'string' },
|
|
||||||
{ name: 'email', label: 'Email', widget: 'string' },
|
|
||||||
{ name: 'phone', label: 'Phone', widget: 'string', required: false },
|
|
||||||
{ name: 'location', label: 'Location', widget: 'string' },
|
|
||||||
{ name: 'website', label: 'Website', widget: 'string', required: false },
|
|
||||||
{ name: 'github', label: 'GitHub', widget: 'string' },
|
|
||||||
{ name: 'avatar', label: 'Avatar', widget: 'image', required: false },
|
|
||||||
{
|
|
||||||
name: 'profilePicture',
|
|
||||||
label: 'Profile Picture (for PDF)',
|
|
||||||
widget: 'image',
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
{ name: 'summary', label: 'Summary', widget: 'text' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'experience',
|
|
||||||
label: 'Experience',
|
|
||||||
format: 'json',
|
|
||||||
folder: 'src/lib/content/experience',
|
|
||||||
fields: [
|
|
||||||
{ name: 'company', label: 'Company', widget: 'string' },
|
|
||||||
{ name: 'position', label: 'Position', widget: 'string' },
|
|
||||||
{ name: 'location', label: 'Location', widget: 'string', required: false },
|
|
||||||
{ name: 'startDate', label: 'Start Date', widget: 'datetime', time_format: false },
|
|
||||||
{
|
|
||||||
name: 'endDate',
|
|
||||||
label: 'End Date',
|
|
||||||
widget: 'datetime',
|
|
||||||
time_format: false,
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
{ name: 'description', label: 'Description', widget: 'markdown' },
|
|
||||||
{
|
|
||||||
name: 'technologies',
|
|
||||||
label: 'Technologies',
|
|
||||||
widget: 'list',
|
|
||||||
field: { label: 'Technology', widget: 'string' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'education',
|
|
||||||
label: 'Education',
|
|
||||||
format: 'json',
|
|
||||||
folder: 'src/lib/content/education',
|
|
||||||
fields: [
|
|
||||||
{ name: 'institution', label: 'Institution', widget: 'string' },
|
|
||||||
{ name: 'degree', label: 'Degree', widget: 'string' },
|
|
||||||
{ name: 'field', label: 'Field', widget: 'string', required: false },
|
|
||||||
{ name: 'location', label: 'Location', widget: 'string', required: false },
|
|
||||||
{ name: 'startDate', label: 'Start Date', widget: 'datetime', time_format: false },
|
|
||||||
{
|
|
||||||
name: 'endDate',
|
|
||||||
label: 'End Date',
|
|
||||||
widget: 'datetime',
|
|
||||||
time_format: false,
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
{ name: 'description', label: 'Description', widget: 'markdown', required: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'skills',
|
|
||||||
label: 'Skills',
|
|
||||||
format: 'json',
|
|
||||||
folder: 'src/lib/content/skills',
|
|
||||||
fields: [
|
|
||||||
{ name: 'category', label: 'Category', widget: 'string' },
|
|
||||||
{
|
|
||||||
name: 'items',
|
|
||||||
label: 'Skills',
|
|
||||||
widget: 'list',
|
|
||||||
field: { label: 'Skill', widget: 'string' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { RequestHandler } from './$types.js';
|
import type { RequestHandler } from './$types.js';
|
||||||
import type { LaunchOptions } from 'puppeteer';
|
import type { LaunchOptions } from 'puppeteer';
|
||||||
|
import { dev } from '$app/environment';
|
||||||
import puppeteer from 'puppeteer';
|
import puppeteer from 'puppeteer';
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@@ -37,7 +38,22 @@ const getLaunchOptions = (): LaunchOptions => {
|
|||||||
return options;
|
return options;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ url, fetch }) => {
|
/**
|
||||||
|
* Prepares the print page HTML and returns the URL to use for PDF generation.
|
||||||
|
* During dev server, returns the dev server URL directly.
|
||||||
|
* During build time, copies assets to temp dir and returns file:// URL.
|
||||||
|
*/
|
||||||
|
const preparePrintPageUrl = async (
|
||||||
|
requestUrl: URL,
|
||||||
|
fetch: typeof globalThis.fetch
|
||||||
|
): Promise<string> => {
|
||||||
|
// In dev mode, just use the dev server URL
|
||||||
|
if (dev) {
|
||||||
|
const devUrl = new URL('/print/', requestUrl);
|
||||||
|
return devUrl.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build time: prepare files in temp directory
|
||||||
const tmpDir = await mkdtemp(path.join(tmpdir(), 'cv-pdf-genration-'));
|
const tmpDir = await mkdtemp(path.join(tmpdir(), 'cv-pdf-genration-'));
|
||||||
const tmpFile = (url: string) => {
|
const tmpFile = (url: string) => {
|
||||||
const filename = path.basename(url);
|
const filename = path.basename(url);
|
||||||
@@ -45,63 +61,70 @@ export const GET: RequestHandler = async ({ url, fetch }) => {
|
|||||||
return tempFile;
|
return tempFile;
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
const printResponse = await fetch('/print/');
|
||||||
// Launch browser
|
const html = await printResponse.text();
|
||||||
const browser = await puppeteer.launch(getLaunchOptions());
|
const $ = cheerio.load(html);
|
||||||
const page = await browser.newPage();
|
|
||||||
const printResponse = await fetch('/print/');
|
|
||||||
const html = await printResponse.text();
|
|
||||||
const $ = cheerio.load(html);
|
|
||||||
|
|
||||||
const fileDownloads: Record<string, string> = {};
|
const fileDownloads: Record<string, string> = {};
|
||||||
|
|
||||||
$('script[src], link[rel="stylesheet"], img[src]').each((i, el) => {
|
$('script[src], link[rel="stylesheet"], img[src]').each((i, el) => {
|
||||||
if (el.tagName === 'link') {
|
if (el.tagName === 'link') {
|
||||||
const href = $(el).attr('href');
|
const href = $(el).attr('href');
|
||||||
if (href) {
|
if (href) {
|
||||||
const tempFile = (fileDownloads[href] ||= tmpFile(href));
|
const tempFile = (fileDownloads[href] ||= tmpFile(href));
|
||||||
$(el).attr('href', `file://${tempFile}`);
|
$(el).attr('href', `file://${tempFile}`);
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const src = $(el).attr('src');
|
|
||||||
if (src) {
|
|
||||||
const tempFile = (fileDownloads[src] ||= tmpFile(src));
|
|
||||||
$(el).attr('src', `file://${tempFile}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
} else {
|
||||||
|
|
||||||
$('style[src]').each((i, el) => {
|
|
||||||
const src = $(el).attr('src');
|
const src = $(el).attr('src');
|
||||||
if (src) {
|
if (src) {
|
||||||
const tempFile = (fileDownloads[src] ||= tmpFile(src));
|
const tempFile = (fileDownloads[src] ||= tmpFile(src));
|
||||||
$(el).attr('src', `file://${tempFile}`);
|
$(el).attr('src', `file://${tempFile}`);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
$('style:not([src])').each((i, el) => {
|
|
||||||
const content = $(el).text();
|
|
||||||
$(el).text(
|
|
||||||
content.replaceAll(/(?<=url\(".+?)(?=")/g, (match) => {
|
|
||||||
const url = match[0];
|
|
||||||
const tempFile = (fileDownloads[url] ||= tmpFile(url));
|
|
||||||
return `file://${tempFile}`;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const dir = await opendir('.svelte-kit/output/client/_app/immutable/assets');
|
|
||||||
for await (const file of dir) {
|
|
||||||
await copyFile(path.join(file.parentPath, file.name), tmpFile(file.name));
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const htmlFile = path.join(tmpDir, 'index.html');
|
$('style[src]').each((i, el) => {
|
||||||
await writeFile(htmlFile, $.root().html() ?? '');
|
const src = $(el).attr('src');
|
||||||
|
if (src) {
|
||||||
|
const tempFile = (fileDownloads[src] ||= tmpFile(src));
|
||||||
|
$(el).attr('src', `file://${tempFile}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$('style:not([src])').each((i, el) => {
|
||||||
|
const content = $(el).text();
|
||||||
|
$(el).text(
|
||||||
|
content.replaceAll(/(?<=url\(".+?)(?=")/g, (match) => {
|
||||||
|
const url = match[0];
|
||||||
|
const tempFile = (fileDownloads[url] ||= tmpFile(url));
|
||||||
|
return `file://${tempFile}`;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const dir = await opendir('.svelte-kit/output/client/_app/immutable/assets');
|
||||||
|
for await (const file of dir) {
|
||||||
|
await copyFile(path.join(file.parentPath, file.name), tmpFile(file.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
const htmlFile = path.join(tmpDir, 'index.html');
|
||||||
|
await writeFile(htmlFile, $.root().html() ?? '');
|
||||||
|
|
||||||
|
return `file://${htmlFile}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url, fetch }) => {
|
||||||
|
try {
|
||||||
|
// Launch browser
|
||||||
|
const browser = await puppeteer.launch(getLaunchOptions());
|
||||||
|
const page = await browser.newPage();
|
||||||
|
|
||||||
|
const pageUrl = await preparePrintPageUrl(url, fetch);
|
||||||
|
|
||||||
// Navigate to the PDF page with increased timeout
|
// Navigate to the PDF page with increased timeout
|
||||||
// waitUntil: 'networkidle2' waits for 2 network connections to be idle
|
// waitUntil: 'networkidle2' waits for 2 network connections to be idle
|
||||||
// This is more lenient than 'networkidle0' which waits for 0 connections
|
// This is more lenient than 'networkidle0' which waits for 0 connections
|
||||||
await page.goto(`file://${htmlFile}`, { waitUntil: 'networkidle2' });
|
await page.goto(pageUrl, { waitUntil: 'networkidle2' });
|
||||||
// Wait for fonts to load
|
// Wait for fonts to load
|
||||||
await page.evaluateHandle('document.fonts.ready');
|
await page.evaluateHandle('document.fonts.ready');
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import type { PageServerLoad } from './$types.js';
|
|
||||||
import { loadAllContent } from '$lib/content/loader.js';
|
|
||||||
import { fetchGitHubProjects, fetchContributedRepos } from '$lib/github.js';
|
|
||||||
|
|
||||||
export const prerender = true;
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async () => {
|
|
||||||
const content = loadAllContent();
|
|
||||||
|
|
||||||
// Fetch GitHub projects and contributions at build time
|
|
||||||
const [ownProjects, contributions] = await Promise.all([
|
|
||||||
fetchGitHubProjects(content.profile.github),
|
|
||||||
fetchContributedRepos(content.profile.github),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
profile: content.profile,
|
|
||||||
experience: content.experience,
|
|
||||||
education: content.education,
|
|
||||||
skills: content.skills,
|
|
||||||
ownProjects: ownProjects.slice(0, 6),
|
|
||||||
contributions: contributions.slice(0, 5),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import '../../app.css';
|
|
||||||
import PDFContent from '$lib/components/PDFContent.svelte';
|
|
||||||
|
|
||||||
let { data } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>{data.profile.name} — CV (Print Version)</title>
|
|
||||||
<meta name="description" content="Printable CV for {data.profile.name}" />
|
|
||||||
|
|
||||||
<!-- Print-specific styles -->
|
|
||||||
<style>
|
|
||||||
@page {
|
|
||||||
size: A4;
|
|
||||||
margin: 15mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media print {
|
|
||||||
body {
|
|
||||||
background: white;
|
|
||||||
color: black;
|
|
||||||
-webkit-print-color-adjust: exact;
|
|
||||||
print-color-adjust: exact;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
-webkit-print-color-adjust: exact !important;
|
|
||||||
print-color-adjust: exact !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Explicitly hide skip link in PDF */
|
|
||||||
.skip-link,
|
|
||||||
.no-print {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<main class="min-h-screen bg-pdf-bg">
|
|
||||||
<PDFContent
|
|
||||||
profile={data.profile}
|
|
||||||
experience={data.experience}
|
|
||||||
education={data.education}
|
|
||||||
skills={data.skills}
|
|
||||||
ownProjects={data.ownProjects}
|
|
||||||
contributions={data.contributions}
|
|
||||||
/>
|
|
||||||
</main>
|
|
||||||
Reference in New Issue
Block a user