diff --git a/.env.example b/.env.example index e73f9ea..68f883a 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/README.md b/README.md index cd49fb2..b79df35 100644 --- a/README.md +++ b/README.md @@ -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`) | diff --git a/layers/cv/app/app.config.ts b/layers/cv/app/app.config.ts new file mode 100644 index 0000000..99dc05f --- /dev/null +++ b/layers/cv/app/app.config.ts @@ -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)' + } +}) diff --git a/layers/cv/app/components/CvSection.vue b/layers/cv/app/components/CvSection.vue new file mode 100644 index 0000000..62655e6 --- /dev/null +++ b/layers/cv/app/components/CvSection.vue @@ -0,0 +1,72 @@ + + + diff --git a/layers/cv/app/composables/useCvLang.ts b/layers/cv/app/composables/useCvLang.ts new file mode 100644 index 0000000..76d0924 --- /dev/null +++ b/layers/cv/app/composables/useCvLang.ts @@ -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 + +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('cv-lang', { default: () => 'de', maxAge: 60 * 60 * 24 * 365, sameSite: 'lax' }) + return { + lang, + t: computed(() => TEXT[lang.value] ?? TEXT.de) + } +} diff --git a/layers/cv/app/pages/cv.vue b/layers/cv/app/pages/cv.vue new file mode 100644 index 0000000..4052108 --- /dev/null +++ b/layers/cv/app/pages/cv.vue @@ -0,0 +1,138 @@ + + + + + diff --git a/layers/cv/app/utils/cvDate.ts b/layers/cv/app/utils/cvDate.ts new file mode 100644 index 0000000..b91f189 --- /dev/null +++ b/layers/cv/app/utils/cvDate.ts @@ -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(' ') +} diff --git a/layers/cv/app/utils/cvLink.ts b/layers/cv/app/utils/cvLink.ts new file mode 100644 index 0000000..c341cdf --- /dev/null +++ b/layers/cv/app/utils/cvLink.ts @@ -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(/\/$/, '') +} diff --git a/layers/cv/nuxt.config.ts b/layers/cv/nuxt.config.ts new file mode 100644 index 0000000..7bb0770 --- /dev/null +++ b/layers/cv/nuxt.config.ts @@ -0,0 +1,5 @@ +export default defineNuxtConfig({ + runtimeConfig: { + baserowCvTableId: '' + } +}) diff --git a/layers/cv/public/cv-photo.jpg b/layers/cv/public/cv-photo.jpg new file mode 100644 index 0000000..742c0ad Binary files /dev/null and b/layers/cv/public/cv-photo.jpg differ diff --git a/layers/cv/server/api/cv/index.get.ts b/layers/cv/server/api/cv/index.get.ts new file mode 100644 index 0000000..f47bbc2 --- /dev/null +++ b/layers/cv/server/api/cv/index.get.ts @@ -0,0 +1,6 @@ +/** The relevant CV entries, latest start first */ +export default defineEventHandler(async (event) => { + const rows = await listRows(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) +}) diff --git a/layers/cv/server/utils/cv.ts b/layers/cv/server/utils/cv.ts new file mode 100644 index 0000000..d772eb1 --- /dev/null +++ b/layers/cv/server/utils/cv.ts @@ -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) + } +} diff --git a/layers/cv/shared/types/cv.ts b/layers/cv/shared/types/cv.ts new file mode 100644 index 0000000..ea3c53f --- /dev/null +++ b/layers/cv/shared/types/cv.ts @@ -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[][] +}