feat: add cv
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
<script setup lang="ts">
|
||||
// Always light: white background, also when the device is in dark mode
|
||||
definePageMeta({ colorMode: 'light' })
|
||||
|
||||
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: () => [] })
|
||||
|
||||
// Opens the browser's print dialog, which offers "Save as PDF"
|
||||
function savePdf() {
|
||||
window.print()
|
||||
}
|
||||
|
||||
// Browsers name the PDF after the page title, so it is "joaquin_gottlebe_cv" while printing (also with ⌘ P)
|
||||
const pdfName = `${(profile.name || 'cv').toLowerCase().trim().replace(/\s+/g, '_')}${profile.name ? '_cv' : ''}`
|
||||
let screenTitle = ''
|
||||
// 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(', ')
|
||||
})
|
||||
function onBeforePrint() {
|
||||
printDate.value = new Date()
|
||||
screenTitle = document.title
|
||||
document.title = pdfName
|
||||
}
|
||||
function onAfterPrint() {
|
||||
if (screenTitle) document.title = screenTitle
|
||||
}
|
||||
onMounted(() => {
|
||||
window.addEventListener('beforeprint', onBeforePrint)
|
||||
window.addEventListener('afterprint', onAfterPrint)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('beforeprint', onBeforePrint)
|
||||
window.removeEventListener('afterprint', onAfterPrint)
|
||||
})
|
||||
|
||||
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 :style="{ '--ui-primary': 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 print:hidden">
|
||||
<UFieldGroup size="xs">
|
||||
<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 flex items-center gap-5">
|
||||
<UAvatar v-if="profile.photo" :src="profile.photo" :alt="profile.name || t.photo" class="size-20 shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<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-1 flex flex-wrap gap-x-4">
|
||||
<UButton
|
||||
v-if="profile.email"
|
||||
:to="`mailto:${profile.email}`"
|
||||
:label="profile.email"
|
||||
color="primary"
|
||||
variant="link"
|
||||
class="p-0"
|
||||
/>
|
||||
<UButton
|
||||
v-if="profile.linkedin"
|
||||
:to="profile.linkedin"
|
||||
target="_blank"
|
||||
label="LinkedIn"
|
||||
color="primary"
|
||||
variant="link"
|
||||
class="p-0 print:hidden"
|
||||
/>
|
||||
<!-- 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>
|
||||
</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>
|
||||
|
||||
<div v-if="entries.length" class="mt-16 flex justify-center print:hidden">
|
||||
<UButton :label="t.savePdf" icon="i-lucide-download" color="primary" variant="outline" @click="savePdf" />
|
||||
</div>
|
||||
</UContainer>
|
||||
</UMain>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 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>
|
||||
Reference in New Issue
Block a user