Files
website/layers/cv/app/pages/cv.vue
T
2026-10-03 23:57:41 +02:00

128 lines
4.5 KiB
Vue

<script setup lang="ts">
const { cv: profile } = useAppConfig()
const { lang, t } = useCvLang()
useHead({
title: profile.name || 'CV',
htmlAttrs: { lang }
})
const EDUCATION: (CvType | null)[] = ['Student', 'Pupil']
const { data: entries, error, status } = useFetch<CvEntry[]>('/api/cv', { key: 'cv-entries', default: () => [] })
// The PDF is named "joaquin_gottlebe_cv"
const pdfName = `${(profile.name || 'cv').toLowerCase().trim().replace(/\s+/g, '_')}${profile.name ? '_cv' : ''}`
// The date under the CV is the day it is printed
const printDate = ref(new Date())
const signedOn = computed(() => {
const date = lang.value === 'de'
? printDate.value.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
: printDate.value.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })
return [profile.city, date].filter(Boolean).join(', ')
})
const { savePdf } = usePdfExport(pdfName, () => {
printDate.value = new Date()
})
const education = computed(() => entries.value.filter(entry => EDUCATION.includes(entry.type)))
const experience = computed(() => entries.value.filter(entry => !EDUCATION.includes(entry.type)))
</script>
<template>
<UMain class="cv" :style="{ '--cv-accent': profile.accent }">
<UContainer class="max-w-3xl py-8 print:max-w-none print:px-[12mm] print:pt-[15mm] print:pb-0">
<div class="mb-6 flex justify-end gap-2 print:hidden">
<AppHeaderActions />
<UFieldGroup>
<UButton
v-for="option in (['de', 'en'] as const)"
:key="option"
:label="option.toUpperCase()"
:color="lang === option ? 'primary' : 'neutral'"
:variant="lang === option ? 'solid' : 'outline'"
:aria-pressed="lang === option"
@click="lang = option"
/>
</UFieldGroup>
</div>
<header class="mb-10">
<UAvatar v-if="profile.photo" :src="profile.photo" :alt="profile.name || t.photo" class="mb-4 size-20" />
<!-- Under the photo: name, description, links -->
<h1 class="text-2xl font-semibold text-highlighted">
{{ profile.name || 'CV' }}
</h1>
<p v-if="profile.description[lang]" class="mt-1 text-sm text-muted">
{{ profile.description[lang] }}
</p>
<div class="mt-3 flex flex-wrap gap-x-4 gap-y-1">
<UButton
v-if="profile.email"
:to="`mailto:${profile.email}`"
icon="i-lucide-mail"
:label="profile.email"
:title="profile.email"
:aria-label="profile.email"
color="primary"
variant="link"
class="p-0"
:ui="CV_LINK_UI"
/>
<UButton
v-if="profile.linkedin"
:to="profile.linkedin"
target="_blank"
icon="i-lucide-linkedin"
label="LinkedIn"
title="LinkedIn"
aria-label="LinkedIn"
color="primary"
variant="link"
class="p-0 print:hidden"
:ui="CV_LINK_UI"
/>
<!-- In the PDF links are written out, since the label alone can't be followed on paper -->
<a v-if="profile.linkedin" :href="profile.linkedin" class="hidden text-sm font-medium text-primary print:inline">{{ cvLinkText(profile.linkedin) }}</a>
</div>
</header>
<UAlert
v-if="error"
color="error"
variant="subtle"
icon="i-lucide-triangle-alert"
:title="t.loadError"
:description="errorMessage(error)"
class="mb-6"
/>
<CvSection v-if="experience.length" :title="t.experience" :entries="experience" />
<CvSection v-if="education.length" :title="t.education" :entries="education" />
<p v-if="!entries.length && !error && status !== 'pending'" class="py-16 text-center text-muted">
{{ t.empty }}
</p>
<p v-if="entries.length" class="mt-12 hidden text-sm text-default break-inside-avoid print:block">
{{ signedOn }}
</p>
<SavePdfButton v-if="entries.length" :label="t.savePdf" @click="savePdf" />
</UContainer>
</UMain>
</template>
<style>
/* The CV's accent instead of the site's primary color in light mode (and so in the PDF); dark mode keeps the site's */
html:not(.dark) .cv {
--ui-primary: var(--cv-accent);
}
/* Top and bottom of every printed page. The sides and the extra space above the first page come from the container's
print padding, which also works in browsers that ignore @page margins */
@page {
margin: 15mm 0;
}
</style>