Compare commits

...

13 Commits

Author SHA1 Message Date
Joakim Repomaa
83f0783a43 fix hover effect for footer links
All checks were successful
Build and Deploy / build (push) Successful in 1m44s
2026-02-20 10:47:18 +02:00
Joakim Repomaa
1970350916 improve contrast of muted text in light mode
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-02-20 10:45:09 +02:00
Joakim Repomaa
e2c7b86cf0 update gitea url
All checks were successful
Build and Deploy / build (push) Successful in 1m42s
2026-02-19 22:41:41 +02:00
Joakim Repomaa
66dd269601 update meta
All checks were successful
Build and Deploy / build (push) Successful in 1m44s
2026-02-19 22:34:01 +02:00
Joakim Repomaa
45b7903b59 update favicon
All checks were successful
Build and Deploy / build (push) Successful in 1m42s
2026-02-19 22:29:53 +02:00
Joakim Repomaa
f604e50172 fix pdf layout
All checks were successful
Build and Deploy / build (push) Successful in 1m43s
2026-02-19 22:21:44 +02:00
Joakim Repomaa
96171576c7 cleanup
Some checks failed
Build and Deploy / build (push) Failing after 19s
2026-02-19 22:10:44 +02:00
Joakim Repomaa
782f46f69f improve accessibility 2026-02-19 19:16:26 +02:00
Joakim Repomaa
1744466b2b improve pdf layout and add profile pics 2026-02-19 19:16:09 +02:00
Joakim Repomaa
60567717eb add vite devtools json 2026-02-19 17:39:45 +02:00
Joakim Repomaa
2f76f1ce38 improve content 2026-02-19 17:36:43 +02:00
Joakim Repomaa
aeecf3b87d improve layout and accessibility 2026-02-19 17:35:54 +02:00
Joakim Repomaa
b5b5b6139e update AGENTS.md 2026-02-19 16:47:21 +02:00
45 changed files with 687 additions and 623 deletions

View File

@@ -84,8 +84,8 @@ npm run format # Format all files with Prettier
### Content Loading ### Content Loading
- YAML files in `/content/` directory - JSON files in `/src/lib/content/` directory
- Use `loadYamlFile<T>()` pattern from `src/lib/content/loader.ts` - Use `loadJsonFile<T>()` pattern from `src/lib/content/loader.ts`
- Provide default/fallback values for missing content - Provide default/fallback values for missing content
- Async data fetching in `+page.server.ts` with `prerender = true` - Async data fetching in `+page.server.ts` with `prerender = true`
@@ -99,11 +99,10 @@ npm run format # Format all files with Prettier
### Styling Patterns ### Styling Patterns
- Terminal aesthetic with JetBrains Mono font - Terminal aesthetic with Iosevka font (monospace)
- 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
@@ -111,15 +110,17 @@ npm run format # Format all files with Prettier
src/ src/
lib/ lib/
components/ # Svelte components (PascalCase) components/ # Svelte components (PascalCase)
content/ # Content loading utilities content/ # Content loading utilities + JSON data
types.ts # TypeScript type definitions types.ts # TypeScript type re-exports
github.ts # GitHub API integration utils/ # Utility functions (date formatting, etc.)
routes/ routes/
(web)/ # Web layout group
+page.svelte # Main CV page +page.svelte # Main CV page
+page.server.ts # Server-side data loading +page.server.ts
pdf/ # Print-optimized PDF version +layout.svelte
print/ # Print-optimized PDF version
admin/ # Sveltia CMS admin panel
app.css # Global styles with @theme app.css # Global styles with @theme
content/ # YAML content files
static/ # Static assets (admin/, fonts) static/ # Static assets (admin/, fonts)
build/ # Output directory (static + cv.pdf) build/ # Output directory (static + cv.pdf)
``` ```
@@ -138,3 +139,11 @@ This project has no test framework configured. To add tests, consider:
- Use `devbox shell` on NixOS for Chrome support - Use `devbox shell` on NixOS for Chrome support
- PDF generation gracefully skips if Chrome unavailable (CI) - PDF generation gracefully skips if Chrome unavailable (CI)
- Static site deployable to any host (outputs to `/build/`) - Static site deployable to any host (outputs to `/build/`)
## Devbox (NixOS)
```bash
devbox shell # Enter devbox environment
devbox run dev # Run dev server via devbox
devbox run build # Build via devbox
```

