This commit is contained in:
@@ -103,7 +103,6 @@ npm run format # Format all files with Prettier
|
||||
- Dark mode via `light-dark()` CSS function
|
||||
- Cyan accent color for interactive elements
|
||||
- Zinc-based neutral colors
|
||||
- Print styles: `.no-print` class hides elements in PDF
|
||||
|
||||
## Project Structure
|
||||
|
||||
|
||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -11,7 +11,8 @@
|
||||
"dependencies": {
|
||||
"@fontsource-variable/roboto-condensed": "^5.2.8",
|
||||
"@fontsource/iosevka": "^5.2.5",
|
||||
"@sveltia/cms": "^0.140.3"
|
||||
"@sveltia/cms": "^0.140.3",
|
||||
"remeda": "^2.33.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
@@ -3581,6 +3582,15 @@
|
||||
"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": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
"dependencies": {
|
||||
"@fontsource-variable/roboto-condensed": "^5.2.8",
|
||||
"@fontsource/iosevka": "^5.2.5",
|
||||
"@sveltia/cms": "^0.140.3"
|
||||
"@sveltia/cms": "^0.140.3",
|
||||
"remeda": "^2.33.6"
|
||||
},
|
||||
"keywords": [
|
||||
"cv",
|
||||
|
||||
12
src/app.css
12
src/app.css
@@ -14,6 +14,18 @@ body {
|
||||
-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 {
|
||||
/* Font families */
|
||||
|
||||
@@ -10,6 +10,6 @@
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
%sveltekit.body%
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
location={edu.location}
|
||||
startDate={edu.startDate}
|
||||
endDate={edu.endDate}
|
||||
current={edu.current}
|
||||
description={edu.description}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
location={job.location}
|
||||
startDate={job.startDate}
|
||||
endDate={job.endDate}
|
||||
current={job.current}
|
||||
description={job.description}
|
||||
tags={job.technologies}
|
||||
/>
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
import PDFSection from './PDFSection.svelte';
|
||||
import PDFTags from './PDFTags.svelte';
|
||||
import PDFTimelineItem from './PDFTimelineItem.svelte';
|
||||
import profilePicture from '$lib/media/profile-picture.jpg';
|
||||
import { MailIcon, MapPinIcon, GithubIcon, GlobeIcon } from 'svelte-feather-icons';
|
||||
|
||||
let {
|
||||
@@ -63,9 +62,15 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-full p-2 inset-shadow-sm">
|
||||
<img src={profilePicture} alt={profile.name} class="w-26 h-26 rounded-full object-cover" />
|
||||
</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}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
}
|
||||
|
||||
let { title, projects, seeAllLink }: Props = $props();
|
||||
$effect(() => console.log({ seeAllLink }));
|
||||
</script>
|
||||
|
||||
{#if projects.length > 0}
|
||||
|
||||
@@ -8,21 +8,11 @@
|
||||
location?: string;
|
||||
startDate: string;
|
||||
endDate?: string;
|
||||
current?: boolean;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
let {
|
||||
title,
|
||||
subtitle,
|
||||
location,
|
||||
startDate,
|
||||
endDate,
|
||||
current = false,
|
||||
description,
|
||||
tags = [],
|
||||
}: Props = $props();
|
||||
let { title, subtitle, location, startDate, endDate, description, tags = [] }: Props = $props();
|
||||
</script>
|
||||
|
||||
<article>
|
||||
@@ -41,7 +31,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
<time class="text-sm text-muted font-mono whitespace-nowrap">
|
||||
{formatDate(startDate)} — {current || !endDate ? 'Present' : formatDate(endDate)}
|
||||
{formatDate(startDate)} — {!endDate ? 'Present' : formatDate(endDate)}
|
||||
</time>
|
||||
</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 { readFileSync, readdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import type { Experience, Education, Skill, Profile } from './types';
|
||||
|
||||
const CONTENT_DIR = join(process.cwd(), 'src', 'lib', 'content');
|
||||
|
||||
function loadJsonFile<T>(filepath: string): T | null {
|
||||
try {
|
||||
const content = readFileSync(filepath, 'utf-8');
|
||||
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 [];
|
||||
}
|
||||
async function loadJsonFilesFromGlob<T>(
|
||||
modules: Record<string, () => Promise<{ default: T }>>
|
||||
): Promise<T[]> {
|
||||
return Promise.all(
|
||||
Object.entries(modules).map(async ([_, module]) => await module().then((m) => m.default))
|
||||
);
|
||||
}
|
||||
|
||||
function decodeBase64Email(email: string): string {
|
||||
@@ -46,42 +16,50 @@ function decodeBase64Email(email: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function loadProfile(): Profile {
|
||||
const profile = loadJsonFile<Profile>(join(CONTENT_DIR, 'profile.json')) ?? {
|
||||
name: 'Your Name',
|
||||
title: 'Developer',
|
||||
email: 'email@example.com',
|
||||
phone: undefined,
|
||||
location: 'Location',
|
||||
website: undefined,
|
||||
github: 'username',
|
||||
summary: 'A passionate developer.',
|
||||
};
|
||||
export async function loadProfile() {
|
||||
const profile: Profile = await import('$lib/content/profile.json');
|
||||
const media = import.meta.glob<{ default: string }>('$lib/media/*');
|
||||
const profilePictureModule = await (profile.profilePicture
|
||||
? media[`/${profile.profilePicture}`]?.()
|
||||
: undefined);
|
||||
const avatarModule = await (profile.avatar ? media[`/${profile.avatar}`]?.() : undefined);
|
||||
|
||||
return {
|
||||
...profile,
|
||||
email: decodeBase64Email(profile.email),
|
||||
profilePicture: profilePictureModule?.default,
|
||||
avatar: avatarModule?.default,
|
||||
};
|
||||
}
|
||||
|
||||
export function loadExperience(): Experience[] {
|
||||
return loadJsonFilesFromDir<Experience>(join(CONTENT_DIR, 'experience'));
|
||||
export async function loadExperience() {
|
||||
return loadJsonFilesFromGlob(
|
||||
import.meta.glob<{ default: Experience }>('$lib/content/experience/*.json')
|
||||
);
|
||||
}
|
||||
|
||||
export function loadEducation(): Education[] {
|
||||
return loadJsonFilesFromDir<Education>(join(CONTENT_DIR, 'education'));
|
||||
export function loadEducation() {
|
||||
return loadJsonFilesFromGlob(
|
||||
import.meta.glob<{ default: Education }>('$lib/content/education/*.json')
|
||||
);
|
||||
}
|
||||
|
||||
export function loadSkills(): Skill[] {
|
||||
return loadJsonFilesFromDir<Skill>(join(CONTENT_DIR, 'skills'));
|
||||
export function loadSkills() {
|
||||
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 {
|
||||
profile: loadProfile(),
|
||||
experience: loadExperience(),
|
||||
education: loadEducation(),
|
||||
skills: loadSkills(),
|
||||
projects: [],
|
||||
profile,
|
||||
experience,
|
||||
education,
|
||||
skills,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"location": "Espoo - Finnland",
|
||||
"website": "https://joakim.repomaa.com",
|
||||
"github": "repomaa",
|
||||
"avatar": "src/lib/media/avatar.jpg",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// Content data types - clean interface definitions
|
||||
|
||||
export interface Profile {
|
||||
name: string;
|
||||
title: string;
|
||||
@@ -19,7 +17,6 @@ export interface Experience {
|
||||
location?: string;
|
||||
startDate: string;
|
||||
endDate?: string;
|
||||
current: boolean;
|
||||
description: string;
|
||||
technologies: string[];
|
||||
}
|
||||
@@ -31,7 +28,6 @@ export interface Education {
|
||||
location?: string;
|
||||
startDate: string;
|
||||
endDate?: string;
|
||||
current: boolean;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import { filter, map, pipe, sortBy, take, uniqueBy } from 'remeda';
|
||||
import type { Project } from './content/types.js';
|
||||
|
||||
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;
|
||||
pull_request?: {
|
||||
merged_at: string | null;
|
||||
url: string;
|
||||
};
|
||||
pull_request?: { merged_at: string | null };
|
||||
}
|
||||
|
||||
interface SearchResponse {
|
||||
items: SearchIssue[];
|
||||
items: SearchItem[];
|
||||
total_count: number;
|
||||
}
|
||||
|
||||
@@ -25,211 +29,167 @@ interface RepoInfo {
|
||||
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> {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
};
|
||||
|
||||
// Use GitHub token if available (for higher rate limits during build)
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
if (token) {
|
||||
headers.Authorization = `token ${token}`;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
// Exponential backoff retry for fetch
|
||||
async function fetchWithRetry(
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
retryCount = 0
|
||||
): Promise<Response> {
|
||||
async function fetchWithRetry(url: string, retryCount = 0): Promise<Response> {
|
||||
try {
|
||||
const response = await fetch(url, { ...options, headers: getHeaders() });
|
||||
const response = await fetch(url, { headers: getHeaders() });
|
||||
|
||||
if (response.status === 429) {
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
const retryAfter = response.headers.get('retry-after');
|
||||
const delay = retryAfter
|
||||
? parseInt(retryAfter, 10) * 1000
|
||||
: Math.min(RATE_LIMIT_DELAY, INITIAL_RETRY_DELAY * Math.pow(2, retryCount));
|
||||
if (response.status === 429 && retryCount < MAX_RETRIES) {
|
||||
const retryAfter = response.headers.get('retry-after');
|
||||
const delay = retryAfter
|
||||
? parseInt(retryAfter, 10) * 1000
|
||||
: Math.min(RATE_LIMIT_DELAY, INITIAL_RETRY_DELAY * Math.pow(2, retryCount));
|
||||
|
||||
console.warn(
|
||||
`Rate limited for ${url}, waiting ${delay}ms before retry ${retryCount + 1}/${MAX_RETRIES}`
|
||||
);
|
||||
return new Promise((resolve) =>
|
||||
setTimeout(() => resolve(fetchWithRetry(url, options, retryCount + 1)), delay)
|
||||
);
|
||||
}
|
||||
console.warn(
|
||||
`Rate limited for ${url}, waiting ${delay}ms before retry ${retryCount + 1}/${MAX_RETRIES}`
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
return fetchWithRetry(url, retryCount + 1);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
// Network errors (timeout, connection refused, etc.)
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
const delay = Math.min(MAX_RETRY_DELAY, INITIAL_RETRY_DELAY * Math.pow(2, retryCount));
|
||||
console.warn(
|
||||
`Network error for ${url}, retrying in ${delay}ms (${retryCount + 1}/${MAX_RETRIES}):`,
|
||||
error
|
||||
);
|
||||
return new Promise((resolve) =>
|
||||
setTimeout(() => resolve(fetchWithRetry(url, options, retryCount + 1)), delay)
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
return fetchWithRetry(url, retryCount + 1);
|
||||
}
|
||||
|
||||
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[]> {
|
||||
try {
|
||||
// Use search API to filter non-forks and sort by stars
|
||||
const query = encodeURIComponent(`user:${username} fork:false`);
|
||||
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.status === 403 || response.status === 429) {
|
||||
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}`);
|
||||
}
|
||||
handleApiError(response, 'user repos');
|
||||
return [];
|
||||
}
|
||||
|
||||
const searchData = await response.json();
|
||||
|
||||
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,
|
||||
})
|
||||
);
|
||||
const data = await response.json();
|
||||
return data.items.map(mapRepoToProject);
|
||||
} catch (error) {
|
||||
console.error('Error fetching GitHub projects:', error);
|
||||
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[]> {
|
||||
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`,
|
||||
{}
|
||||
const query = encodeURIComponent(`type:pr author:${username} is:merged`);
|
||||
const response = await fetchWithRetry(
|
||||
`${GITHUB_API_BASE}/search/issues?q=${query}&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}`
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
handleApiError(response, 'search');
|
||||
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) {
|
||||
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\/([^\/]+)\/([^\/]+)$/);
|
||||
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;
|
||||
}
|
||||
})
|
||||
const repoUrls = pipe(
|
||||
items,
|
||||
filter(isNotExcluded),
|
||||
uniqueBy((item) => item.repository_url),
|
||||
map((item) => item.repository_url)
|
||||
);
|
||||
|
||||
// Sort by stars descending and take top 5
|
||||
return repos
|
||||
.filter((repo) => repo !== null)
|
||||
.sort((a, b) => b.stars - a.stars)
|
||||
.slice(0, 5);
|
||||
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) {
|
||||
console.error('Error fetching contributed repos:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to get top projects from array
|
||||
export function getTopProjects(projects: Project[], limit: number): Project[] {
|
||||
return projects.slice(0, limit);
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -1,6 +1,5 @@
|
||||
<script>
|
||||
let { children } = $props();
|
||||
import '../../app.css';
|
||||
</script>
|
||||
|
||||
<div class="font-mono bg-background text-foreground">
|
||||
@@ -34,7 +34,7 @@
|
||||
<a
|
||||
href="/joakim-repomaa-cv.pdf"
|
||||
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%);"
|
||||
data-sveltekit-preload-data="off"
|
||||
aria-label="Download PDF"
|
||||
@@ -51,12 +51,3 @@
|
||||
|
||||
<Footer />
|
||||
</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">
|
||||
import { onMount } from 'svelte';
|
||||
import config from '$lib/content/config';
|
||||
|
||||
onMount(async () => {
|
||||
const CMS = await import('@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: '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' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
CMS.init({ config });
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import type { LaunchOptions } from 'puppeteer';
|
||||
import { dev } from '$app/environment';
|
||||
import puppeteer from 'puppeteer';
|
||||
import * as cheerio from 'cheerio';
|
||||
import path from 'path';
|
||||
@@ -37,7 +38,22 @@ const getLaunchOptions = (): LaunchOptions => {
|
||||
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 tmpFile = (url: string) => {
|
||||
const filename = path.basename(url);
|
||||
@@ -45,63 +61,70 @@ export const GET: RequestHandler = async ({ url, fetch }) => {
|
||||
return tempFile;
|
||||
};
|
||||
|
||||
try {
|
||||
// Launch browser
|
||||
const browser = await puppeteer.launch(getLaunchOptions());
|
||||
const page = await browser.newPage();
|
||||
const printResponse = await fetch('/print/');
|
||||
const html = await printResponse.text();
|
||||
const $ = cheerio.load(html);
|
||||
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) => {
|
||||
if (el.tagName === 'link') {
|
||||
const href = $(el).attr('href');
|
||||
if (href) {
|
||||
const tempFile = (fileDownloads[href] ||= tmpFile(href));
|
||||
$(el).attr('href', `file://${tempFile}`);
|
||||
}
|
||||
} else {
|
||||
const src = $(el).attr('src');
|
||||
if (src) {
|
||||
const tempFile = (fileDownloads[src] ||= tmpFile(src));
|
||||
$(el).attr('src', `file://${tempFile}`);
|
||||
}
|
||||
$('script[src], link[rel="stylesheet"], img[src]').each((i, el) => {
|
||||
if (el.tagName === 'link') {
|
||||
const href = $(el).attr('href');
|
||||
if (href) {
|
||||
const tempFile = (fileDownloads[href] ||= tmpFile(href));
|
||||
$(el).attr('href', `file://${tempFile}`);
|
||||
}
|
||||
});
|
||||
|
||||
$('style[src]').each((i, el) => {
|
||||
} else {
|
||||
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() ?? '');
|
||||
$('style[src]').each((i, el) => {
|
||||
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
|
||||
// waitUntil: 'networkidle2' waits for 2 network connections to be idle
|
||||
// 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
|
||||
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