initial commit

This commit is contained in:
Joakim Repomaa
2026-02-17 02:15:25 +02:00
commit 72a636d175
55 changed files with 6171 additions and 0 deletions

13
src/routes/+layout.svelte Normal file
View File

@@ -0,0 +1,13 @@
<script>
let { children } = $props();
import '../app.css';
</script>
<div
class="font-mono bg-background text-foreground dark:bg-background-dark dark:text-shadow-foreground-dark"
>
<a href="#main-content" class="sr-only focus:not-sr-only focus:absolute">
Skip to main content
</a>
{@render children()}
</div>

View File

@@ -0,0 +1,24 @@
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),
};
};

71
src/routes/+page.svelte Normal file
View File

@@ -0,0 +1,71 @@
<script lang="ts">
import Header from '$lib/components/Header.svelte';
import Experience from '$lib/components/Experience.svelte';
import Education from '$lib/components/Education.svelte';
import Skills from '$lib/components/Skills.svelte';
import Projects from '$lib/components/Projects.svelte';
let { data } = $props();
</script>
<svelte:head>
<title>{data.profile.name}{data.profile.title}</title>
<meta name="description" content={data.profile.summary.substring(0, 160)} />
</svelte:head>
<main id="main-content" class="min-h-screen bg-bg">
<div class="max-w-5xl mx-auto px-6 py-12">
<Header profile={data.profile} />
<Experience experience={data.experience} />
<Education education={data.education} />
<Skills skills={data.skills} />
<Projects
ownProjects={data.ownProjects}
contributions={data.contributions}
username={data.profile.github}
/>
</div>
<a
href="/joakim-repomaa-cv.pdf"
download
class="no-print fixed top-0 right-0 w-24 h-24 z-50 group"
style="clip-path: polygon(0 0, 100% 0, 100% 100%);"
data-sveltekit-preload-data="off"
aria-label="Download PDF"
>
<div
class="absolute top-0 right-0 w-0 h-0 border-t-80 border-t-accent border-l-80 border-l-transparent group-hover:opacity-80 transition-opacity"
></div>
<span
class="absolute inset-0 flex items-start justify-end p-4 text-bg font-mono text-sm font-bold"
>
<span class="inline-block rotate-45 origin-center group-focus:ring ring-fg">PDF</span>
</span>
</a>
<footer class="max-w-5xl mx-auto mt-16 py-8 border-t border-fg/20 text-center text-sm text-muted">
<p class="font-mono">
<span class="text-accent">$</span>
Built with SvelteKit + Sveltia CMS
</p>
<p class="mt-2">
<a href="/admin/index.html" class="text-accent hover:text-accent/80 transition-colors">
Edit Content →
</a>
</p>
</footer>
</main>
<style>
/* Ensure proper print styling */
@media print {
main {
background: white !important;
}
}
</style>

View File

@@ -0,0 +1 @@
export const ssr = false;

View File

@@ -0,0 +1,105 @@
<script lang="ts">
import CMS from '@sveltia/cms';
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: '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>
<svelte:head>
<title>Content Manager</title>
</svelte:head>

View File

@@ -0,0 +1,111 @@
import type { RequestHandler } from './$types.js';
import type { LaunchOptions } from 'puppeteer';
import puppeteer from 'puppeteer';
// This is a dynamic endpoint - not prerenderable
export const prerender = false;
// PDF generation configuration
const PDF_CONFIG = {
format: 'A4' as const,
printBackground: true,
preferCSSPageSize: true,
margin: {
top: '20mm',
right: '20mm',
bottom: '20mm',
left: '20mm',
},
};
// Browser launch options for different environments
const getLaunchOptions = (): LaunchOptions => {
const chromePath = process.env.PUPPETEER_EXECUTABLE_PATH;
const options: LaunchOptions = {
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-accelerated-2d-canvas',
'--disable-gpu',
],
};
if (chromePath) {
options.executablePath = chromePath;
}
return options;
};
export const GET: RequestHandler = async ({ url }: { url: URL }) => {
// Check if Chrome/Puppeteer is available
const chromePath = process.env.PUPPETEER_EXECUTABLE_PATH;
const isCI = process.env.CI || process.env.CONTINUOUS_INTEGRATION;
if (!chromePath && !isCI) {
// Return 503 if Chrome is not available
return new Response(
JSON.stringify({
error: 'PDF generation not available',
message:
'Chrome/Chromium is not configured. Set PUPPETEER_EXECUTABLE_PATH environment variable.',
}),
{
status: 503,
headers: { 'Content-Type': 'application/json' },
}
);
}
try {
// Get the base URL for the current request
const baseUrl = `${url.protocol}//${url.host}`;
const pdfUrl = `${baseUrl}/print/`;
// Launch browser
const browser = await puppeteer.launch(getLaunchOptions());
const page = await browser.newPage();
// Navigate to the PDF page with increased timeout
// waitUntil: 'networkidle2' waits for 2 network connections to be idle
// This is more lenient than 'networkidle0' which waits for 0 connections
await page.goto(pdfUrl, {
waitUntil: 'networkidle2',
timeout: 120000, // Increased from 30s to 120s to handle slow GitHub API calls
});
// Wait for fonts to load
await page.evaluateHandle('document.fonts.ready');
// Generate PDF
const pdfBuffer = await page.pdf(PDF_CONFIG);
// Close browser
await browser.close();
// Create Blob from buffer for Response
const pdfBlob = new Blob([pdfBuffer as unknown as ArrayBuffer], { type: 'application/pdf' });
// Return PDF with appropriate headers
return new Response(pdfBlob, {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Cache-Control': 'public, max-age=3600', // Cache for 1 hour
},
});
} catch (error) {
console.error('PDF generation error:', error);
return new Response(
JSON.stringify({
error: 'Failed to generate PDF',
message: error instanceof Error ? error.message : 'Unknown error',
}),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
}
);
}
};

View File

@@ -0,0 +1,24 @@
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),
};
};

View File

@@ -0,0 +1,50 @@
<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>