63
package-lock.json generated
View File

@@ -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",
@@ -27,10 +28,12 @@
"puppeteer": "^24.37.3", "puppeteer": "^24.37.3",
"svelte": "^5.51.0", "svelte": "^5.51.0",
"svelte-check": "^4.1.0", "svelte-check": "^4.1.0",
"svelte-feather-icons": "^4.2.0",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"tslib": "^2.8.1", "tslib": "^2.8.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.3.1" "vite": "^7.3.1",
"vite-plugin-devtools-json": "^1.0.0"
} }
}, },
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
@@ -3579,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",
@@ -3963,6 +3975,26 @@
"typescript": ">=5.0.0" "typescript": ">=5.0.0"
} }
}, },
"node_modules/svelte-feather-icons": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/svelte-feather-icons/-/svelte-feather-icons-4.2.0.tgz",
"integrity": "sha512-KuMTDrL6sA8aCxBv3RMgmmnnyIaAXaYcmWkmNa2r2Qj70vi+An2T6ZBAdiZr6wjx+a3eZJy+FRseeRkzQFGHPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"svelte": "^3.38.2"
}
},
"node_modules/svelte-feather-icons/node_modules/svelte": {
"version": "3.59.2",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-3.59.2.tgz",
"integrity": "sha512-vzSyuGr3eEoAtT/A6bmajosJZIUWySzY2CzB3w2pgPvnkUjGqlDnsNnA0PMO+mMAhuyMul6C2uuZzY6ELSkzyA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "4.1.18", "version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
@@ -4113,6 +4145,20 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/uuid": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
"dev": true,
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "7.3.1", "version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
@@ -4188,6 +4234,19 @@
} }
} }
}, },
"node_modules/vite-plugin-devtools-json": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/vite-plugin-devtools-json/-/vite-plugin-devtools-json-1.0.0.tgz",
"integrity": "sha512-MobvwqX76Vqt/O4AbnNMNWoXWGrKUqZbphCUle/J2KXH82yKQiunOeKnz/nqEPosPsoWWPP9FtNuPBSYpiiwkw==",
"dev": true,
"license": "MIT",
"dependencies": {
"uuid": "^11.1.0"
},
"peerDependencies": {
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/vitefu": { "node_modules/vitefu": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz",

View File

@@ -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",
@@ -38,9 +39,11 @@
"puppeteer": "^24.37.3", "puppeteer": "^24.37.3",
"svelte": "^5.51.0", "svelte": "^5.51.0",
"svelte-check": "^4.1.0", "svelte-check": "^4.1.0",
"svelte-feather-icons": "^4.2.0",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"tslib": "^2.8.1", "tslib": "^2.8.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.3.1" "vite": "^7.3.1",
"vite-plugin-devtools-json": "^1.0.0"
} }
} }

View File

