diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md index bd41540..0bd0c16 100644 --- a/PROJECT_STATUS.md +++ b/PROJECT_STATUS.md @@ -36,7 +36,27 @@ Legend: ⬜ not started · 🟡 in progress · ✅ done · ⛔ blocked ## Deviations from spec (with rationale) -_None yet. Any deviation gets logged here per the engineering rules._ +1. **Content stored as typed TS modules validated by zod, not Astro content-collection + entries** (DATA_MODEL / I18N_SPEC §3 described "content collections with per-locale + entries"). Rationale: the content is highly structured (not markdown documents) and the + nested `{en,no}` single-entry model serves the spec's own stated goal — "facts exist + once, prose exists twice" — *better* than Astro's parallel per-locale entries, which + can't express author-once invariance natively. All required properties are preserved: + zod schema validation at build (`src/lib/schema.ts`), structural EN/NO parity by + construction, and CI-testable getters (`src/lib/content.ts`). Same validation guarantee, + cleaner authoring, no duplication. +2. **Sitemap hand-rolled from the slug map** instead of `@astrojs/sitemap`. Rationale: the + slug map is the single route contract (ARCHITECTURE A5) and localised slugs (prosjekter + vs projects) don't fit the integration's parallel-slug assumption. Hand-rolling keeps one + source of truth and correct hreflang alternates. + +## Prerequisite substitutions in place (interim, non-blocking) + +- Portrait + project screenshots use a `placeholder:` scheme (styled skeletons) until the + seeded-data capture pass and real headshot are provided. Real assets drop straight into + the same content refs. +- CV download targets are the current PDFs (`public/cv/…`); the ATS-safe rework replaces + the files at the same URLs. ## Open questions / blocked-on-user diff --git a/site/public/cv/connor-babbington-cv-en.pdf b/site/public/cv/connor-babbington-cv-en.pdf new file mode 100644 index 0000000..3de417b Binary files /dev/null and b/site/public/cv/connor-babbington-cv-en.pdf differ diff --git a/site/public/cv/connor-babbington-cv-no.pdf b/site/public/cv/connor-babbington-cv-no.pdf new file mode 100644 index 0000000..41acfca Binary files /dev/null and b/site/public/cv/connor-babbington-cv-no.pdf differ diff --git a/site/src/content/experience.ts b/site/src/content/experience.ts new file mode 100644 index 0000000..4d23f92 --- /dev/null +++ b/site/src/content/experience.ts @@ -0,0 +1,93 @@ +import type { Experience } from '@lib/schema'; + +/* + Experience timeline, newest first. Facts from CANONICAL_CONTENT.md. + The council role is featured with apprentice→developer progression; side roles + are compact and marked `alongside` (they overlapped the council job). +*/ +export const experience: Experience = { + items: [ + { + id: 'norway-now', + employer: 'Tønsberg, Norway', + location: 'Tønsberg', + period: { from: 2023, to: null }, + emphasis: 'compact', + alongside: false, + role: { + en: 'Self-directed development & Norskkurs', + no: 'Egen produktutvikling og norskkurs', + }, + }, + { + id: 'warwickshire', + employer: 'Warwickshire County Council, UK', + location: 'UK', + period: { from: 2015, to: 2023 }, + emphasis: 'featured', + alongside: false, + progression: [ + { from: 2015, to: 2017, label: { en: 'Apprentice', no: 'Lærling' } }, + { from: 2017, to: 2023, label: { en: 'System Developer', no: 'Systemutvikler' } }, + ], + role: { en: 'System Developer', no: 'Systemutvikler' }, + summary: { + en: 'Public-sector systems — from apprentice to full developer over eight years.', + no: 'Fagsystemer i offentlig sektor — fra lærling til utvikler over åtte år.', + }, + highlights: { + en: [ + 'Worked on the county-wide highways and streetlight fault-reporting system used across Warwickshire.', + 'Built and maintained full-stack applications in C#, Python, Ruby on Rails, SQL and JavaScript.', + 'Delivered reliable, well-tested software and resolved stability issues on live systems.', + 'Owned deployments and production troubleshooting; guided colleagues on code and process.', + ], + no: [ + 'Jobbet på det fylkesdekkende systemet for feilmelding av veilys og veier, brukt i hele Warwickshire.', + 'Bygde og vedlikeholdt fullstack-applikasjoner i C#, Python, Ruby on Rails, SQL og JavaScript.', + 'Leverte pålitelig, godt testet programvare og løste stabilitetsproblemer i produksjon.', + 'Hadde ansvar for utrulling og feilsøking i drift, og veiledet kollegaer på kode og prosess.', + ], + }, + }, + { + id: 'royal-vapes', + employer: 'Royal Vapes, UK', + location: 'UK', + period: { from: 2017, to: 2021 }, + emphasis: 'compact', + alongside: true, + role: { en: 'Sales Representative', no: 'Salgsrepresentant' }, + }, + { + id: 'hodcarrier', + employer: 'The Hodcarrier, UK', + location: 'UK', + period: { from: 2016, to: 2018 }, + emphasis: 'compact', + alongside: true, + role: { en: 'Bartender', no: 'Bartender' }, + }, + { + id: 'nuffield', + employer: 'Nuffield Health, UK', + location: 'UK', + period: { from: 2014, to: 2015 }, + emphasis: 'compact', + alongside: false, + role: { en: 'Receptionist', no: 'Resepsjonist' }, + }, + { + id: 'education', + employer: 'Warwickshire College, UK', + location: 'UK', + period: { from: 2012, to: 2015 }, + emphasis: 'compact', + alongside: false, + role: { + en: 'Extended Diploma — NVQ Level 3 in ICT', + no: 'Extended Diploma — NVQ nivå 3 i IKT', + }, + }, + ], +}; diff --git a/site/src/content/meta.ts b/site/src/content/meta.ts new file mode 100644 index 0000000..fff95d1 --- /dev/null +++ b/site/src/content/meta.ts @@ -0,0 +1,125 @@ +import type { Meta } from '@lib/schema'; + +/* Per-page SEO metadata, handwritten per locale (SEO_SPEC §2). Keyed by pageId. */ +export const meta: Meta = { + home: { + en: { + title: 'Connor Babbington — Systems Developer (.NET, React) · Tønsberg, Norway', + description: + 'Systems developer with 8+ years building full-stack and backend systems in C#, .NET, React and Docker. Based in Tønsberg, Norway. Open to work.', + }, + no: { + title: 'Connor Babbington — Systemutvikler (.NET, React) · Tønsberg', + description: + 'Systemutvikler med 8+ års erfaring med fullstack og backend i C#, .NET, React og Docker. Bosatt i Tønsberg. Åpen for nye muligheter.', + }, + }, + projects: { + en: { + title: 'Projects — Connor Babbington', + description: + 'Case studies of systems I have designed, built and run: JobTrack, InboxIntel and a self-hosted infrastructure lab.', + }, + no: { + title: 'Prosjekter — Connor Babbington', + description: + 'Gjennomganger av systemer jeg har designet, bygget og driftet: JobTrack, InboxIntel og en egendriftet hjemmelab.', + }, + }, + jobtrack: { + en: { + title: 'JobTrack — case study · Connor Babbington', + description: + 'A full-stack job-application workspace: React, ASP.NET Core, SQLite, a local Ollama AI service and Docker. Architecture, decisions and security.', + }, + no: { + title: 'JobTrack — prosjektgjennomgang · Connor Babbington', + description: + 'Et fullstack arbeidsrom for jobbsøking: React, ASP.NET Core, SQLite, lokal Ollama-AI og Docker. Arkitektur, valg og sikkerhet.', + }, + }, + inboxintel: { + en: { + title: 'InboxIntel — case study · Connor Babbington', + description: + 'Gmail analytics and safe bulk cleanup, built as Clean Architecture in .NET 8 with encrypted OAuth tokens and preview-before-delete.', + }, + no: { + title: 'InboxIntel — prosjektgjennomgang · Connor Babbington', + description: + 'Gmail-analyse og trygg masseopprydding, bygget som Clean Architecture i .NET 8 med krypterte OAuth-tokens og forhåndsvisning før sletting.', + }, + }, + homelab: { + en: { + title: 'Self-hosted infrastructure lab — Connor Babbington', + description: + 'A home lab running Ubuntu, Docker, nginx, Gitea and monitoring — hands-on deployment and operations, and the deploy target for this site.', + }, + no: { + title: 'Egendriftet hjemmelab — Connor Babbington', + description: + 'En hjemmelab med Ubuntu, Docker, nginx, Gitea og overvåking — praktisk drift og utrulling, og utrullingsmålet for denne siden.', + }, + }, + experience: { + en: { + title: 'Experience — Connor Babbington', + description: + 'Eight years as a systems developer in UK local government, from apprentice to full developer, plus self-directed product work in Norway.', + }, + no: { + title: 'Erfaring — Connor Babbington', + description: + 'Åtte år som systemutvikler i britisk offentlig sektor, fra lærling til utvikler, samt egen produktutvikling i Norge.', + }, + }, + about: { + en: { + title: 'About — Connor Babbington', + description: + 'A systems developer based in Tønsberg, Norway, with a backend-leaning full-stack background and a habit of building and self-hosting projects.', + }, + no: { + title: 'Om meg — Connor Babbington', + description: + 'Systemutvikler bosatt i Tønsberg, med fullstack-bakgrunn med tyngde på backend og en vane for å bygge og drifte egne prosjekter.', + }, + }, + contact: { + en: { + title: 'Contact — Connor Babbington', + description: + 'Get in touch about a systems developer role in Norway. E-mail, LinkedIn or the contact form — I usually reply within a day.', + }, + no: { + title: 'Kontakt — Connor Babbington', + description: + 'Ta kontakt om en systemutvikler-rolle i Norge. E-post, LinkedIn eller kontaktskjema — jeg svarer vanligvis innen en dag.', + }, + }, + cv: { + en: { + title: 'CV — Connor Babbington', + description: + 'Download Connor Babbington’s CV in English or Norwegian — systems developer, 8+ years, .NET and full-stack.', + }, + no: { + title: 'CV — Connor Babbington', + description: + 'Last ned CV-en til Connor Babbington på engelsk eller norsk — systemutvikler, 8+ år, .NET og fullstack.', + }, + }, + colophon: { + en: { + title: 'Colophon — how this site works · Connor Babbington', + description: + 'How this portfolio is built: Astro static site, CSS-first motion, bilingual by design, self-hosted in Docker with a small .NET contact relay.', + }, + no: { + title: 'Kolofon — slik er siden bygget · Connor Babbington', + description: + 'Hvordan denne porteføljen er bygget: Astro-statisk side, CSS-basert bevegelse, tospråklig fra bunnen, egendriftet i Docker med en liten .NET-relay.', + }, + }, +}; diff --git a/site/src/content/profile.ts b/site/src/content/profile.ts new file mode 100644 index 0000000..0e9c47f --- /dev/null +++ b/site/src/content/profile.ts @@ -0,0 +1,99 @@ +import type { Profile } from '@lib/schema'; + +/* + Profile singleton. Facts from docs/design/02-ux/CANONICAL_CONTENT.md; + hero/about copy from CONTENT_STRATEGY §2–3. Norwegian written natively. + photo.src uses the `placeholder:` scheme until the real headshot is dropped in. +*/ +export const profile: Profile = { + name: 'Connor Babbington', + links: { + email: 'connor.babbington@cesnimda.co.uk', + phone: '+47 41 33 44 70', + phoneHref: 'tel:+4741334470', + linkedin: 'https://cesnimda.co.uk/Linkedin', + gitea: 'https://git.cesnimda.uk/cesnimda', + }, + cv: { + en: { path: '/cv/connor-babbington-cv-en.pdf', sizeKb: 132, updated: '2026-07' }, + no: { path: '/cv/connor-babbington-cv-no.pdf', sizeKb: 172, updated: '2026-07' }, + }, + photo: { + src: 'placeholder:portrait', + alt: { + en: 'Connor Babbington, smiling, outdoors', + no: 'Connor Babbington, smilende, utendørs', + }, + caption: { en: 'tønsberg, norway · 2025', no: 'tønsberg, norge · 2025' }, + }, + languages: [ + { label: { en: 'English', no: 'Engelsk' }, level: { en: 'native', no: 'morsmål' } }, + { + label: { en: 'Norwegian', no: 'Norsk' }, + level: { en: 'B1 · actively developing', no: 'B1 · i aktiv utvikling' }, + }, + ], + chips: [ + { id: 'location', label: { en: 'Tønsberg, Norway', no: 'Bosatt i Tønsberg' } }, + { id: 'permit', label: { en: 'Work permit ✓', no: 'Gyldig oppholdstillatelse' } }, + { + id: 'availability', + label: { en: 'Remote / hybrid / on-site', no: 'Remote / hybrid / oppmøte' }, + }, + { + id: 'languages', + label: { en: 'English native · Norsk B1', no: 'Engelsk morsmål · Norsk B1' }, + }, + ], + proof: [ + { stat: { en: '8+ yrs', no: '8+ år' }, label: { en: 'Experience', no: 'Erfaring' } }, + { + stat: { en: 'UK public sector', no: 'Offentlig sektor (UK)' }, + label: { en: 'Background', no: 'Bakgrunn' }, + }, + { + stat: { en: '.NET · React · Docker', no: '.NET · React · Docker' }, + label: { en: 'Core stack', no: 'Kjernestack' }, + }, + { + stat: { en: 'Based in Norway', no: 'Bosatt i Norge' }, + label: { en: 'Work permit ✓', no: 'Oppholdstillatelse ✓' }, + }, + ], + content: { + en: { + eyebrow: 'SYSTEMS DEVELOPER · TØNSBERG, NORWAY', + roleLine: 'Systems Developer — .NET, full-stack & infrastructure', + heroTagline: 'I design, build and run web systems.', + heroSummary: + 'Eight-plus years delivering internal software for UK local government — now building full-stack products in Norway.', + statusLine: 'status: open to work', + aboutHeading: 'About', + aboutParagraphs: [ + 'I’m a systems developer with a backend-leaning full-stack background: business systems, troubleshooting and production support. I like turning stakeholder requirements into practical, reliable software, and I care about the parts users never see — tests, deployments and keeping things running.', + 'Alongside professional work I build and self-host my own projects — a job-application tracker, a Gmail analytics tool, and a home infrastructure lab. It keeps my skills current and hands-on, and it’s where I get to make the architecture decisions end to end.', + 'I’m based in Tønsberg, Norway, with a valid residence permit. English is my native language and my Norwegian is at B1 and actively improving. Outside work I’m into PC and board games, cooking, and generally learning new things.', + ], + interestsLine: 'PC & board games · cooking · always learning something new', + contactHeading: 'Looking for a systems developer in Norway?', + contactBody: 'I usually reply within a day — e-mail is fastest.', + }, + no: { + eyebrow: 'SYSTEMUTVIKLER · TØNSBERG', + roleLine: 'Systemutvikler — .NET, fullstack og infrastruktur', + heroTagline: 'Jeg utvikler og drifter websystemer.', + heroSummary: + 'Åtte års erfaring med fagsystemer i britisk offentlig sektor — nå bygger jeg fullstack-løsninger fra Tønsberg.', + statusLine: 'status: åpen for muligheter', + aboutHeading: 'Om meg', + aboutParagraphs: [ + 'Jeg er systemutvikler med bakgrunn i fullstack med tyngde på backend: fagsystemer, feilsøking og driftsstøtte. Jeg liker å gjøre om behov fra brukere og interessenter til praktisk og pålitelig programvare, og jeg bryr meg om det brukerne aldri ser — tester, utrulling og stabil drift.', + 'Ved siden av jobb bygger og drifter jeg egne prosjekter — en jobbsøknads-tracker, et verktøy for Gmail-analyse og en hjemmelab for infrastruktur. Det holder ferdighetene mine oppdaterte, og der tar jeg alle arkitekturvalgene selv, fra ende til ende.', + 'Jeg er bosatt i Tønsberg og har gyldig oppholdstillatelse. Engelsk er morsmålet mitt, og norsken min er på B1 og i aktiv utvikling. På fritiden liker jeg PC- og brettspill, matlaging og å lære nye ting.', + ], + interestsLine: 'PC- og brettspill · matlaging · lærer alltid noe nytt', + contactHeading: 'Ser dere etter en systemutvikler?', + contactBody: 'Jeg svarer vanligvis innen en dag — e-post er raskest.', + }, + }, +}; diff --git a/site/src/content/projects/homelab.ts b/site/src/content/projects/homelab.ts new file mode 100644 index 0000000..b7289a6 --- /dev/null +++ b/site/src/content/projects/homelab.ts @@ -0,0 +1,190 @@ +import type { Project } from '@lib/schema'; + +/* Homelab — capability page (lighter template): no TL;DR/decisions/gallery rendered. */ +export const homelab: Project = { + id: 'homelab', + name: 'Self-hosted infrastructure lab', + order: 3, + status: 'active', + template: 'capability', + stack: [ + { name: 'Ubuntu' }, + { name: 'Docker' }, + { name: 'nginx' }, + { name: 'Gitea' }, + { name: 'Monitoring' }, + { name: 'Backups' }, + ], + links: [], + diagram: { + viewBox: '0 0 1120 300', + title: { en: 'Homelab topology', no: 'Hjemmelab-topologi' }, + desc: { + en: 'Inbound traffic reaches an nginx reverse proxy terminating TLS, which routes to self-hosted services: Gitea, application containers and a monitoring stack.', + no: 'Innkommende trafikk når en nginx reverse proxy som terminerer TLS, og rutes videre til egendriftede tjenester: Gitea, applikasjonscontainere og en overvåkingsstack.', + }, + nodes: [ + { + id: 'internet', + x: 40, + y: 116, + w: 160, + h: 72, + kind: 'external', + label: 'Internet', + sub: { en: 'HTTPS', no: 'HTTPS' }, + }, + { + id: 'proxy', + x: 280, + y: 116, + w: 190, + h: 72, + kind: 'primary', + label: 'nginx', + sub: { en: 'reverse proxy · TLS', no: 'reverse proxy · TLS' }, + }, + { + id: 'gitea', + x: 560, + y: 40, + w: 190, + h: 60, + kind: 'internal', + label: 'Gitea', + sub: { en: 'git.cesnimda.uk', no: 'git.cesnimda.uk' }, + }, + { + id: 'apps', + x: 560, + y: 120, + w: 190, + h: 60, + kind: 'internal', + label: 'App services', + sub: { en: 'Docker containers', no: 'Docker-containere' }, + }, + { + id: 'monitor', + x: 560, + y: 200, + w: 190, + h: 60, + kind: 'internal', + label: 'Monitoring', + sub: { en: 'uptime · logs', no: 'oppetid · logger' }, + }, + ], + edges: [ + { d: 'M200 152 H280', kind: 'flow' }, + { d: 'M470 140 L560 70', kind: 'flow' }, + { d: 'M470 152 H560', kind: 'flow' }, + { d: 'M470 164 L560 230', kind: 'flow' }, + ], + }, + media: [], + content: { + en: { + valueProp: 'Where I run, break and fix real infrastructure — not just deploy it.', + cardTeaser: + 'Ubuntu · Docker services · reverse proxy · auth · monitoring · Gitea (this site’s own git remote).', + tldr: { + what: 'A self-hosted home lab running production-style services.', + why: 'To keep operations skills hands-on.', + stack: 'Ubuntu · Docker · nginx · Gitea · monitoring.', + role: 'Operator.', + }, + sections: [ + { + kind: 'problem', + heading: 'What it is', + anchorId: 'problem', + blocks: [ + { + type: 'p', + text: 'A home lab running Ubuntu and Docker-based services behind an nginx reverse proxy, with authentication, monitoring and backups. It’s where I get ongoing, hands-on practice with deployment, troubleshooting and real-world operations — including the self-hosted Gitea instance that this very site is built and deployed from.', + }, + ], + }, + { + kind: 'architecture', + heading: 'How it fits together', + anchorId: 'architecture', + blocks: [ + { + type: 'ul', + items: [ + 'nginx reverse proxy terminating TLS in front of every service.', + 'Docker Compose stacks for applications, each isolated on its own network.', + 'Authentication in front of internal tools; monitoring for uptime and logs.', + 'Self-hosted Gitea with CI runners — the deployment target for this portfolio.', + ], + }, + ], + }, + { + kind: 'next', + heading: 'What’s next', + anchorId: 'next', + blocks: [ + { + type: 'p', + text: 'Tightening backup verification and adding more automated health checks and alerting across the stack.', + }, + ], + }, + ], + }, + no: { + valueProp: 'Der jeg drifter, ødelegger og fikser ekte infrastruktur — ikke bare ruller den ut.', + cardTeaser: + 'Ubuntu · Docker-tjenester · reverse proxy · auth · overvåking · Gitea (git-remoten til denne siden).', + tldr: { + what: 'En egendriftet hjemmelab som kjører produksjonslignende tjenester.', + why: 'For å holde driftsferdighetene praktiske.', + stack: 'Ubuntu · Docker · nginx · Gitea · overvåking.', + role: 'Operatør.', + }, + sections: [ + { + kind: 'problem', + heading: 'Hva det er', + anchorId: 'problem', + blocks: [ + { + type: 'p', + text: 'En hjemmelab som kjører Ubuntu og Docker-baserte tjenester bak en nginx reverse proxy, med autentisering, overvåking og sikkerhetskopier. Det er her jeg får løpende, praktisk øving på utrulling, feilsøking og ekte drift — inkludert den egendriftede Gitea-instansen som denne siden bygges og rulles ut fra.', + }, + ], + }, + { + kind: 'architecture', + heading: 'Hvordan det henger sammen', + anchorId: 'arkitektur', + blocks: [ + { + type: 'ul', + items: [ + 'nginx reverse proxy som terminerer TLS foran hver tjeneste.', + 'Docker Compose-stacker for applikasjoner, hver isolert på eget nettverk.', + 'Autentisering foran interne verktøy; overvåking av oppetid og logger.', + 'Egendriftet Gitea med CI-runnere — utrullingsmålet for denne porteføljen.', + ], + }, + ], + }, + { + kind: 'next', + heading: 'Veien videre', + anchorId: 'videre', + blocks: [ + { + type: 'p', + text: 'Strammere verifisering av sikkerhetskopier og flere automatiske helsesjekker og varsling på tvers av stacken.', + }, + ], + }, + ], + }, + }, +}; diff --git a/site/src/content/projects/inboxintel.ts b/site/src/content/projects/inboxintel.ts new file mode 100644 index 0000000..ad9c233 --- /dev/null +++ b/site/src/content/projects/inboxintel.ts @@ -0,0 +1,324 @@ +import type { Project } from '@lib/schema'; + +/* InboxIntel case study. Source: F:\Documents\InboxIntel\InboxIntel (README + docs). */ +export const inboxintel: Project = { + id: 'inboxintel', + name: 'InboxIntel', + order: 2, + status: 'in-development', + template: 'case-study', + stack: [ + { name: '.NET 8', context: { en: 'ASP.NET Core', no: 'ASP.NET Core' } }, + { name: 'PostgreSQL', context: { en: 'EF Core + Npgsql', no: 'EF Core + Npgsql' } }, + { name: 'React 18', context: { en: 'Vite · Chart.js', no: 'Vite · Chart.js' } }, + { name: 'Gmail API', context: { en: 'OAuth2 · Polly', no: 'OAuth2 · Polly' } }, + { name: 'Serilog' }, + { name: 'Docker' }, + ], + links: [], + diagram: { + viewBox: '0 0 1120 300', + title: { en: 'InboxIntel architecture', no: 'InboxIntel-arkitektur' }, + desc: { + en: 'A React SPA calls an ASP.NET Core API layered as Clean Architecture (Api → Infrastructure → Application → Domain). A hosted background worker syncs the external Gmail API into PostgreSQL with Polly retry/backoff.', + no: 'En React-app kaller et ASP.NET Core-API bygget som Clean Architecture (Api → Infrastructure → Application → Domain). En bakgrunnstjeneste synkroniserer det eksterne Gmail-API-et inn i PostgreSQL med Polly retry/backoff.', + }, + nodes: [ + { + id: 'spa', + x: 40, + y: 116, + w: 170, + h: 72, + kind: 'internal', + label: 'React SPA', + sub: { en: 'Vite · Chart.js', no: 'Vite · Chart.js' }, + }, + { + id: 'api', + x: 290, + y: 116, + w: 200, + h: 72, + kind: 'primary', + label: 'ASP.NET Core API', + sub: { en: 'Clean Architecture', no: 'Clean Architecture' }, + }, + { + id: 'pg', + x: 580, + y: 48, + w: 180, + h: 64, + kind: 'internal', + label: 'PostgreSQL', + sub: { en: 'EF Core · Npgsql', no: 'EF Core · Npgsql' }, + }, + { + id: 'worker', + x: 580, + y: 160, + w: 180, + h: 64, + kind: 'internal', + label: 'Sync worker', + sub: { en: 'hosted · Polly', no: 'hostet · Polly' }, + }, + { + id: 'gmail', + x: 900, + y: 152, + w: 180, + h: 72, + kind: 'external', + label: 'Gmail API', + sub: { en: 'external · OAuth2', no: 'eksternt · OAuth2' }, + }, + ], + edges: [ + { d: 'M210 152 H290', kind: 'flow', label: '/api', labelX: 240, labelY: 144 }, + { d: 'M490 134 L580 92', kind: 'flow' }, + { d: 'M490 170 L580 192', kind: 'flow' }, + { d: 'M670 160 V112', kind: 'flow' }, + { d: 'M760 192 H900', kind: 'external', label: 'sync', labelX: 820, labelY: 184 }, + ], + }, + media: [ + { + src: 'placeholder:inboxintel-dashboard', + width: 1600, + height: 1000, + alt: { + en: 'InboxIntel draggable analytics dashboard with charts', + no: 'InboxIntel dashbord med flyttbare analyse-widgets', + }, + caption: { + en: 'Draggable analytics dashboard (react-grid-layout)', + no: 'Dashbord med flyttbare widgets (react-grid-layout)', + }, + }, + { + src: 'placeholder:inboxintel-cleanup', + width: 1600, + height: 1000, + alt: { + en: 'Bulk cleanup preview screen requiring explicit confirmation', + no: 'Forhåndsvisning av masseopprydding som krever bekreftelse', + }, + caption: { + en: 'Cleanup preview — nothing is deleted without a confirmed flag', + no: 'Oppryddings-forhåndsvisning — ingenting slettes uten bekreftelse', + }, + }, + ], + content: { + en: { + valueProp: 'Gmail analytics and safe bulk cleanup, built as a disciplined Clean Architecture.', + cardTeaser: + 'Gmail analytics and safe bulk cleanup — Clean Architecture, encrypted OAuth tokens, preview-before-delete by design.', + tldr: { + what: 'Gmail analytics, cleanup and unsubscribe management with a background sync worker.', + why: 'To practise Clean Architecture properly and make destructive operations safe by design.', + stack: '.NET 8 · PostgreSQL · React 18 · Gmail API · Serilog · Polly · Docker.', + role: 'Sole architect and developer, front to back.', + }, + sections: [ + { + kind: 'problem', + heading: 'Problem & context', + anchorId: 'problem', + blocks: [ + { + type: 'p', + text: 'A busy inbox needs analytics and cleanup, but bulk operations on real mail are dangerous — one wrong filter can delete things you can’t get back. I built InboxIntel to explore two things at once: a clean, testable backend architecture, and destructive operations that are safe by construction.', + }, + ], + }, + { + kind: 'architecture', + heading: 'Architecture', + anchorId: 'architecture', + blocks: [ + { + type: 'p', + text: 'A four-project Clean Architecture solution behind a React SPA. The dependency rule points inward: Api → Infrastructure → Application → Domain. Controllers hold no business logic — they delegate to Application-layer service interfaces resolved through DI. A hosted background worker syncs Gmail into PostgreSQL with Polly retry and backoff.', + }, + { + type: 'ul', + items: [ + 'Domain: entities and enums, no external dependencies.', + 'Application: service interfaces, DTOs, validators, the Gmail query parser.', + 'Infrastructure: EF Core, the Gmail client, the sync worker, AI and export.', + 'Api: ASP.NET Core controllers, auth, DI and Serilog — thin by design.', + ], + }, + ], + }, + { + kind: 'decisions', + heading: 'Key decisions & trade-offs', + anchorId: 'decisions', + decisions: [ + { + n: 1, + choice: 'Full Clean Architecture across four projects.', + alternative: 'A single project would have shipped faster.', + rationale: + 'I wanted the dependency rule to be enforced by the project structure, not by discipline alone. It costs more ceremony up front and pays back in testability and clear boundaries.', + }, + { + n: 2, + choice: 'Encrypt OAuth refresh tokens at rest and never log them.', + alternative: 'Store them as plain columns.', + rationale: + 'Refresh tokens are long-lived keys to someone’s mailbox. They’re encrypted with the ASP.NET Core Data Protection API (AES), with keys persisted to a mounted volume — the single most important security decision in the app.', + }, + { + n: 3, + choice: 'Every destructive action is preview-then-confirm.', + alternative: 'Delete immediately on request.', + rationale: + 'All cleanup and unsubscribe actions require a server-side preview and an explicit Confirmed flag. The AI layer is advisory only and can never trigger a deletion.', + }, + ], + }, + { + kind: 'security', + heading: 'Security & production notes', + anchorId: 'security', + blocks: [ + { + type: 'ul', + items: [ + 'Gmail scopes are read/modify only — no send scope is ever requested.', + 'Polly provides retry and backoff against Gmail API rate limits and transient failures.', + 'FluentValidation on inputs; Serilog structured logging that never records tokens.', + 'Unit tests cover the query parser and unsubscribe extraction; integration tests boot the API host and assert authorization is enforced.', + ], + }, + ], + }, + { + kind: 'screenshots', + heading: 'Screenshots', + anchorId: 'screenshots', + }, + { + kind: 'next', + heading: 'Status & what’s next', + anchorId: 'next', + blocks: [ + { + type: 'p', + text: 'InboxIntel is in active development: the architecture is complete and the layers compile and are wired end to end, with a few integration points (Gmail parsing edge cases, AI prompt tuning) left as clearly-marked extension points. Next up is hardening those edges and expanding the integration-test suite.', + }, + ], + }, + ], + }, + no: { + valueProp: 'Gmail-analyse og trygg masseopprydding, bygget som en ryddig Clean Architecture.', + cardTeaser: + 'Gmail-analyse og trygg masseopprydding — Clean Architecture, krypterte OAuth-tokens, forhåndsvisning før sletting.', + tldr: { + what: 'Gmail-analyse, opprydding og avmeldingshåndtering med en bakgrunns-synk.', + why: 'For å øve på Clean Architecture skikkelig og gjøre destruktive operasjoner trygge fra bunnen.', + stack: '.NET 8 · PostgreSQL · React 18 · Gmail API · Serilog · Polly · Docker.', + role: 'Eneste arkitekt og utvikler, fra ende til ende.', + }, + sections: [ + { + kind: 'problem', + heading: 'Problem og kontekst', + anchorId: 'problem', + blocks: [ + { + type: 'p', + text: 'En travel innboks trenger analyse og opprydding, men masseoperasjoner på ekte e-post er farlige — ett feil filter kan slette ting du ikke får tilbake. Jeg bygde InboxIntel for å utforske to ting samtidig: en ryddig, testbar backend-arkitektur, og destruktive operasjoner som er trygge fra bunnen.', + }, + ], + }, + { + kind: 'architecture', + heading: 'Arkitektur', + anchorId: 'arkitektur', + blocks: [ + { + type: 'p', + text: 'En løsning med fire prosjekter i Clean Architecture bak en React-app. Avhengighetsregelen peker innover: Api → Infrastructure → Application → Domain. Kontrollerne har ingen forretningslogikk — de delegerer til tjenestegrensesnitt i Application-laget via DI. En bakgrunnstjeneste synkroniserer Gmail inn i PostgreSQL med Polly retry og backoff.', + }, + { + type: 'ul', + items: [ + 'Domain: entiteter og enums, uten eksterne avhengigheter.', + 'Application: tjenestegrensesnitt, DTO-er, validatorer og Gmail-søkeparser.', + 'Infrastructure: EF Core, Gmail-klient, synk-tjeneste, AI og eksport.', + 'Api: ASP.NET Core-kontrollere, auth, DI og Serilog — tynt med hensikt.', + ], + }, + ], + }, + { + kind: 'decisions', + heading: 'Viktige valg og avveininger', + anchorId: 'beslutninger', + decisions: [ + { + n: 1, + choice: 'Full Clean Architecture over fire prosjekter.', + alternative: 'Ett prosjekt hadde vært raskere å levere.', + rationale: + 'Jeg ville at avhengighetsregelen skulle håndheves av prosjektstrukturen, ikke bare av disiplin. Det koster mer seremoni i starten og betaler seg i testbarhet og tydelige grenser.', + }, + { + n: 2, + choice: 'Kryptere OAuth-refresh-tokens i ro og aldri logge dem.', + alternative: 'Lagre dem som vanlige kolonner.', + rationale: + 'Refresh-tokens er langlevde nøkler til noens innboks. De krypteres med ASP.NET Core Data Protection API (AES), med nøkler lagret på et montert volum — det viktigste sikkerhetsvalget i appen.', + }, + { + n: 3, + choice: 'Alle destruktive handlinger er forhåndsvis-så-bekreft.', + alternative: 'Slette umiddelbart ved forespørsel.', + rationale: + 'All opprydding og avmelding krever en server-side forhåndsvisning og et eksplisitt Confirmed-flagg. AI-laget er kun rådgivende og kan aldri utløse en sletting.', + }, + ], + }, + { + kind: 'security', + heading: 'Sikkerhet og drift', + anchorId: 'sikkerhet', + blocks: [ + { + type: 'ul', + items: [ + 'Gmail-scopes er kun lese/endre — send-scope blir aldri etterspurt.', + 'Polly gir retry og backoff mot Gmail-API-ets rategrenser og forbigående feil.', + 'FluentValidation på input; strukturert Serilog-logging som aldri lagrer tokens.', + 'Enhetstester dekker søkeparser og avmeldings-uttrekk; integrasjonstester starter API-verten og sjekker at autorisasjon håndheves.', + ], + }, + ], + }, + { + kind: 'screenshots', + heading: 'Skjermbilder', + anchorId: 'skjermbilder', + }, + { + kind: 'next', + heading: 'Status og veien videre', + anchorId: 'videre', + blocks: [ + { + type: 'p', + text: 'InboxIntel er under aktiv utvikling: arkitekturen er komplett, lagene kompilerer og er koblet ende til ende, med noen integrasjonspunkter (kanttilfeller i Gmail-parsing, tuning av AI-prompter) igjen som tydelig markerte utvidelsespunkter. Neste steg er å herde disse kantene og utvide integrasjonstestene.', + }, + ], + }, + ], + }, + }, +}; diff --git a/site/src/content/projects/jobtrack.ts b/site/src/content/projects/jobtrack.ts new file mode 100644 index 0000000..42ec285 --- /dev/null +++ b/site/src/content/projects/jobtrack.ts @@ -0,0 +1,338 @@ +import type { Project } from '@lib/schema'; + +/* JobTrack case study. Source: D:\Job tracker (README + website_details.md). */ +export const jobtrack: Project = { + id: 'jobtrack', + name: 'JobTrack', + order: 1, + status: 'active', + template: 'case-study', + stack: [ + { name: '.NET 9', context: { en: 'ASP.NET Core API', no: 'ASP.NET Core-API' } }, + { name: 'React', context: { en: 'SPA frontend', no: 'SPA-frontend' } }, + { name: 'TypeScript' }, + { name: 'SQLite', context: { en: 'EF Core', no: 'EF Core' } }, + { name: 'FastAPI', context: { en: 'Python AI service', no: 'Python AI-tjeneste' } }, + { name: 'Ollama', context: { en: 'local LLM', no: 'lokal LLM' } }, + { name: 'Docker' }, + ], + links: [], + diagram: { + viewBox: '0 0 1120 300', + title: { en: 'JobTrack architecture', no: 'JobTrack-arkitektur' }, + desc: { + en: 'A React single-page app talks to an ASP.NET Core API, which persists to SQLite and disk, calls a FastAPI/Ollama AI service, and imports from the external Gmail API over OAuth2.', + no: 'En React-app snakker med et ASP.NET Core-API som lagrer til SQLite og disk, kaller en FastAPI/Ollama AI-tjeneste, og importerer fra det eksterne Gmail-API-et via OAuth2.', + }, + nodes: [ + { + id: 'react', + x: 40, + y: 112, + w: 180, + h: 72, + kind: 'internal', + label: 'React SPA', + sub: { en: 'nginx · PWA', no: 'nginx · PWA' }, + }, + { + id: 'api', + x: 300, + y: 112, + w: 200, + h: 72, + kind: 'primary', + label: 'ASP.NET Core API', + sub: { en: '.NET 9 · auth · rules', no: '.NET 9 · auth · regler' }, + }, + { + id: 'data', + x: 600, + y: 44, + w: 180, + h: 64, + kind: 'internal', + label: 'SQLite + files', + sub: { en: 'EF Core · attachments', no: 'EF Core · vedlegg' }, + }, + { + id: 'ai', + x: 600, + y: 156, + w: 180, + h: 64, + kind: 'internal', + label: 'AI service', + sub: { en: 'FastAPI → Ollama', no: 'FastAPI → Ollama' }, + }, + { + id: 'gmail', + x: 900, + y: 112, + w: 180, + h: 72, + kind: 'external', + label: 'Gmail API', + sub: { en: 'external · OAuth2', no: 'eksternt · OAuth2' }, + }, + ], + edges: [ + { d: 'M220 148 H300', kind: 'flow', label: '/api', labelX: 248, labelY: 140 }, + { d: 'M500 130 L600 88', kind: 'flow' }, + { d: 'M500 166 L600 200', kind: 'flow' }, + { d: 'M500 148 H900', kind: 'external', label: 'import', labelX: 690, labelY: 140 }, + ], + }, + media: [ + { + src: 'placeholder:jobtrack-table', + width: 1600, + height: 1000, + alt: { + en: 'JobTrack applications table with status filters and search', + no: 'JobTrack-søknadstabell med statusfiltre og søk', + }, + caption: { + en: 'Applications table — pipeline at a glance', + no: 'Søknadstabell — hele pipelinen samlet', + }, + }, + { + src: 'placeholder:jobtrack-details', + width: 1600, + height: 1000, + alt: { + en: 'Job details dialog combining summary, notes, correspondence and attachments', + no: 'Jobbdetaljer med sammendrag, notater, korrespondanse og vedlegg', + }, + caption: { + en: 'Job details — everything about one application in one place', + no: 'Jobbdetaljer — alt om én søknad på ett sted', + }, + }, + { + src: 'placeholder:jobtrack-gmail', + width: 1600, + height: 1000, + alt: { + en: 'Gmail import view with smart query filters and thread import', + no: 'Gmail-import med smarte søkefiltre og trådimport', + }, + caption: { + en: 'Gmail import — pull correspondence straight onto the application', + no: 'Gmail-import — hent korrespondanse rett inn på søknaden', + }, + }, + ], + content: { + en: { + valueProp: + 'A job-application workspace covering the full journey from application to follow-up.', + cardTeaser: + 'One workspace for the whole job-application journey — pipeline, correspondence, attachments, AI summaries.', + tldr: { + what: 'Full-stack job-application tracker: pipeline, correspondence, attachments, analytics.', + why: 'A real problem — my own job search needed production-grade tooling, not a spreadsheet.', + stack: 'React + ASP.NET Core + SQLite · FastAPI/Ollama AI service · Docker Compose.', + role: 'Everything: product, backend, frontend, ops and security.', + }, + sections: [ + { + kind: 'problem', + heading: 'Problem & context', + anchorId: 'problem', + blocks: [ + { + type: 'p', + text: 'A serious job search spreads across spreadsheets, email threads, notes apps and scattered documents. Nothing shows you, at a glance, which applications need attention or what was said last. I wanted one focused workspace — and I wanted to build it to the standard I would ship at work, not as a throwaway.', + }, + ], + }, + { + kind: 'architecture', + heading: 'Architecture', + anchorId: 'architecture', + blocks: [ + { + type: 'p', + text: 'Four services behind one Docker Compose file. A React SPA talks to an ASP.NET Core (.NET 9) API that owns the domain logic and follow-up rules, persisting to SQLite via EF Core with attachments on disk. A small FastAPI service backed by a local Ollama model produces summaries and skill extraction, and the API imports correspondence from the Gmail API over OAuth2.', + }, + { + type: 'ul', + items: [ + 'Frontend: React + TypeScript SPA, served by nginx, installable as a PWA with a mobile share-target for quick capture.', + 'API: ASP.NET Core, EF Core, rule-driven follow-up reminders, per-application history trail.', + 'AI: FastAPI wrapper over Ollama for short/full description summaries — advisory only.', + 'Integration: Gmail OAuth2 with smart query filters and single-message or full-thread import.', + ], + }, + ], + }, + { + kind: 'decisions', + heading: 'Key decisions & trade-offs', + anchorId: 'decisions', + decisions: [ + { + n: 1, + choice: 'Run the AI locally with Ollama instead of a cloud API.', + alternative: 'A hosted LLM API would have been faster to wire up.', + rationale: + 'Job-search data is sensitive and I wanted zero per-call cost and no third party in the loop. The trade-off is more setup and heavier local resources — acceptable for a self-hosted, single-user tool.', + }, + { + n: 2, + choice: 'Ship the PWA with no offline service-worker cache.', + alternative: 'A cache would enable full offline use.', + rationale: + 'I deploy frequently, so an aggressive cache risks serving stale builds — a worse failure than a brief offline gap. The manifest still provides installability and share-to-capture. A deliberate anti-feature.', + }, + { + n: 3, + choice: 'SQLite rather than PostgreSQL.', + alternative: 'Postgres is the default reach for a web backend.', + rationale: + 'This is a single-user app; SQLite removes an entire service with no practical downside here. If it ever went multi-tenant, moving to Postgres via EF Core is a contained change.', + }, + ], + }, + { + kind: 'security', + heading: 'Security & production notes', + anchorId: 'security', + blocks: [ + { + type: 'ul', + items: [ + 'Optional Google sign-in (Google ID tokens) protects the API; every record is scoped to its owner.', + 'File uploads are validated and stored per-application with ownership checks on every access.', + 'The AI layer is advisory only — it never performs destructive actions.', + 'Runs as a reproducible Docker Compose stack with a documented .env; scheduled JSON/CSV exports for data portability.', + ], + }, + ], + }, + { + kind: 'screenshots', + heading: 'Screenshots', + anchorId: 'screenshots', + }, + { + kind: 'next', + heading: 'What I’d improve next', + anchorId: 'next', + blocks: [ + { + type: 'p', + text: 'The rules engine for follow-ups is simple date logic; I’d make it configurable per pipeline stage. I’d also add a proper integration-test pass around the Gmail import edge cases, and tighten the summariser prompts now that I have real usage to learn from.', + }, + ], + }, + ], + }, + no: { + valueProp: 'Et arbeidsrom for jobbsøking som dekker hele reisen fra søknad til oppfølging.', + cardTeaser: + 'Ett arbeidsrom for hele jobbsøkerreisen — pipeline, korrespondanse, vedlegg og AI-sammendrag.', + tldr: { + what: 'Fullstack jobbsøknads-tracker: pipeline, korrespondanse, vedlegg og analyse.', + why: 'Et ekte problem — min egen jobbsøking trengte skikkelig verktøy, ikke et regneark.', + stack: 'React + ASP.NET Core + SQLite · FastAPI/Ollama AI-tjeneste · Docker Compose.', + role: 'Alt: produkt, backend, frontend, drift og sikkerhet.', + }, + sections: [ + { + kind: 'problem', + heading: 'Problem og kontekst', + anchorId: 'problem', + blocks: [ + { + type: 'p', + text: 'En seriøs jobbsøking sprer seg over regneark, e-posttråder, notatapper og løse dokumenter. Ingenting viser deg med ett blikk hvilke søknader som trenger oppfølging, eller hva som sist ble sagt. Jeg ville ha ett samlet arbeidsrom — og bygge det til samme standard som jeg leverer på jobb, ikke som noe engangs.', + }, + ], + }, + { + kind: 'architecture', + heading: 'Arkitektur', + anchorId: 'arkitektur', + blocks: [ + { + type: 'p', + text: 'Fire tjenester bak én Docker Compose-fil. En React-app snakker med et ASP.NET Core-API (.NET 9) som eier domenelogikken og oppfølgingsreglene, og lagrer til SQLite via EF Core med vedlegg på disk. En liten FastAPI-tjeneste med en lokal Ollama-modell lager sammendrag og trekker ut ferdigheter, og API-et importerer korrespondanse fra Gmail-API-et via OAuth2.', + }, + { + type: 'ul', + items: [ + 'Frontend: React + TypeScript, servert av nginx, installerbar som PWA med share-target for rask registrering.', + 'API: ASP.NET Core, EF Core, regelstyrte oppfølgingspåminnelser og historikk per søknad.', + 'AI: FastAPI over Ollama for korte og fulle sammendrag — kun rådgivende.', + 'Integrasjon: Gmail OAuth2 med smarte søkefiltre og import av enkeltmeldinger eller hele tråder.', + ], + }, + ], + }, + { + kind: 'decisions', + heading: 'Viktige valg og avveininger', + anchorId: 'beslutninger', + decisions: [ + { + n: 1, + choice: 'Kjøre AI-en lokalt med Ollama i stedet for et sky-API.', + alternative: 'Et hostet LLM-API hadde vært raskere å koble til.', + rationale: + 'Jobbsøkerdata er sensitivt, og jeg ville ha null kostnad per kall og ingen tredjepart i loopen. Avveiningen er mer oppsett og tyngre lokale ressurser — greit for et egendriftet verktøy med én bruker.', + }, + { + n: 2, + choice: 'Levere PWA-en uten offline-cache i service-workeren.', + alternative: 'En cache ville gitt full offline-bruk.', + rationale: + 'Jeg ruller ut ofte, så en aggressiv cache risikerer å servere utdaterte bygg — en verre feil enn et kort offline-hull. Manifestet gir fortsatt installerbarhet og share-to-capture. En bevisst anti-funksjon.', + }, + { + n: 3, + choice: 'SQLite framfor PostgreSQL.', + alternative: 'Postgres er standardvalget for en web-backend.', + rationale: + 'Dette er en app for én bruker; SQLite fjerner en hel tjeneste uten praktisk ulempe her. Skulle den bli fler-bruker, er overgang til Postgres via EF Core en avgrenset endring.', + }, + ], + }, + { + kind: 'security', + heading: 'Sikkerhet og drift', + anchorId: 'sikkerhet', + blocks: [ + { + type: 'ul', + items: [ + 'Valgfri Google-innlogging (Google ID-tokens) beskytter API-et; hver post er knyttet til sin eier.', + 'Filopplastinger valideres og lagres per søknad, med eierskapssjekk ved hvert tilgangspunkt.', + 'AI-laget er kun rådgivende — det utfører aldri destruktive handlinger.', + 'Kjører som en reproduserbar Docker Compose-stack med dokumentert .env; planlagte JSON/CSV-eksporter for dataportabilitet.', + ], + }, + ], + }, + { + kind: 'screenshots', + heading: 'Skjermbilder', + anchorId: 'skjermbilder', + }, + { + kind: 'next', + heading: 'Hva jeg ville forbedret', + anchorId: 'videre', + blocks: [ + { + type: 'p', + text: 'Regelmotoren for oppfølging er enkel datologikk; jeg ville gjort den konfigurerbar per pipeline-steg. Jeg ville også lagt til skikkelige integrasjonstester rundt kanttilfellene i Gmail-importen, og strammet inn sammendrags-promptene nå som jeg har ekte bruk å lære av.', + }, + ], + }, + ], + }, + }, +}; diff --git a/site/src/content/skills.ts b/site/src/content/skills.ts new file mode 100644 index 0000000..a5f0cf8 --- /dev/null +++ b/site/src/content/skills.ts @@ -0,0 +1,56 @@ +import type { Skills } from '@lib/schema'; + +/* Skills — grouped chips, one context line per group (CONTENT_STRATEGY §3). No bars. */ +export const skills: Skills = { + groups: [ + { + id: 'development', + title: { en: 'Development', no: 'Utvikling' }, + context: { + en: 'Backend-leaning full-stack work in the .NET ecosystem.', + no: 'Fullstack med tyngde på backend i .NET-økosystemet.', + }, + skills: [ + { name: 'C#' }, + { name: '.NET' }, + { name: 'Python' }, + { name: 'TypeScript' }, + { name: 'JavaScript' }, + { name: 'React' }, + { name: 'SQL' }, + ], + }, + { + id: 'devops-infrastructure', + title: { en: 'DevOps & Infrastructure', no: 'DevOps og infrastruktur' }, + context: { + en: 'I deploy and operate what I build — professionally and at home.', + no: 'Jeg ruller ut og drifter det jeg bygger — på jobb og hjemme.', + }, + skills: [ + { name: 'Docker' }, + { name: 'Linux' }, + { name: 'CI/CD' }, + { name: 'Azure DevOps' }, + { name: 'Reverse proxies' }, + { name: 'Monitoring' }, + { name: 'Self-hosting' }, + ], + }, + { + id: 'practices', + title: { en: 'Practices', no: 'Arbeidsmåte' }, + context: { + en: 'Production thinking: testing, security and honest communication.', + no: 'Produksjonstankegang: testing, sikkerhet og ærlig kommunikasjon.', + }, + skills: [ + { name: 'Testing' }, + { name: 'OAuth2 integrations' }, + { name: 'Security hardening' }, + { name: 'Production support' }, + { name: 'Stakeholder communication' }, + ], + }, + ], +}; diff --git a/site/src/lib/content.ts b/site/src/lib/content.ts new file mode 100644 index 0000000..5b6d56e --- /dev/null +++ b/site/src/lib/content.ts @@ -0,0 +1,162 @@ +/* + Content loader. Imports the typed data modules, validates each against its zod + schema at build (an invalid entry fails the build — DATA_MODEL §8), and exposes + locale-resolved view models so components never deal with {en,no} pairs directly. +*/ + +import * as S from './schema'; +import { profile as profileData } from '@/content/profile'; +import { skills as skillsData } from '@/content/skills'; +import { experience as experienceData } from '@/content/experience'; +import { meta as metaData } from '@/content/meta'; +import { jobtrack } from '@/content/projects/jobtrack'; +import { inboxintel } from '@/content/projects/inboxintel'; +import { homelab } from '@/content/projects/homelab'; +import type { Locale } from '@i18n/locales'; +import { pathFor, type PageId, type ProjectPageId } from '@i18n/slugMap'; + +// ---- Validate at module load (build-time gate) ---- +const PROFILE = S.profile.parse(profileData); +const SKILLS = S.skills.parse(skillsData); +const EXPERIENCE = S.experience.parse(experienceData); +const META = S.meta.parse(metaData); +const PROJECTS: S.Project[] = [jobtrack, inboxintel, homelab] + .map((p) => S.project.parse(p)) + .sort((a, b) => a.order - b.order); + +/** Pick the locale variant of a localised pair. */ +function pick(pair: { en: T; no: T }, locale: Locale): T { + return pair[locale]; +} + +// ---------------- Profile ---------------- +export function getProfile(locale: Locale) { + const c = PROFILE.content[locale]; + return { + name: PROFILE.name, + links: PROFILE.links, + cv: PROFILE.cv, + cvCurrent: PROFILE.cv[locale], + photo: { + src: PROFILE.photo.src, + alt: pick(PROFILE.photo.alt, locale), + caption: pick(PROFILE.photo.caption, locale), + }, + languages: PROFILE.languages.map((l) => ({ + label: pick(l.label, locale), + level: pick(l.level, locale), + })), + chips: PROFILE.chips.map((ch) => ({ id: ch.id, label: pick(ch.label, locale) })), + proof: PROFILE.proof.map((p) => ({ stat: pick(p.stat, locale), label: pick(p.label, locale) })), + ...c, + }; +} +export type ProfileVM = ReturnType; + +// ---------------- Skills ---------------- +export function getSkills(locale: Locale) { + return SKILLS.groups.map((g) => ({ + id: g.id, + title: pick(g.title, locale), + context: pick(g.context, locale), + skills: g.skills.map((s) => ({ + name: s.name, + tooltip: s.tooltip ? pick(s.tooltip, locale) : undefined, + })), + })); +} + +// ---------------- Experience ---------------- +export function getExperience(locale: Locale) { + return EXPERIENCE.items.map((it) => ({ + id: it.id, + employer: it.employer, + location: it.location, + period: it.period, + emphasis: it.emphasis, + alongside: it.alongside, + progression: it.progression?.map((p) => ({ + from: p.from, + to: p.to, + label: pick(p.label, locale), + })), + role: pick(it.role, locale), + summary: it.summary ? pick(it.summary, locale) : undefined, + highlights: it.highlights ? pick(it.highlights, locale) : undefined, + })); +} + +// ---------------- Projects ---------------- +function projectCard(p: S.Project, locale: Locale) { + const c = p.content[locale]; + return { + id: p.id as ProjectPageId, + name: p.name, + status: p.status, + order: p.order, + template: p.template, + path: pathFor(p.id as PageId, locale), + stack: p.stack.map((s) => ({ + name: s.name, + context: s.context ? pick(s.context, locale) : undefined, + })), + valueProp: c.valueProp, + cardTeaser: c.cardTeaser, + heroMedia: p.media[0] + ? { + src: p.media[0].src, + width: p.media[0].width, + height: p.media[0].height, + alt: pick(p.media[0].alt, locale), + caption: pick(p.media[0].caption, locale), + } + : undefined, + }; +} + +export function getProjectCards(locale: Locale) { + return PROJECTS.map((p) => projectCard(p, locale)); +} + +export function getProject(id: ProjectPageId, locale: Locale) { + const p = PROJECTS.find((x) => x.id === id); + if (!p) throw new Error(`Unknown project: ${id}`); + const c = p.content[locale]; + return { + ...projectCard(p, locale), + links: p.links, + diagram: p.diagram + ? { + viewBox: p.diagram.viewBox, + title: pick(p.diagram.title, locale), + desc: pick(p.diagram.desc, locale), + nodes: p.diagram.nodes.map((n) => ({ + ...n, + sub: n.sub ? pick(n.sub, locale) : undefined, + })), + edges: p.diagram.edges, + } + : undefined, + media: p.media.map((m) => ({ + src: m.src, + width: m.width, + height: m.height, + alt: pick(m.alt, locale), + caption: pick(m.caption, locale), + })), + tldr: c.tldr, + sections: c.sections, + }; +} +export type ProjectVM = ReturnType; + +export function getProjectIds(): ProjectPageId[] { + return PROJECTS.map((p) => p.id as ProjectPageId); +} + +// ---------------- Meta ---------------- +export function getMeta(pageId: PageId, locale: Locale): { title: string; description: string } { + const m = META[pageId]; + if (!m) throw new Error(`No page meta for: ${pageId}`); + return m[locale]; +} diff --git a/site/src/lib/schema.ts b/site/src/lib/schema.ts new file mode 100644 index 0000000..c4078f7 --- /dev/null +++ b/site/src/lib/schema.ts @@ -0,0 +1,194 @@ +/* + Content schemas (DATA_MODEL.md), validated at build via zod (bundled with Astro). + Model: invariant facts live once at the top level; locale-varying prose lives under + `content.{en,no}` or as `{en,no}` pairs — so facts exist once, prose exists twice + (DATA_MODEL, I18N_SPEC §3). Any violation fails the build. +*/ + +import { z } from 'astro/zod'; + +/** A localised string pair. */ +export const loc = z.object({ en: z.string(), no: z.string() }); +/** A localised string-array pair. */ +export const locArr = z.object({ en: z.array(z.string()), no: z.array(z.string()) }); + +/** Prose block — the only content primitives (no markdown runtime needed). */ +export const block = z.discriminatedUnion('type', [ + z.object({ type: z.literal('p'), text: z.string() }), + z.object({ type: z.literal('ul'), items: z.array(z.string()) }), +]); +export type Block = z.infer; + +export const link = z.object({ + type: z.enum(['repo', 'live', 'docs']), + url: z.string().url(), + public: z.boolean().default(true), +}); + +export const stackChip = z.object({ + name: z.string(), + context: loc.optional(), +}); + +export const media = z.object({ + src: z.string(), + width: z.number().int().positive(), + height: z.number().int().positive(), + alt: loc, + caption: loc, +}); + +/* ---- Architecture diagram (data-driven; localised labels) ---- */ +export const diagramNode = z.object({ + id: z.string(), + x: z.number(), + y: z.number(), + w: z.number(), + h: z.number(), + kind: z.enum(['internal', 'primary', 'external']), + label: z.string(), + sub: loc.optional(), +}); +export const diagramEdge = z.object({ + d: z.string(), // SVG path + kind: z.enum(['flow', 'external']), + label: z.string().optional(), + labelX: z.number().optional(), + labelY: z.number().optional(), +}); +export const diagram = z.object({ + viewBox: z.string(), + title: loc, + desc: loc, + nodes: z.array(diagramNode), + edges: z.array(diagramEdge), +}); + +/* ---- Case-study sections ---- */ +export const decision = z.object({ + n: z.number().int().positive(), + choice: z.string(), + alternative: z.string(), + rationale: z.string(), +}); + +export const SECTION_KINDS = [ + 'problem', + 'architecture', + 'decisions', + 'security', + 'screenshots', + 'next', +] as const; + +export const section = z.object({ + kind: z.enum(SECTION_KINDS), + heading: z.string(), + anchorId: z.string(), + blocks: z.array(block).optional(), + decisions: z.array(decision).optional(), +}); + +export const projectLocaleContent = z.object({ + valueProp: z.string(), + cardTeaser: z.string(), + tldr: z.object({ what: z.string(), why: z.string(), stack: z.string(), role: z.string() }), + sections: z.array(section), +}); + +export const project = z.object({ + id: z.enum(['jobtrack', 'inboxintel', 'homelab']), + name: z.string(), + order: z.number().int(), + status: z.enum(['active', 'in-development', 'archived']), + template: z.enum(['case-study', 'capability']), + stack: z.array(stackChip), + links: z.array(link).default([]), + diagram: diagram.optional(), + media: z.array(media).default([]), + content: z.object({ en: projectLocaleContent, no: projectLocaleContent }), +}); +export type Project = z.infer; + +/* ---- Skills ---- */ +export const skillGroup = z.object({ + id: z.enum(['development', 'devops-infrastructure', 'practices']), + title: loc, + context: loc, + skills: z.array(z.object({ name: z.string(), tooltip: loc.optional() })), +}); +export const skills = z.object({ groups: z.array(skillGroup) }); +export type Skills = z.infer; + +/* ---- Experience ---- */ +export const experienceItem = z.object({ + id: z.string(), + employer: z.string(), + location: z.string(), + period: z.object({ from: z.number().int(), to: z.number().int().nullable() }), + emphasis: z.enum(['featured', 'compact']), + alongside: z.boolean().default(false), + progression: z + .array(z.object({ from: z.number().int(), to: z.number().int(), label: loc })) + .optional(), + role: loc, + summary: loc.optional(), + highlights: locArr.optional(), +}); +export const experience = z.object({ items: z.array(experienceItem) }); +export type Experience = z.infer; + +/* ---- Profile (singleton) ---- */ +export const profile = z.object({ + name: z.string(), + links: z.object({ + email: z.string().email(), + phone: z.string(), + phoneHref: z.string(), + linkedin: z.string(), + gitea: z.string(), + }), + cv: z.object({ + en: z.object({ path: z.string(), sizeKb: z.number(), updated: z.string() }), + no: z.object({ path: z.string(), sizeKb: z.number(), updated: z.string() }), + }), + photo: z.object({ src: z.string(), alt: loc, caption: loc }), + languages: z.array(z.object({ label: loc, level: loc })), + chips: z.array(z.object({ id: z.string(), label: loc })), + proof: z.array(z.object({ stat: loc, label: loc })), + content: z.object({ + en: z.object({ + eyebrow: z.string(), + roleLine: z.string(), + heroTagline: z.string(), + heroSummary: z.string(), + statusLine: z.string(), + aboutHeading: z.string(), + aboutParagraphs: z.array(z.string()), + interestsLine: z.string(), + contactHeading: z.string(), + contactBody: z.string(), + }), + no: z.object({ + eyebrow: z.string(), + roleLine: z.string(), + heroTagline: z.string(), + heroSummary: z.string(), + statusLine: z.string(), + aboutHeading: z.string(), + aboutParagraphs: z.array(z.string()), + interestsLine: z.string(), + contactHeading: z.string(), + contactBody: z.string(), + }), + }), +}); +export type Profile = z.infer; + +/* ---- Per-page SEO meta ---- */ +export const pageMeta = z.object({ + en: z.object({ title: z.string().max(70), description: z.string().max(160) }), + no: z.object({ title: z.string().max(70), description: z.string().max(160) }), +}); +export const meta = z.record(z.string(), pageMeta); +export type Meta = z.infer;