feat: add cv
This commit is contained in:
+2
-1
@@ -1,8 +1,9 @@
|
||||
NUXT_BASEROW_URL=http://baserow.example
|
||||
NUXT_BASEROW_URL=https://baserow.huaqo.de
|
||||
NUXT_BASEROW_TOKEN=
|
||||
NUXT_BASEROW_REVIEWS_TABLE_ID=866
|
||||
NUXT_BASEROW_NOTES_TABLE_ID=864
|
||||
NUXT_BASEROW_WISHLIST_TABLE_ID=870
|
||||
NUXT_BASEROW_NOTES_APP_TABLE_ID=
|
||||
NUXT_BASEROW_CV_TABLE_ID=833
|
||||
NUXT_DIARY_PASSWORD=
|
||||
NUXT_SESSION_SECRET=
|
||||
|
||||
@@ -19,6 +19,7 @@ picked up automatically. The root only holds what apps share:
|
||||
| `/diary` | [Diary](#diary) — `layers/diary/` (password protected) |
|
||||
| `/wishlist` | [Wishlist](#wishlist) — `layers/wishlist/` |
|
||||
| `/notes` | [Notes](#notes) — `layers/notes/` (password protected) |
|
||||
| `/cv` | [CV](#cv) — `layers/cv/` |
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
@@ -35,6 +36,42 @@ pnpm dev
|
||||
names that won't clash with another app's (e.g. `DiaryEntry`, not `Entry`).
|
||||
3. Don't use `~/` inside a layer — it points to the root `app/`. Use relative imports.
|
||||
|
||||
# CV
|
||||
|
||||
A public CV built from the Baserow CV table (`NUXT_BASEROW_CV_TABLE_ID`). Only rows with `relevant` checked are shown,
|
||||
latest start first, split into **Experience** and **Education** (`Student` and `Pupil`). An empty `To` means the
|
||||
entry is still going on: it shows as "today" and its duration is counted until today (Baserow's `Duration` formula is
|
||||
not used).
|
||||
|
||||
The header (name, description per language, photo, email, LinkedIn link) is set in `layers/cv/app/app.config.ts`
|
||||
under `cv`. LinkedIn blocks scraping, so the photo and description are not fetched from there; put the photo in
|
||||
`layers/cv/public/`.
|
||||
|
||||
A DE / EN switch (remembered in the `cv-lang` cookie, German by default) translates the page's own text: headings,
|
||||
dates, durations, types, the description. `Area` comes from `area_german` on the German page; the rest of the table's text (`Institution`) is shown as stored.
|
||||
|
||||
**Table:** `From`, `To` (dates), `Type` (single select), `Institution`, `Area` (text, English), `area_german` (text, used on the German page, falls back to `Area`), `Place` (single select),
|
||||
`Grade` (number), `relevant` (boolean), `file` (file; each file is listed under its entry as a download, e.g. a
|
||||
thesis), `urls` (long text: one link per line, a blank line between groups such as projects; GitHub links show
|
||||
`owner/repo` with a GitHub icon, others their address; write `Label https://…` for custom text). Rows without `From`
|
||||
are skipped. Baserow returns file URLs as `http://`, so they are rewritten to
|
||||
`NUXT_BASEROW_URL`.
|
||||
|
||||
```
|
||||
layers/cv/
|
||||
nuxt.config.ts baserowCvTableId
|
||||
app/
|
||||
app.config.ts cv: name, description, photo, email, linkedin
|
||||
pages/cv.vue language switch, header, Experience and Education
|
||||
composables/useCvLang.ts current language and the translated texts
|
||||
components/CvSection.vue one section: period, duration, area, institution, type, grade
|
||||
utils/cvDate.ts period and duration, open-ended until today
|
||||
server/
|
||||
api/cv/index.get.ts GET relevant entries
|
||||
utils/cv.ts Baserow row -> CvEntry
|
||||
shared/types/cv.ts
|
||||
```
|
||||
|
||||
# Notes
|
||||
|
||||
Minimal notes: a collapsible list of notes on the left, a WYSIWYG Markdown editor (Nuxt UI `UEditor`) on the right.
|
||||
@@ -122,6 +159,7 @@ self-hosted Baserow tables.
|
||||
| `NUXT_BASEROW_REVIEWS_TABLE_ID` | Reviews table |
|
||||
| `NUXT_BASEROW_NOTES_TABLE_ID` | Notes table |
|
||||
| `NUXT_BASEROW_WISHLIST_TABLE_ID` | Wishlist table (see [Wishlist](#wishlist)) |
|
||||
| `NUXT_BASEROW_CV_TABLE_ID` | CV table (see [CV](#cv)) |
|
||||
| `NUXT_BASEROW_NOTES_APP_TABLE_ID` | Table of the notes app (see [Notes](#notes)), not the diary's notes |
|
||||
| `NUXT_DIARY_PASSWORD` | Password that unlocks the diary and notes |
|
||||
| `NUXT_SESSION_SECRET` | Signs the login cookie, at least 32 characters (`openssl rand -hex 32`) |
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
export default defineAppConfig({
|
||||
cv: {
|
||||
/** Shown as the page title; "CV" when empty */
|
||||
name: 'Joaquin Gottlebe',
|
||||
/** One or two sentences under the name, per language */
|
||||
description: {
|
||||
en: 'Computational Science Master’s student | Passionate about Software Development, Data Engineering and GIS',
|
||||
de: 'Masterstudent Computational Science | Begeistert von Softwareentwicklung, Data Engineering und GIS'
|
||||
},
|
||||
/** Path or URL of the profile picture, e.g. '/cv-photo.jpg' (put the file in layers/cv/public/) */
|
||||
photo: '/cv-photo.jpg',
|
||||
email: 'mail@huaqo.de',
|
||||
/** Printed with the date at the bottom of the PDF, e.g. "Berlin, 02.10.2026" */
|
||||
city: 'Berlin',
|
||||
linkedin: 'https://linkedin.com/in/joaquingottlebe',
|
||||
/** Accent color of the CV page (links, active language); the rest of the site keeps its primary color */
|
||||
accent: 'rgb(53 116 110)'
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ title: string, entries: CvEntry[] }>()
|
||||
|
||||
const { lang, t } = useCvLang()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mt-12 first:mt-0">
|
||||
<h2 class="mb-6 text-xl font-semibold text-highlighted">
|
||||
{{ title }}
|
||||
</h2>
|
||||
|
||||
<ol class="space-y-6">
|
||||
<li v-for="entry in entries" :key="entry.id" class="grid gap-1 break-inside-avoid sm:grid-cols-[11rem_1fr] sm:gap-6 print:grid-cols-[11rem_1fr] print:gap-6">
|
||||
<div class="text-sm text-muted tabular-nums">
|
||||
<div>{{ cvPeriod(entry.from, entry.to, lang, t) }}</div>
|
||||
<div class="text-dimmed">
|
||||
{{ cvDuration(entry.from, entry.to, t) }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium text-highlighted">
|
||||
{{ lang === 'de' && entry.areaGerman || entry.area }}
|
||||
</div>
|
||||
<div class="text-sm text-default">
|
||||
{{ entry.institution }}<span v-if="entry.place" class="ms-1.5 text-muted">{{ entry.place }}</span>
|
||||
</div>
|
||||
<div class="mt-1.5 flex flex-wrap items-center gap-2">
|
||||
<UBadge v-if="entry.type" :label="t.types[entry.type] ?? entry.type" color="neutral" variant="subtle" size="sm" />
|
||||
<span v-if="entry.grade" class="text-xs text-muted">{{ t.grade }} {{ entry.grade }}</span>
|
||||
</div>
|
||||
<ul v-if="entry.links.length" class="mt-2 space-y-1">
|
||||
<li v-for="(group, index) in entry.links" :key="index" class="flex flex-wrap gap-x-4 gap-y-1">
|
||||
<UButton
|
||||
v-for="link in group"
|
||||
:key="link.url"
|
||||
:to="link.url"
|
||||
target="_blank"
|
||||
:icon="link.kind === 'github' ? 'i-lucide-github' : 'i-lucide-globe'"
|
||||
:label="link.label"
|
||||
color="primary"
|
||||
variant="link"
|
||||
size="sm"
|
||||
class="p-0 print:hidden"
|
||||
/>
|
||||
<a
|
||||
v-for="link in group"
|
||||
:key="`print-${link.url}`"
|
||||
:href="link.url"
|
||||
class="hidden text-xs break-all text-primary print:inline"
|
||||
>{{ cvLinkText(link.url) }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
<ul v-if="entry.files.length" class="mt-2 space-y-1 print:hidden">
|
||||
<li v-for="file in entry.files" :key="file.url">
|
||||
<UButton
|
||||
:to="file.url"
|
||||
target="_blank"
|
||||
icon="i-lucide-file-down"
|
||||
:label="file.name"
|
||||
color="primary"
|
||||
variant="link"
|
||||
size="sm"
|
||||
class="p-0"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,53 @@
|
||||
export type CvLang = 'en' | 'de'
|
||||
|
||||
const TEXT = {
|
||||
en: {
|
||||
experience: 'Experience',
|
||||
education: 'Education',
|
||||
today: 'today',
|
||||
grade: 'Grade',
|
||||
year: ['year', 'years'],
|
||||
month: ['month', 'months'],
|
||||
loadError: 'The CV could not be loaded',
|
||||
empty: 'Nothing here yet.',
|
||||
photo: 'Profile picture',
|
||||
savePdf: 'Save as PDF',
|
||||
types: {
|
||||
'Part-time': 'Part-time',
|
||||
'Student': 'Student',
|
||||
'Pupil': 'Pupil',
|
||||
'Working Student': 'Working Student',
|
||||
'Volunteer': 'Volunteer'
|
||||
}
|
||||
},
|
||||
de: {
|
||||
experience: 'Erfahrung',
|
||||
education: 'Ausbildung',
|
||||
today: 'heute',
|
||||
grade: 'Note',
|
||||
year: ['Jahr', 'Jahre'],
|
||||
month: ['Monat', 'Monate'],
|
||||
loadError: 'Der Lebenslauf konnte nicht geladen werden',
|
||||
empty: 'Noch nichts hier.',
|
||||
photo: 'Profilbild',
|
||||
savePdf: 'Als PDF speichern',
|
||||
types: {
|
||||
'Part-time': 'Teilzeit',
|
||||
'Student': 'Studium',
|
||||
'Pupil': 'Schule',
|
||||
'Working Student': 'Werkstudent',
|
||||
'Volunteer': 'Freiwilligendienst'
|
||||
}
|
||||
}
|
||||
} satisfies Record<CvLang, unknown>
|
||||
|
||||
export type CvText = typeof TEXT.en
|
||||
|
||||
/** The CV's language, remembered in a cookie so the server renders the same one */
|
||||
export function useCvLang() {
|
||||
const lang = useCookie<CvLang>('cv-lang', { default: () => 'de', maxAge: 60 * 60 * 24 * 365, sameSite: 'lax' })
|
||||
return {
|
||||
lang,
|
||||
t: computed<CvText>(() => TEXT[lang.value] ?? TEXT.de)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,27 @@
|
||||
function parse(iso: string): Date {
|
||||
return new Date(`${iso}T00:00:00`)
|
||||
}
|
||||
|
||||
/** "Apr 2025 – Oct 2026", or "Oct 2024 – today" when there is no end date */
|
||||
export function cvPeriod(from: string, to: string | null, lang: CvLang, t: CvText): string {
|
||||
const locale = lang === 'de' ? 'de-DE' : 'en-GB'
|
||||
const month = (iso: string) => parse(iso).toLocaleDateString(locale, { month: 'short', year: 'numeric' })
|
||||
return `${month(from)} – ${to ? month(to) : t.today}`
|
||||
}
|
||||
|
||||
/** "1 year 6 months", counted until today when there is no end date */
|
||||
export function cvDuration(from: string, to: string | null, t: CvText): string {
|
||||
const start = parse(from)
|
||||
// The end day counts, so 1 Apr – 31 Oct is 7 months, not 6
|
||||
const end = to ? parse(to) : new Date()
|
||||
end.setDate(end.getDate() + 1)
|
||||
let total = (end.getFullYear() - start.getFullYear()) * 12 + end.getMonth() - start.getMonth()
|
||||
if (end.getDate() < start.getDate()) total--
|
||||
total = Math.max(1, total)
|
||||
const years = Math.floor(total / 12)
|
||||
const months = total % 12
|
||||
return [
|
||||
years && `${years} ${t.year[years === 1 ? 0 : 1]}`,
|
||||
months && `${months} ${t.month[months === 1 ? 0 : 1]}`
|
||||
].filter(Boolean).join(' ')
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/** The full address without "https://" and a trailing slash, as written out in the PDF */
|
||||
export function cvLinkText(url: string): string {
|
||||
return url.replace(/^https?:\/\//, '').replace(/\/$/, '')
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export default defineNuxtConfig({
|
||||
runtimeConfig: {
|
||||
baserowCvTableId: ''
|
||||
}
|
||||
})
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
@@ -0,0 +1,6 @@
|
||||
/** The relevant CV entries, latest start first */
|
||||
export default defineEventHandler(async (event) => {
|
||||
const rows = await listRows<CvRow>(event, cvTable(event), { filter__relevant__boolean: true, order_by: '-From' })
|
||||
const { baserowUrl } = useRuntimeConfig(event)
|
||||
return rows.map(row => toCvEntry(row, baserowUrl)).filter((entry): entry is CvEntry => !!entry)
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { H3Event } from 'h3'
|
||||
|
||||
interface SelectValue {
|
||||
value: string
|
||||
}
|
||||
|
||||
interface FileValue {
|
||||
url: string
|
||||
visible_name: string
|
||||
}
|
||||
|
||||
export interface CvRow {
|
||||
id: number
|
||||
From: string | null
|
||||
To: string | null
|
||||
Type: SelectValue | null
|
||||
Institution: string | null
|
||||
Area: string | null
|
||||
area_german?: string | null
|
||||
Place: SelectValue | null
|
||||
Grade: string | null
|
||||
file?: FileValue[] | null
|
||||
urls?: string | null
|
||||
}
|
||||
|
||||
export function cvTable(event: H3Event): string {
|
||||
const { baserowCvTableId } = useRuntimeConfig(event)
|
||||
if (!baserowCvTableId) {
|
||||
throw createError({ statusCode: 500, statusMessage: 'NUXT_BASEROW_CV_TABLE_ID is not set' })
|
||||
}
|
||||
return baserowCvTableId
|
||||
}
|
||||
|
||||
/** Baserow hands out file URLs with its internal protocol (http); serve them from the configured Baserow URL instead */
|
||||
function fileUrl(url: string, baserowUrl: string): string {
|
||||
const { pathname } = new URL(url)
|
||||
return new URL(pathname, baserowUrl).href
|
||||
}
|
||||
|
||||
function toCvLink(line: string): CvLink | null {
|
||||
// "https://…", or "Label https://…" for custom link text
|
||||
const match = line.match(/^(.*?)[\s:|–-]*(https?:\/\/\S+)$/)
|
||||
if (!match) return null
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(match[2]!)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const path = url.pathname.replace(/\/+$/, '')
|
||||
const kind = url.hostname === 'github.com' ? 'github' : 'web'
|
||||
const fallback = kind === 'github' ? path.slice(1) : url.hostname + path
|
||||
return { label: match[1]!.trim() || fallback, url: url.href, kind }
|
||||
}
|
||||
|
||||
/** One URL per line; a blank line starts a new group */
|
||||
function toCvLinks(text: string | null | undefined): CvLink[][] {
|
||||
return (text ?? '')
|
||||
.split(/\n\s*\n/)
|
||||
.map(block => block.split('\n').map(line => toCvLink(line.trim())).filter((link): link is CvLink => !!link))
|
||||
.filter(group => group.length)
|
||||
}
|
||||
|
||||
export function toCvEntry(row: CvRow, baserowUrl: string): CvEntry | null {
|
||||
if (!row.From) return null
|
||||
return {
|
||||
id: row.id,
|
||||
from: row.From,
|
||||
to: row.To || null,
|
||||
type: (row.Type?.value as CvType | undefined) ?? null,
|
||||
institution: (row.Institution ?? '').trim(),
|
||||
area: (row.Area ?? '').trim(),
|
||||
areaGerman: (row.area_german ?? '').trim(),
|
||||
place: row.Place?.value ?? null,
|
||||
grade: row.Grade ?? null,
|
||||
files: (row.file ?? []).map(file => ({ name: file.visible_name, url: fileUrl(file.url, baserowUrl) })),
|
||||
links: toCvLinks(row.urls)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export type CvType = 'Part-time' | 'Student' | 'Pupil' | 'Working Student' | 'Volunteer'
|
||||
|
||||
export interface CvFile {
|
||||
name: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface CvLink {
|
||||
label: string
|
||||
url: string
|
||||
kind: 'github' | 'web'
|
||||
}
|
||||
|
||||
export interface CvEntry {
|
||||
id: number
|
||||
/** ISO date */
|
||||
from: string
|
||||
/** ISO date, null while it is still going on */
|
||||
to: string | null
|
||||
type: CvType | null
|
||||
institution: string
|
||||
area: string
|
||||
/** German translation of `area`, empty when there is none */
|
||||
areaGerman: string
|
||||
place: string | null
|
||||
grade: string | null
|
||||
files: CvFile[]
|
||||
/** Groups of links (e.g. a project's repository and its deployment), in the order of the table */
|
||||
links: CvLink[][]
|
||||
}
|
||||
Reference in New Issue
Block a user