@@ -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 */
@@ -23,9 +35,9 @@ body {
/* Web layout - 5 colors using light-dark() for dark mode */ /* Web layout - 5 colors using light-dark() for dark mode */
--color-bg: light-dark(#fafafa, #0c0c0e); --color-bg: light-dark(#fafafa, #0c0c0e);
--color-fg: light-dark(#18181b, #fafafa); --color-fg: light-dark(#18181b, #fafafa);
--color-muted: light-dark(#71717a, #a1a1aa); --color-muted: light-dark(#56565d, #a1a1aa);
--color-accent: light-dark(#0891b2, #22d3ee); --color-accent: light-dark(#0e7490, #22d3ee);
--color-hot: light-dark(#ea580c, #fb923c); --color-hot: light-dark(#c2410c, #fb923c);
/* PDF layout - 4 colors (print-optimized) */ /* PDF layout - 4 colors (print-optimized) */
--color-pdf-bg: #ffffff; --color-pdf-bg: #ffffff;

View File

@@ -2,14 +2,17 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" /> <link rel="icon" type="image/png" sizes="32x32" href="%sveltekit.assets%/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="%sveltekit.assets%/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="%sveltekit.assets%/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="192x192" href="%sveltekit.assets%/icon-192x192.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Professional CV - Developer Portfolio" /> <meta name="description" content="Developer Portfolio" />
<meta name="theme-color" content="#0891b2" media="(prefers-color-scheme: light)" /> <meta name="theme-color" content="#0891b2" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#22d3ee" media="(prefers-color-scheme: dark)" /> <meta name="theme-color" content="#22d3ee" media="(prefers-color-scheme: dark)" />
%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>

View 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>

View File

@@ -3,10 +3,14 @@
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">
<div class="space-y-6"> <div class="space-y-6">
{#each education as edu} {#each education as edu}
<TimelineItem <TimelineItem
@@ -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}

View File

@@ -3,10 +3,14 @@
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">
<div class="space-y-8"> <div class="space-y-8">
{#each experience as job} {#each experience as job}
<TimelineItem <TimelineItem
@@ -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}
/> />

View File

@@ -0,0 +1,31 @@
<script lang="ts">
</script>
<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">
Built with
<a
href="https://svelte.dev"
target="_blank"
rel="noopener noreferrer"
class="text-accent hover:text-accent/80 transition-colors">SvelteKit</a
>
+
<a
href="https://sveltiacms.app/"
target="_blank"
rel="noopener noreferrer"
class="text-accent hover:text-accent/80 transition-colors">Sveltia CMS</a
>
</p>
<p class="mt-2">
<a href="/admin" class="text-accent hover:text-accent/80 transition-colors">edit</a>
<span class="mx-2 text-muted" aria-hidden="true">//</span>
<a
href="https://git.freun.dev/repomaa/cv"
target="_blank"
rel="noopener noreferrer"
class="text-accent hover:text-accent/80 transition-colors">src</a
>
</p>
</footer>

View File

@@ -1,19 +1,31 @@
<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">
<div class="flex flex-col md:flex-row md:items-start md:justify-between gap-6"> <div class="flex flex-col md:flex-row md:items-start md:justify-between gap-6">
<div class="flex-1"> <div class="flex-1">
<div class="flex items-start gap-6 mb-4">
{#if profile.avatar}
<img src={profile.avatar} alt={profile.name} class="w-20 h-20 object-cover" />
{/if}
<div>
<h1 class="text-4xl font-bold mb-2 tracking-tight text-fg"> <h1 class="text-4xl font-bold mb-2 tracking-tight text-fg">
{profile.name} {profile.name}
</h1> </h1>
<p class="text-xl text-muted mb-4 font-medium"> <p class="text-xl text-muted font-medium">
{profile.title} {profile.title}
</p> </p>
</div>
</div>
<p class="text-muted leading-relaxed max-w-2xl"> <p class="text-muted leading-relaxed max-w-2xl">
{profile.summary} {profile.summary}
</p> </p>
@@ -21,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>
@@ -36,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>
@@ -47,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}

View File

@@ -4,75 +4,65 @@
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 { 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 class="font-sans max-w-[210mm] mx-auto bg-pdf-bg text-pdf-fg leading-relaxed text-sm">
class="font-sans max-w-[210mm] mx-auto px-[20mm] py-[18mm] bg-pdf-bg text-pdf-fg leading-relaxed text-sm"
>
<!-- Header --> <!-- Header -->
<header class="text-center mb-6 pb-4 border-b-2 border-pdf-fg"> <header class="mb-6 pb-4 border-b-2 border-pdf-fg">
<div class="flex items-start justify-between gap-4">
<div> <div>
<h1 class="text-3xl font-extrabold tracking-tight text-pdf-fg leading-tight m-0"> <h1 class="text-3xl font-extrabold tracking-tight text-pdf-fg leading-tight m-0">
{profile.name} {profile.name}
</h1> </h1>
<p class="text-base font-medium text-pdf-muted mt-1.5 mb-3">{profile.title}</p> <p class="text-base font-medium text-pdf-muted mt-1.5 mb-3">{profile.title}</p>
</div> <div class="grid grid-cols-2 gap-x-6 gap-y-2 text-xs text-pdf-accent">
<div class="flex justify-center flex-wrap gap-x-6 gap-y-3 text-xs mb-2">
{#if profile.email} {#if profile.email}
<a class="inline-flex items-center gap-1.5 text-pdf-accent" href="mailto:{profile.email}"> <a class="inline-flex items-center gap-1.5" href="mailto:{profile.email}">
<svg <MailIcon class="w-3.5 h-3.5" />
class="w-3.5 h-3.5 text-pdf-accent"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
<polyline points="22,6 12,13 2,6" />
</svg>
{profile.email} {profile.email}
</a> </a>
{/if} {/if}
<span class="inline-flex items-center gap-1.5 text-pdf-muted"> <span class="inline-flex items-center gap-1.5 text-pdf-muted">
<svg <MapPinIcon class="w-3.5 h-3.5" />
class="w-3.5 h-3.5 text-pdf-accent"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
<circle cx="12" cy="10" r="3" />
</svg>
{profile.location} {profile.location}
</span> </span>
</div>
<div class="flex justify-center flex-wrap gap-x-5 gap-y-2 text-xs text-pdf-accent">
{#if profile.github} {#if profile.github}
<a href="https://github.com/{profile.github}" class="no-underline" <a
>github.com/{profile.github}</a href="https://github.com/{profile.github}"
class="no-underline inline-flex items-center gap-1.5"
> >
<GithubIcon class="w-3.5 h-3.5" />
github.com/{profile.github}
</a>
{/if} {/if}
{#if profile.website} {#if profile.website}
<a href={profile.website} class="no-underline" <a href={profile.website} class="no-underline inline-flex items-center gap-1.5">
>{profile.website.replace(/^https?:\/\//, '')}</a <GlobeIcon class="w-3.5 h-3.5" />
> {profile.website.replace(/^https?:\/\//, '')}
</a>
{/if}
</div>
</div>
{#if profile.profilePicture}
<div class="rounded-full p-2 inset-shadow-sm">
<img
src={profile.profilePicture}
alt={profile.name}
class="w-26 h-26 rounded-full object-cover"
/>
</div>
{/if} {/if}
</div> </div>
</header> </header>

View File

@@ -9,7 +9,7 @@
let { title, children }: Props = $props(); let { title, children }: Props = $props();
</script> </script>
<section class="mb-6 pb-3 border-b border-pdf-muted/30 last:border-b-0"> <section class="mb-6 pb-3 border-b border-pdf-muted/30 last:border-b-0 break-inside-avoid">
<h2 class="text-sm font-bold text-pdf-fg flex items-center uppercase tracking-wide"> <h2 class="text-sm font-bold text-pdf-fg flex items-center uppercase tracking-wide">
{title} {title}
</h2> </h2>

View File

@@ -13,6 +13,7 @@
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="block p-4 border border-accent rounded-lg transition-shadow hover:shadow-glow" class="block p-4 border border-accent rounded-lg transition-shadow hover:shadow-glow"
aria-label={project.name}
> >
<article class="flex flex-col justify-between h-full"> <article class="flex flex-col justify-between h-full">
<div> <div>
@@ -21,8 +22,10 @@
{project.name} {project.name}
</span> </span>
<div class="flex items-center gap-3 text-sm text-hot"> <div class="flex items-center gap-3 text-sm text-hot">
<span class="flex items-center gap-1" aria-label="{project.stars} stars"> <span
<span class="text-xl"></span> class="flex items-center gap-1 before:content-['★'] before:text-xl"
aria-label="{project.stars} stars"
>
{project.stars} {project.stars}
</span> </span>
</div> </div>

View File

@@ -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}

View File

@@ -3,14 +3,16 @@
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">
<ProjectList <ProjectList
title="Original Projects" title="Original Projects"
projects={ownProjects} projects={ownProjects}

View File

@@ -11,8 +11,10 @@
</script> </script>
<section class="pb-8 mb-10 border-b border-fg/20 last:border-b-0" aria-labelledby={headingId}> <section class="pb-8 mb-10 border-b border-fg/20 last:border-b-0" aria-labelledby={headingId}>
<h2 id={headingId} class="text-3xl font-bold mb-6 pb-2 text-fg"> <h2
<span class="text-accent">$></span> id={headingId}
class="text-3xl lowercase font-bold mb-6 pb-2 text-fg before:content-['$>_'] before:text-accent"
>
{title} {title}
</h2> </h2>

View File

@@ -3,10 +3,14 @@
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">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
{#each skills as skillCategory} {#each skills as skillCategory}
<div> <div>

View File

@@ -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>

134
src/lib/content/config.ts Normal file
View File

@@ -0,0 +1,134 @@
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',
base_url: 'https://git.freun.dev',
api_root: 'https://git.freun.dev/api/v1',
},
collections: [profile, experience, education, skills],
};
export default config;

View File

@@ -5,5 +5,5 @@
"location": "Wiesbaden, Germany", "location": "Wiesbaden, Germany",
"startDate": "2010", "startDate": "2010",
"endDate": "2016", "endDate": "2016",
"description": "Bachelor thesis: 'Collaborative Content Creation' - built a scalable CMS with real-time collaboration, hierarchical caching, and JSON-API using Crystal and Redux" "description": "Bachelor thesis: 'Collaborative Content Creation' - a scalable CMS with real-time collaboration, hierarchical caching, and JSON-API using Crystal and Redux"
} }

View File

@@ -1,9 +1,10 @@
{ {
"company": "hundertzehn GmbH", "company": "hundertzehn GmbH",
"position": "Senior Software Engineer", "position": "Senior Software Engineer",
"location": "Zürich, Schweiz", "location": "Zürich, Schweiz (remote)",
"startDate": "2021", "startDate": "2021",
"description": "Full-Stack Engineer for MOCO Cloud-ERP-SaaS serving 7'500+ SMEs. Develop Ruby-on-Rails backend, REST/GraphQL APIs, React/TypeScript frontends. Analyze large datasets, evaluate new technologies, integrate external APIs (Paywise), direct customer communication. Leading AI integration including Support Agent and MCP functionality. Key technical figure in SaaS product context ensuring platform stability at 60'000 daily active users.", "endDate": "",
"description": "Full-Stack Engineer for MOCO Cloud-ERP-SaaS serving 7'500+ SMEs. Ruby-on-Rails backend, REST/GraphQL APIs, React/TypeScript frontends. Analysis of large datasets, evaluation of new technologies, integration of external APIs, direct customer communication. Leading AI integration including Support Agent and MCP functionality. Key technical figure in SaaS product context ensuring platform stability at 60'000 daily active users.",
"technologies": [ "technologies": [
"Ruby on Rails", "Ruby on Rails",
"TypeScript", "TypeScript",

View File

@@ -4,7 +4,7 @@
"location": "Frankfurt, Germany", "location": "Frankfurt, Germany",
"startDate": "2014", "startDate": "2014",
"endDate": "2021", "endDate": "2021",
"description": "Developed software solutions for digital communication, IoT platforms, and cloud services. Key projects: Energybox - high-resolution big data IoT platform processing 1 billion+ data points daily with horizontally scalable sensor cloud backend, API, and frontend. HERE Technologies - experience platform and CMS for CES showcasing location intelligence. HfG Offenbach - database-driven university website with custom CMS for content management.", "description": "Software solutions for digital communication, IoT platforms, and cloud services. Key projects: Energybox - high-resolution big data IoT platform processing 1 billion+ data points daily with horizontally scalable sensor cloud backend, API, and frontend. HERE Technologies - CMS for CES experience platform showcasing location intelligence. HfG Offenbach - database-driven university website with custom CMS for content management.",
"technologies": [ "technologies": [
"Crystal", "Crystal",
"Ruby on Rails", "Ruby on Rails",

View File

@@ -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: [],
}; };
} }

View File

@@ -5,5 +5,7 @@
"location": "Espoo - Finnland", "location": "Espoo - Finnland",
"website": "https://joakim.repomaa.com", "website": "https://joakim.repomaa.com",
"github": "repomaa", "github": "repomaa",
"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, external API partnerships, 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." "avatar": "src/lib/media/avatar.png",
"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."
} }

View File

@@ -1,5 +1,3 @@
// Content data types - clean interface definitions
export interface Profile { export interface Profile {
name: string; name: string;
title: string; title: string;
@@ -9,6 +7,8 @@ export interface Profile {
website?: string; website?: string;
github: string; github: string;
summary: string; summary: string;
avatar?: string;
profilePicture?: string;
} }
export interface Experience { export interface Experience {
@@ -17,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[];
} }
@@ -29,7 +28,6 @@ export interface Education {
location?: string; location?: string;
startDate: string; startDate: string;
endDate?: string; endDate?: string;
current: boolean;
description?: string; description?: string;
} }

View File

@@ -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,40 +29,22 @@ 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
@@ -67,169 +53,143 @@ async function fetchWithRetry(
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;
} }
} }
export async function fetchGitHubProjects(username: string): Promise<Project[]> { function handleApiError(response: Response, context: string): null {
try {
// Use search API to filter non-forks and sort by stars
const response = await fetchWithRetry(
`${GITHUB_API_BASE}/search/repositories?q=${encodeURIComponent(`user:${username} fork:false`)}&sort=stars&order=desc&per_page=100`,
{}
);
if (!response.ok) {
if (response.status === 403 || response.status === 429) { if (response.status === 403 || response.status === 429) {
console.warn( console.warn(
`GitHub API rate limit exceeded for user repos. Set GITHUB_TOKEN env var for higher limits.` `GitHub API rate limit exceeded for ${context}. Set GITHUB_TOKEN env var for higher limits.`
); );
} else { } else {
console.error(`GitHub API error: ${response.status} ${response.statusText}`); console.error(`GitHub API error: ${response.status} ${response.statusText}`);
} }
return []; return null;
} }
const searchData = await response.json(); function mapRepoToProject(repo: {
return searchData.items.map(
(repo: {
name: string; name: string;
full_name?: string;
description: string | null; description: string | null;
html_url: string; html_url: string;
stargazers_count: number; stargazers_count: number;
forks_count: number; forks_count: number;
language: string | null; language: string | null;
pushed_at: string; }): Project {
}) => ({ return {
name: repo.name, name: repo.full_name ?? repo.name,
description: repo.description ?? '', description: repo.description ?? '',
url: repo.html_url, url: repo.html_url,
stars: repo.stargazers_count, stars: repo.stargazers_count,
forks: repo.forks_count, forks: repo.forks_count,
language: repo.language ?? undefined, language: repo.language ?? undefined,
isFork: false, isFork: false,
}) };
}
export async function fetchGitHubProjects(username: string): Promise<Project[]> {
try {
const query = encodeURIComponent(`user:${username} fork:false`);
const response = await fetchWithRetry(
`${GITHUB_API_BASE}/search/repositories?q=${query}&sort=stars&order=desc&per_page=6`
); );
if (!response.ok) {
handleApiError(response, 'user repos');
return [];
}
const data = await response.json();
return data.items.map(mapRepoToProject);
} catch (error) { } catch (error) {
console.error('Error fetching GitHub projects:', error); console.error('Error fetching GitHub projects:', error);
return []; return [];
} }
} }
export async function fetchContributedRepos(username: string): Promise<Project[]> { function getRepoOwner(repoUrl: string): string | null {
try {
// Search for merged PRs by this user
const searchResponse = await fetchWithRetry(
`${GITHUB_API_BASE}/search/issues?q=${encodeURIComponent(`type:pr author:${username} is:merged`)}&per_page=100`,
{}
);
if (!searchResponse.ok) {
if (searchResponse.status === 403 || searchResponse.status === 429) {
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 [];
}
const searchData: SearchResponse = await searchResponse.json();
if (searchData.total_count === 0 || !searchData.items || searchData.items.length === 0) {
return [];
}
// Extract unique repositories from closed PRs
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\/([^\/]+)\/([^\/]+)$/); const match = repoUrl.match(/\/repos\/([^\/]+)\/([^\/]+)$/);
if (match) { return match?.[1] ?? null;
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) { function isNotExcluded(item: SearchItem): boolean {
return []; const owner = getRepoOwner(item.repository_url);
return owner !== null && !EXCLUDED_OWNERS.has(owner);
} }
const repos = await Promise.all( async function fetchRepoAsProject(repoUrl: string, username: string): Promise<Project | null> {
[...repoData.entries()].map(async ([repoUrl, data]) => {
try { try {
const repoResponse = await fetchWithRetry(repoUrl, {}); const response = await fetchWithRetry(repoUrl);
if (!response.ok) {
if (!repoResponse.ok) { console.warn(`Could not fetch repo ${repoUrl}: ${response.status}`);
console.warn(`Could not fetch repo ${repoUrl}: ${repoResponse.status}`);
return null; return null;
} }
const repo: RepoInfo = await repoResponse.json(); 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`;
// Create URL to user's closed PRs in this repo return { ...mapRepoToProject(repo), url: prsUrl };
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) { } catch (error) {
console.warn(`Error fetching repo details for ${repoUrl}:`, error); console.warn(`Error fetching repo details for ${repoUrl}:`, error);
return null; return null;
} }
}) }
export async function fetchContributedRepos(username: string): Promise<Project[]> {
try {
const query = encodeURIComponent(`type:pr author:${username} is:merged`);
const response = await fetchWithRetry(
`${GITHUB_API_BASE}/search/issues?q=${query}&per_page=100`
); );
// Sort by stars descending and take top 5 if (!response.ok) {
return repos handleApiError(response, 'search');
.filter((repo) => repo !== null) return [];
.sort((a, b) => b.stars - a.stars) }
.slice(0, 5);
const { total_count, items }: SearchResponse = await response.json();
if (!total_count || !items?.length) return [];
const repoUrls = pipe(
items,
filter(isNotExcluded),
uniqueBy((item) => item.repository_url),
map((item) => item.repository_url)
);
if (repoUrls.length === 0) return [];
const projects = await Promise.all(repoUrls.map((url) => fetchRepoAsProject(url, username)));
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);
} }

BIN
src/lib/media/avatar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -1,11 +1,8 @@
<script> <script>
let { children } = $props(); let { children } = $props();
import '../../app.css';
</script> </script>
<div <div class="font-mono bg-background text-foreground">
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"> <a href="#main-content" class="sr-only focus:not-sr-only focus:absolute">
Skip to main content Skip to main content
</a> </a>

View File

@@ -4,6 +4,7 @@
import Education from '$lib/components/Education.svelte'; import Education from '$lib/components/Education.svelte';
import Skills from '$lib/components/Skills.svelte'; import Skills from '$lib/components/Skills.svelte';
import Projects from '$lib/components/Projects.svelte'; import Projects from '$lib/components/Projects.svelte';
import Footer from '$lib/components/Footer.svelte';
let { data } = $props(); let { data } = $props();
</script> </script>
@@ -33,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"
@@ -48,24 +49,5 @@
</span> </span>
</a> </a>
<footer class="max-w-5xl mx-auto mt-16 py-8 border-t border-fg/20 text-center text-sm text-muted"> <Footer />
<p class="font-mono">
<span class="text-accent">$</span>
Built with SvelteKit + Sveltia CMS
</p>
<p class="mt-2">
<a href="/admin" class="text-accent hover:text-accent/80 transition-colors">
Edit Content →
</a>
</p>
</footer>
</main> </main>
<style>
/* Ensure proper print styling */
@media print {
main {
background: white !important;
}
}
</style>

View 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,
};
};

View File

@@ -0,0 +1,13 @@
<script>
let { children } = $props();
</script>
<main class="min-h-screen bg-pdf-bg text-pdf-fg">
{@render children()}
</main>
<style>
@page {
size: A4;
}
</style>

View 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}
/>

View File

@@ -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),
};
};

View File

@@ -1,106 +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: '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>

View File

@@ -1,5 +1,6 @@
import type { RequestHandler } from './$types.js'; import type { RequestHandler } from './$types.js';
import type { LaunchOptions } from 'puppeteer'; import type { LaunchOptions, PDFOptions } 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';
@@ -11,14 +12,14 @@ export const prerender = true;
const cwd = process.cwd(); const cwd = process.cwd();
// PDF generation configuration // PDF generation configuration
const PDF_CONFIG = { const PDF_CONFIG: PDFOptions = {
format: 'A4' as const, format: 'A4',
printBackground: true, printBackground: true,
preferCSSPageSize: true, preferCSSPageSize: true,
margin: { margin: {
top: '20mm', top: '15mm',
right: '20mm', right: '20mm',
bottom: '20mm', bottom: '15mm',
left: '20mm', left: '20mm',
}, },
}; };
@@ -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,10 +61,6 @@ export const GET: RequestHandler = async ({ url, fetch }) => {
return tempFile; return tempFile;
}; };
try {
// Launch browser
const browser = await puppeteer.launch(getLaunchOptions());
const page = await browser.newPage();
const printResponse = await fetch('/print/'); const printResponse = await fetch('/print/');
const html = await printResponse.text(); const html = await printResponse.text();
const $ = cheerio.load(html); const $ = cheerio.load(html);
@@ -98,10 +110,21 @@ export const GET: RequestHandler = async ({ url, fetch }) => {
const htmlFile = path.join(tmpDir, 'index.html'); const htmlFile = path.join(tmpDir, 'index.html');
await writeFile(htmlFile, $.root().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');

View File

@@ -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),
};
};

View File

@@ -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>

BIN
static/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

BIN
static/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
static/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 B

BIN
static/icon-192x192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -1,3 +1,4 @@
import devtoolsJson from 'vite-plugin-devtools-json';
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig, loadEnv } from 'vite'; import { defineConfig, loadEnv } from 'vite';
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from '@tailwindcss/vite';
@@ -10,12 +11,8 @@ export default defineConfig(({ mode }) => {
process.env = { ...process.env, ...env }; process.env = { ...process.env, ...env };
return { return {
plugins: [tailwindcss(), sveltekit()], plugins: [tailwindcss(), sveltekit(), devtoolsJson()],
css: { css: { transformer: 'lightningcss' },
transformer: 'lightningcss', build: { target: 'esnext' },
},
build: {
target: 'esnext',
},
}; };
}); });