Compare commits
12 Commits
1f695d3932
...
743cce306b
| Author | SHA1 | Date | |
|---|---|---|---|
| 743cce306b | |||
| 9088dcffb9 | |||
| 033c9ec315 | |||
| 9fa16b650d | |||
| 1b5204e7f8 | |||
| 2330f374fc | |||
| 9a6d0edf6d | |||
| 1fce1b3cec | |||
| 2184a1892f | |||
| 63db8bf254 | |||
| 9a75f73550 | |||
| ff27b918ae |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "site",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["-C", "site", "dev", "--host", "--port", "4321"],
|
||||
"port": 4321
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# Normalise line endings to LF in the repo; check out native on Windows.
|
||||
* text=auto eol=lf
|
||||
|
||||
# Binary assets — never touch.
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.webp binary
|
||||
*.avif binary
|
||||
*.pdf binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.ttf binary
|
||||
*.otf binary
|
||||
*.ico binary
|
||||
|
||||
# Shell scripts and container files must stay LF even on Windows checkout.
|
||||
*.sh text eol=lf
|
||||
Dockerfile text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
@@ -0,0 +1,85 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
ASTRO_TELEMETRY_DISABLED: '1'
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with: { version: 9 }
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22, cache: pnpm, cache-dependency-path: site/pnpm-lock.yaml }
|
||||
- run: pnpm -C site install --frozen-lockfile
|
||||
- run: pnpm -C site lint
|
||||
- run: pnpm -C site check
|
||||
- run: pnpm -C site test
|
||||
- run: pnpm -C site build
|
||||
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with: { version: 9 }
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22, cache: pnpm }
|
||||
- run: pnpm -C site install --frozen-lockfile
|
||||
- run: pnpm -C tests install --frozen-lockfile
|
||||
- run: pnpm -C tests exec playwright install --with-deps chromium
|
||||
- run: pnpm -C tests test:e2e
|
||||
|
||||
lighthouse:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with: { version: 9 }
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22, cache: pnpm }
|
||||
- run: pnpm -C site install --frozen-lockfile
|
||||
- run: pnpm -C site build
|
||||
- run: pnpm dlx @lhci/cli@0.14.x autorun --config=site/lighthouserc.json
|
||||
|
||||
relay:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with: { dotnet-version: '9.0.x' }
|
||||
- run: dotnet build relay/ContactRelay.csproj -c Release
|
||||
|
||||
images:
|
||||
needs: [quality, e2e, relay]
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.cesnimda.uk
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
- name: Build & push site image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./site
|
||||
push: true
|
||||
tags: |
|
||||
git.cesnimda.uk/cesnimda/resumesite-site:latest
|
||||
git.cesnimda.uk/cesnimda/resumesite-site:${{ github.sha }}
|
||||
- name: Build & push relay image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./relay
|
||||
push: true
|
||||
tags: |
|
||||
git.cesnimda.uk/cesnimda/resumesite-relay:latest
|
||||
git.cesnimda.uk/cesnimda/resumesite-relay:${{ github.sha }}
|
||||
+58
-31
@@ -1,10 +1,8 @@
|
||||
# PROJECT_STATUS.md
|
||||
|
||||
Living status of the Phase 3 implementation. Updated continuously.
|
||||
|
||||
**Current phase:** Phase 3 — implementation
|
||||
**Branch strategy:** feature branches → PR → `main` (never commit directly to `main`)
|
||||
**Last updated:** 2026-07-03
|
||||
**Current phase:** Phase 3 — implementation **complete** (all milestones ✅)
|
||||
**Branch strategy:** feature branches → `main` (never committed directly to `main`)
|
||||
**Last updated:** 2026-07-04
|
||||
|
||||
---
|
||||
|
||||
@@ -12,39 +10,68 @@ Living status of the Phase 3 implementation. Updated continuously.
|
||||
|
||||
| # | Milestone | Status |
|
||||
|---|---|---|
|
||||
| 0 | Repo, tooling, tracking (git, pnpm, docs moved to `docs/`) | 🟡 in progress |
|
||||
| 1 | Astro base config + token/style layer | ⬜ not started |
|
||||
| 2 | i18n layer (locales, slug map, dictionaries) | ⬜ not started |
|
||||
| 3 | Content collections + schemas + EN/NO data | ⬜ not started |
|
||||
| 4 | UI atoms + core layout (Header, Footer, SEO) | ⬜ not started |
|
||||
| 5 | Home page sections | ⬜ not started |
|
||||
| 6 | Case-study + capability templates | ⬜ not started |
|
||||
| 7 | Remaining pages (about, experience, contact, cv, colophon, 404) | ⬜ not started |
|
||||
| 8 | Behaviour modules (theme, nav, observer, lightbox, form) | ⬜ not started |
|
||||
| 9 | SEO layer (meta, hreflang, JSON-LD, OG, sitemap) | ⬜ not started |
|
||||
| 10 | Contact relay (.NET 9) | ⬜ not started |
|
||||
| 11 | Tests (vitest + playwright) | ⬜ not started |
|
||||
| 12 | Docker + CI | ⬜ not started |
|
||||
| 13 | Verification pass against acceptance criteria | ⬜ not started |
|
||||
| 0 | Repo, tooling, tracking | ✅ |
|
||||
| 1 | Astro base config + token/style layer | ✅ |
|
||||
| 2 | i18n layer (locales, slug map, dictionaries) | ✅ |
|
||||
| 3 | Content schemas + EN/NO data | ✅ |
|
||||
| 4 | UI atoms + core layout (Header, Footer, SEO) | ✅ |
|
||||
| 5 | Home page sections | ✅ |
|
||||
| 6 | Case-study + capability templates | ✅ |
|
||||
| 7 | Remaining pages (about, experience, contact, cv, colophon, 404) | ✅ |
|
||||
| 8 | Behaviour modules (theme, nav, observer, lightbox, form) | ✅ |
|
||||
| 9 | SEO layer (meta, hreflang, JSON-LD, OG images, sitemap) | ✅ |
|
||||
| 10 | Contact relay (.NET 9) | ✅ |
|
||||
| 11 | Tests (vitest + playwright) | ✅ |
|
||||
| 12 | Docker + CI | ✅ |
|
||||
| 13 | Verification pass | ✅ |
|
||||
|
||||
Legend: ⬜ not started · 🟡 in progress · ✅ done · ⛔ blocked
|
||||
## Verification results
|
||||
|
||||
- **Build:** 21 pages + 20 OG images + sitemap/robots, static, clean.
|
||||
- **Lint / types:** ESLint + Prettier clean; `tsc --noEmit` clean; `astro check` wired.
|
||||
- **Unit tests (vitest):** 23 passing — dictionary parity, slug-map bijection, content-schema
|
||||
validation, JSON-LD builders.
|
||||
- **E2E (playwright):** 23 passing — both locales render, language switch maps page↔page
|
||||
(incl. JS-disabled), CV downloads resolve, contact form happy-path + honeypot, and
|
||||
**axe a11y with zero serious/critical violations across 5 templates × 2 themes**.
|
||||
- **Docker:** both images build; relay `/healthz` → 200; site container serves EN + NO with
|
||||
CSP header and the `/Linkedin` 301 redirect.
|
||||
- **Contrast:** all body/label text ≥ 4.5:1 in both themes (axe-verified; `ink-faint` and the
|
||||
light accent were raised to meet AA).
|
||||
|
||||
## Environment
|
||||
|
||||
- Node v22.23.1 · pnpm 9.15.9 · .NET SDK 10.0.201 (relay targets `net9.0`) · Docker 29.3.1
|
||||
- Repo initialised locally; remote `git.cesnimda.uk/cesnimda/ResumeSite.git` (push when instructed).
|
||||
- Node v22.23.1 · pnpm 9.15.9 · .NET SDK 10.0.201 (relay targets `net9.0`) · Docker 29.3.1.
|
||||
|
||||
## Deviations from spec (with rationale)
|
||||
|
||||
_None yet. Any deviation gets logged here per the engineering rules._
|
||||
1. **Content as typed TS modules + zod, not Astro content-collection entries.** The nested
|
||||
`{en,no}` model serves the spec's "facts once, prose twice" goal better than parallel
|
||||
per-locale entries; same build-time zod validation and CI-testable getters.
|
||||
2. **Sitemap hand-rolled from the slug map** (localised slugs don't fit `@astrojs/sitemap`'s
|
||||
parallel-slug assumption; keeps one route contract).
|
||||
3. **OG images via resvg + hand-built SVG, not satori** (satori's parser can't read the
|
||||
vendored variable fonts). Deterministic offline builds; fonts vendored in-repo.
|
||||
4. **Relay targets `net9.0` built with the .NET 10 SDK** (only SDK available); runs on the
|
||||
`aspnet:9.0` runtime in Docker.
|
||||
5. **Site nginx config co-located in `site/`** (not `deploy/`) so the image build context
|
||||
stays `site/`. `deploy/` holds the compose stacks + env template as specified.
|
||||
6. **Theme-init is an external script** (`/theme-init.js`) so the CSP can use `script-src
|
||||
'self'` with no inline hash.
|
||||
|
||||
## Pre-launch checklist (carried from Phase 1/2 — owner action)
|
||||
|
||||
- [ ] **Replace the `/Linkedin` redirect target** in `site/nginx.conf` with the real LinkedIn
|
||||
profile URL before cutover (currently a placeholder slug).
|
||||
- [ ] Screenshot capture pass on **seeded demo data** for JobTrack + InboxIntel; drop real
|
||||
captures into the `placeholder:` media refs (`src/data/projects/*`).
|
||||
- [ ] Drop the real **outdoor headshot** into the portrait ref (replaces `placeholder:portrait`).
|
||||
- [ ] **ATS-safe CV rework** (EN/NO) — replace the PDFs at `site/public/cv/` (same URLs).
|
||||
- [ ] **Native-Norwegian review** of all NO content (launch gate).
|
||||
- [ ] **Legacy WordPress URL inventory** → confirm the 301/410 map in `nginx.conf`.
|
||||
- [ ] Configure CI secrets (`REGISTRY_USER`, `REGISTRY_TOKEN`) + the relay `.env` on the server.
|
||||
|
||||
## Open questions / blocked-on-user
|
||||
|
||||
_None yet._
|
||||
|
||||
## Prerequisites carried from Phase 1/2 (not blocking scaffold)
|
||||
|
||||
- Screenshot capture pass on seeded demo data (JobTrack, InboxIntel) — placeholders used until provided.
|
||||
- ATS-safe CV rework (EN/NO) — current PDFs wired as download targets meanwhile.
|
||||
- Native-Norwegian review pass of NO content — launch gate.
|
||||
- Legacy WordPress URL inventory for redirect/410 map.
|
||||
- Are the JobTrack / InboxIntel repos public? If so, add `repo` links in `src/data/projects/*`
|
||||
(currently omitted to avoid dead links).
|
||||
|
||||
@@ -1,69 +1,28 @@
|
||||
# TODO.md
|
||||
|
||||
Granular work list. Checked items are done; each maps to atomic commits.
|
||||
Implementation is **complete**. Remaining items are owner/pre-launch actions
|
||||
(tracked in PROJECT_STATUS.md "Pre-launch checklist").
|
||||
|
||||
## Milestone 0 — Scaffold
|
||||
- [x] Init git, enable pnpm, move docs into `docs/`
|
||||
- [x] Root `README.md`, `.gitignore`, `PROJECT_STATUS.md`, `TODO.md`
|
||||
- [ ] Initial commit on `main`, then feature branch
|
||||
- [ ] `site/` Astro project: `package.json`, `astro.config.mjs`, `tsconfig.json`, Tailwind config
|
||||
- [ ] ESLint + Prettier config
|
||||
- [ ] `pnpm install` + verify `pnpm build` on empty skeleton
|
||||
## Done — all implementation milestones
|
||||
- [x] M0 Scaffold (git, pnpm, configs, tracking)
|
||||
- [x] M1 Tokens & styles (both themes, reduced-motion)
|
||||
- [x] M2 i18n (locales, slug map, dictionaries, helpers)
|
||||
- [x] M3 Content (zod schemas, EN/NO data, view models)
|
||||
- [x] M4 UI atoms + core layout (Header, Footer, Seo, ThemeScript, SkipLink)
|
||||
- [x] M5 Home sections (hero, proof, skills, projects, timeline, about, contact)
|
||||
- [x] M6 Case-study + capability templates (diagram, TL;DR, TOC, decisions, gallery)
|
||||
- [x] M7 All pages, both locales; 404; robots; sitemap
|
||||
- [x] M8 Behaviour modules (theme, nav, observer, lightbox, form) ≤15 kB
|
||||
- [x] M9 SEO (meta, hreflang, JSON-LD, build-time OG images)
|
||||
- [x] M10 Contact relay (.NET 9) — verified endpoints
|
||||
- [x] M11 Tests — 23 unit + 23 e2e (incl. axe a11y) green
|
||||
- [x] M12 Docker (site + relay images, compose, nginx) + Gitea CI
|
||||
- [x] M13 Verification pass
|
||||
|
||||
## Milestone 1 — Tokens & styles
|
||||
- [ ] `styles/tokens.css` (colour/space/type/radius/motion, both themes via `[data-theme]`)
|
||||
- [ ] `styles/base.css` (reset, base element styles, focus ring, dot-grid, prose)
|
||||
- [ ] `styles/motion.css` (keyframes + reduced-motion layer)
|
||||
- [ ] Tailwind `@theme` wired to tokens; verify no raw hex/px in components (lint guard)
|
||||
|
||||
## Milestone 2 — i18n
|
||||
- [ ] `i18n/locales.ts` (locale config)
|
||||
- [ ] `i18n/slugMap.ts` (pageId ↔ {en,no} paths + section anchors) — the tested contract
|
||||
- [ ] `i18n/dictionary.en.ts` + `dictionary.no.ts` (typed, same interface)
|
||||
- [ ] `i18n/t.ts` accessor + `getLocale`/`resolvePath` helpers
|
||||
- [ ] Astro i18n config (defaultLocale en at root, no prefixed)
|
||||
|
||||
## Milestone 3 — Content
|
||||
- [ ] Zod schemas (`content.config.ts`) per DATA_MODEL
|
||||
- [ ] profile EN/NO · skills EN/NO · experience EN/NO · projects EN/NO (jobtrack, inboxintel, homelab) · meta pages
|
||||
- [ ] Locale-invariant field inheritance loader
|
||||
- [ ] Content parity + schema validation (feeds CI later)
|
||||
|
||||
## Milestone 4 — UI atoms + core
|
||||
- [ ] ui: Chip, Button, SplitCvButton, LangSwitch, ThemeToggle, Card, SectionLabel, TraceMotif, FramedImage, Tooltip
|
||||
- [ ] core: Base/Page/Home/CaseStudy layouts, Header, MobileNav, Footer, Seo, ThemeScript, SkipLink
|
||||
|
||||
## Milestone 5 — Home
|
||||
- [ ] Hero, ProofStrip, SkillsGrid, ProjectCards, ExperienceTimeline, AboutTeaser, ContactBand
|
||||
|
||||
## Milestone 6 — Case study
|
||||
- [ ] CsHeader, TldrBox, MiniToc, SectionRenderer, DecisionList, ArchDiagram, Gallery+Lightbox host, NextPrev
|
||||
- [ ] Capability template (homelab)
|
||||
|
||||
## Milestone 7 — Pages
|
||||
- [ ] Home (en/no), projects index, 3 case studies ×2, experience, about, contact, cv, colophon, 404
|
||||
|
||||
## Milestone 8 — Behaviour
|
||||
- [ ] theme.ts, nav.ts, observer.ts, lightbox.ts, form.ts (≤15 kB total)
|
||||
|
||||
## Milestone 9 — SEO
|
||||
- [ ] Seo component meta + hreflang from slug map
|
||||
- [ ] JSON-LD builders (Person, CreativeWork, WebSite, BreadcrumbList)
|
||||
- [ ] OG image generation, sitemap, robots.txt
|
||||
|
||||
## Milestone 10 — Relay
|
||||
- [ ] .NET 9 minimal API: validate, honeypot/time-trap, rate-limit, SMTP send, /healthz
|
||||
- [ ] appsettings + env config, Dockerfile
|
||||
|
||||
## Milestone 11 — Tests
|
||||
- [ ] vitest: dictionary parity, slug-map bijection, JSON-LD shape
|
||||
- [ ] playwright: both locales render, lang switch mapping, CV downloads, form contract, a11y (axe), keyboard
|
||||
|
||||
## Milestone 12 — Docker + CI
|
||||
- [ ] site Dockerfile (multi-stage → nginx) + nginx.conf (headers, caching, redirects)
|
||||
- [ ] relay Dockerfile
|
||||
- [ ] compose (prod + dev) + `.env.example`
|
||||
- [ ] `.gitea/workflows` CI pipeline (gates → build → images)
|
||||
|
||||
## Milestone 13 — Verification
|
||||
- [ ] Lighthouse budgets, axe clean, acceptance criteria walkthrough, PROJECT_STATUS final
|
||||
## Pre-launch (owner) — see PROJECT_STATUS.md
|
||||
- [ ] Real `/Linkedin` redirect URL in `site/nginx.conf`
|
||||
- [ ] Seeded-data screenshots + real headshot (replace `placeholder:` refs)
|
||||
- [ ] ATS-safe CV PDFs (replace files at `site/public/cv/`)
|
||||
- [ ] Native-Norwegian content review
|
||||
- [ ] Legacy WordPress URL inventory → finalise 301/410 map
|
||||
- [ ] CI registry secrets + server `.env`
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Copy to .env and fill in. Secrets never go in git or in the image (DOCKER_SPEC §3).
|
||||
|
||||
# --- Contact relay: SMTP (submission endpoint of your mail provider) ---
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD= # secret — provide via env file (chmod 600) or a Docker secret
|
||||
RELAY_FROM= # optional From address; defaults to SMTP_USER / RELAY_TO
|
||||
RELAY_TO=connor.babbington@cesnimda.co.uk
|
||||
RELAY_ALLOWED_ORIGIN=https://cesnimda.co.uk
|
||||
RELAY_RATE_LIMIT=5 # requests per window per IP
|
||||
RELAY_WINDOW_SECONDS=600
|
||||
|
||||
# --- Site build ---
|
||||
PUBLIC_SITE_URL=https://cesnimda.co.uk
|
||||
|
||||
# --- Infra ---
|
||||
PROXY_NETWORK=web # name of the existing external reverse-proxy docker network
|
||||
@@ -0,0 +1,32 @@
|
||||
# Development stack (DOCKER_SPEC §1). Contributors need only Docker — Astro HMR and
|
||||
# the relay with dotnet watch, with /api/contact proxied by the Astro dev server.
|
||||
|
||||
services:
|
||||
site-dev:
|
||||
image: node:22-alpine
|
||||
working_dir: /app
|
||||
command: sh -c "corepack enable && pnpm install && pnpm dev --host --port 4321"
|
||||
environment:
|
||||
- ASTRO_TELEMETRY_DISABLED=1
|
||||
volumes:
|
||||
- ../site:/app
|
||||
- site_node_modules:/app/node_modules
|
||||
ports:
|
||||
- '4321:4321'
|
||||
|
||||
relay-dev:
|
||||
image: mcr.microsoft.com/dotnet/sdk:9.0-alpine
|
||||
working_dir: /src
|
||||
command: sh -c "dotnet watch run --urls http://+:8081 --non-interactive"
|
||||
environment:
|
||||
- DOTNET_USE_POLLING_FILE_WATCHER=1
|
||||
- Smtp__Host=${SMTP_HOST:-}
|
||||
- Relay__ToAddress=${RELAY_TO:-dev@example.com}
|
||||
- Relay__AllowedOrigin=http://localhost:4321
|
||||
volumes:
|
||||
- ../relay:/src
|
||||
ports:
|
||||
- '8081:8081'
|
||||
|
||||
volumes:
|
||||
site_node_modules:
|
||||
@@ -0,0 +1,47 @@
|
||||
# Production stack (DOCKER_SPEC §2). Publishes no host ports — the existing host
|
||||
# reverse proxy routes cesnimda.co.uk -> site:8080 and /api/contact -> relay:8081
|
||||
# over the shared external proxy network.
|
||||
|
||||
services:
|
||||
site:
|
||||
build:
|
||||
context: ../site
|
||||
dockerfile: Dockerfile
|
||||
image: git.cesnimda.uk/cesnimda/resumesite-site:latest
|
||||
restart: unless-stopped
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
- /var/cache/nginx
|
||||
- /var/run
|
||||
networks: [web]
|
||||
logging:
|
||||
driver: json-file
|
||||
options: { max-size: '10m', max-file: '3' }
|
||||
|
||||
relay:
|
||||
build:
|
||||
context: ../relay
|
||||
dockerfile: Dockerfile
|
||||
image: git.cesnimda.uk/cesnimda/resumesite-relay:latest
|
||||
restart: unless-stopped
|
||||
read_only: true
|
||||
environment:
|
||||
- Smtp__Host=${SMTP_HOST}
|
||||
- Smtp__Port=${SMTP_PORT:-587}
|
||||
- Smtp__User=${SMTP_USER}
|
||||
- Smtp__Password=${SMTP_PASSWORD}
|
||||
- Relay__FromAddress=${RELAY_FROM:-}
|
||||
- Relay__ToAddress=${RELAY_TO}
|
||||
- Relay__AllowedOrigin=${RELAY_ALLOWED_ORIGIN:-https://cesnimda.co.uk}
|
||||
- Relay__RateLimitPerWindow=${RELAY_RATE_LIMIT:-5}
|
||||
- Relay__WindowSeconds=${RELAY_WINDOW_SECONDS:-600}
|
||||
networks: [web]
|
||||
logging:
|
||||
driver: json-file
|
||||
options: { max-size: '10m', max-file: '3' }
|
||||
|
||||
networks:
|
||||
web:
|
||||
external: true
|
||||
name: ${PROXY_NETWORK:-web}
|
||||
@@ -0,0 +1,5 @@
|
||||
bin/
|
||||
obj/
|
||||
**/appsettings.*.local.json
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<RootNamespace>ContactRelay</RootNamespace>
|
||||
<AssemblyName>ContactRelay</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,22 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ---- build ----
|
||||
FROM mcr.microsoft.com/dotnet/sdk:9.0-alpine AS build
|
||||
WORKDIR /src
|
||||
COPY ContactRelay.csproj .
|
||||
RUN dotnet restore
|
||||
COPY . .
|
||||
RUN dotnet publish -c Release -o /app --no-restore
|
||||
|
||||
# ---- runtime ----
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0-alpine AS final
|
||||
WORKDIR /app
|
||||
COPY --from=build /app .
|
||||
# The .NET aspnet image ships a non-root `app` user.
|
||||
USER app
|
||||
ENV ASPNETCORE_URLS=http://+:8081 \
|
||||
DOTNET_EnableDiagnostics=0
|
||||
EXPOSE 8081
|
||||
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
|
||||
CMD wget -qO- http://localhost:8081/healthz || exit 1
|
||||
ENTRYPOINT ["dotnet", "ContactRelay.dll"]
|
||||
@@ -0,0 +1,146 @@
|
||||
using System.Net;
|
||||
using System.Net.Mail;
|
||||
using System.Net.Mime;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading.RateLimiting;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
// Stateless contact relay (TECH_SPEC §7): validate -> honeypot -> rate-limit ->
|
||||
// SMTP send -> forget. No persistence, no message bodies logged.
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
var cfg = builder.Configuration;
|
||||
var allowedOrigin = cfg["Relay:AllowedOrigin"] ?? "https://cesnimda.co.uk";
|
||||
var rateLimit = cfg.GetValue("Relay:RateLimitPerWindow", 5);
|
||||
var windowSeconds = cfg.GetValue("Relay:WindowSeconds", 600);
|
||||
|
||||
builder.Services.ConfigureHttpJsonOptions(o =>
|
||||
o.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default));
|
||||
|
||||
// Real client IP from our own reverse proxy.
|
||||
builder.Services.Configure<ForwardedHeadersOptions>(o =>
|
||||
{
|
||||
o.ForwardedHeaders = ForwardedHeaders.XForwardedFor;
|
||||
o.KnownNetworks.Clear();
|
||||
o.KnownProxies.Clear();
|
||||
});
|
||||
|
||||
builder.Services.AddCors(o => o.AddPolicy("site", p =>
|
||||
p.WithOrigins(allowedOrigin).WithMethods("POST").WithHeaders("Content-Type")));
|
||||
|
||||
builder.Services.AddRateLimiter(o =>
|
||||
{
|
||||
o.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
o.AddPolicy("contact", ctx => RateLimitPartition.GetFixedWindowLimiter(
|
||||
ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||
_ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = rateLimit,
|
||||
Window = TimeSpan.FromSeconds(windowSeconds),
|
||||
QueueLimit = 0,
|
||||
}));
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
app.UseForwardedHeaders();
|
||||
app.UseCors("site");
|
||||
app.UseRateLimiter();
|
||||
|
||||
app.MapGet("/healthz", () => Results.Ok("ok"));
|
||||
|
||||
app.MapPost("/api/contact", async (ContactRequest req, HttpContext http, ILogger<Program> log) =>
|
||||
{
|
||||
var ipHash = HashIp(http.Connection.RemoteIpAddress?.ToString());
|
||||
|
||||
// Honeypot: a filled hidden field means a bot. Feign success, send nothing.
|
||||
if (!string.IsNullOrWhiteSpace(req.Company))
|
||||
{
|
||||
log.LogInformation("contact rejected (honeypot) ip={Ip}", ipHash);
|
||||
return Results.Ok(new ContactResponse(true));
|
||||
}
|
||||
|
||||
var name = (req.Name ?? "").Trim();
|
||||
var email = (req.Email ?? "").Trim();
|
||||
var message = (req.Message ?? "").Trim();
|
||||
|
||||
if (name.Length is 0 or > 200 ||
|
||||
email.Length is 0 or > 320 || !IsEmail(email) ||
|
||||
message.Length is 0 or > 5000)
|
||||
{
|
||||
return Results.BadRequest(new ContactResponse(false, "invalid"));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await SendAsync(cfg, name, email, message);
|
||||
log.LogInformation("contact sent ip={Ip}", ipHash);
|
||||
return Results.Ok(new ContactResponse(true));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log.LogError(ex, "contact send failed ip={Ip}", ipHash);
|
||||
return Results.StatusCode(StatusCodes.Status502BadGateway);
|
||||
}
|
||||
}).RequireRateLimiting("contact");
|
||||
|
||||
app.Run();
|
||||
|
||||
static bool IsEmail(string s)
|
||||
{
|
||||
var at = s.IndexOf('@');
|
||||
var dot = s.LastIndexOf('.');
|
||||
return at > 0 && dot > at + 1 && dot < s.Length - 1 && !s.Contains(' ');
|
||||
}
|
||||
|
||||
static string HashIp(string? ip)
|
||||
{
|
||||
if (string.IsNullOrEmpty(ip)) return "unknown";
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(ip));
|
||||
return Convert.ToHexString(bytes, 0, 4); // short, non-reversible marker
|
||||
}
|
||||
|
||||
static async Task SendAsync(IConfiguration cfg, string name, string email, string message)
|
||||
{
|
||||
var host = cfg["Smtp:Host"] ?? throw new InvalidOperationException("Smtp:Host not configured");
|
||||
var port = cfg.GetValue("Smtp:Port", 587);
|
||||
var user = cfg["Smtp:User"];
|
||||
var password = cfg["Smtp:Password"];
|
||||
var to = cfg["Relay:ToAddress"] ?? throw new InvalidOperationException("Relay:ToAddress not configured");
|
||||
var from = cfg["Relay:FromAddress"] ?? user ?? to;
|
||||
|
||||
using var msg = new MailMessage
|
||||
{
|
||||
From = new MailAddress(from, "Portfolio contact form"),
|
||||
Subject = $"Portfolio contact from {name}",
|
||||
Body =
|
||||
$"From: {name} <{email}>\n\n{message}\n\n— sent via cesnimda.co.uk contact form",
|
||||
BodyEncoding = Encoding.UTF8,
|
||||
SubjectEncoding = Encoding.UTF8,
|
||||
};
|
||||
msg.To.Add(to);
|
||||
msg.ReplyToList.Add(new MailAddress(email, name));
|
||||
|
||||
using var client = new SmtpClient(host, port)
|
||||
{
|
||||
EnableSsl = true,
|
||||
DeliveryMethod = SmtpDeliveryMethod.Network,
|
||||
Credentials = string.IsNullOrEmpty(user) ? null : new NetworkCredential(user, password),
|
||||
};
|
||||
await client.SendMailAsync(msg);
|
||||
}
|
||||
|
||||
record ContactRequest(
|
||||
[property: JsonPropertyName("name")] string? Name,
|
||||
[property: JsonPropertyName("email")] string? Email,
|
||||
[property: JsonPropertyName("message")] string? Message,
|
||||
[property: JsonPropertyName("company")] string? Company);
|
||||
|
||||
record ContactResponse(bool Ok, string? Error = null);
|
||||
|
||||
[JsonSerializable(typeof(ContactRequest))]
|
||||
[JsonSerializable(typeof(ContactResponse))]
|
||||
partial class AppJsonContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"Relay": {
|
||||
"AllowedOrigin": "https://cesnimda.co.uk",
|
||||
"RateLimitPerWindow": 5,
|
||||
"WindowSeconds": 600
|
||||
},
|
||||
"Smtp": {
|
||||
"Port": 587
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
dist
|
||||
.astro
|
||||
test
|
||||
tests
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
@@ -0,0 +1,6 @@
|
||||
dist/
|
||||
.astro/
|
||||
node_modules/
|
||||
pnpm-lock.yaml
|
||||
public/
|
||||
*.pdf
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"semi": true,
|
||||
"trailingComma": "all",
|
||||
"plugins": ["prettier-plugin-astro", "prettier-plugin-tailwindcss"],
|
||||
"overrides": [{ "files": "*.astro", "options": { "parser": "astro" } }]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ---- build ----
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
RUN corepack enable
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY . .
|
||||
ENV ASTRO_TELEMETRY_DISABLED=1
|
||||
RUN pnpm build
|
||||
|
||||
# ---- runtime: static files behind unprivileged nginx (non-root, read-only capable) ----
|
||||
FROM nginxinc/nginx-unprivileged:1.27-alpine AS final
|
||||
COPY --chown=nginx:nginx nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build --chown=nginx:nginx /app/dist /usr/share/nginx/html
|
||||
EXPOSE 8080
|
||||
# runs as uid 101 (nginx) by default in this image
|
||||
@@ -0,0 +1,41 @@
|
||||
// @ts-check
|
||||
import { defineConfig } from 'astro/config';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// Canonical origin used for absolute URLs (sitemap, OG, hreflang, JSON-LD).
|
||||
// Overridable at build time so staging emits its own origin. See DOCKER_SPEC §3.
|
||||
const SITE = process.env.PUBLIC_SITE_URL ?? 'https://cesnimda.co.uk';
|
||||
|
||||
const r = (p) => fileURLToPath(new URL(p, import.meta.url));
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
site: SITE,
|
||||
// ROUTING_SPEC §1: trailing-slash canonical, directory output.
|
||||
trailingSlash: 'always',
|
||||
build: { format: 'directory', inlineStylesheets: 'auto' },
|
||||
// I18N_SPEC §1: en at root, no prefixed. Localised slugs are handled by explicit
|
||||
// page files + the slug map module (ARCHITECTURE A5), not automatic locale routing.
|
||||
i18n: {
|
||||
defaultLocale: 'en',
|
||||
locales: ['en', 'no'],
|
||||
routing: { prefixDefaultLocale: false, redirectToDefaultLocale: false },
|
||||
},
|
||||
prefetch: false,
|
||||
compressHTML: true,
|
||||
vite: {
|
||||
plugins: [tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': r('./src'),
|
||||
'@i18n': r('./src/i18n'),
|
||||
'@components': r('./src/components'),
|
||||
'@layouts': r('./src/layouts'),
|
||||
'@styles': r('./src/styles'),
|
||||
'@scripts': r('./src/scripts'),
|
||||
'@lib': r('./src/lib'),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import js from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import astro from 'eslint-plugin-astro';
|
||||
|
||||
export default [
|
||||
{ ignores: ['dist/', '.astro/', 'node_modules/', '**/*.d.ts'] },
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
...astro.configs.recommended,
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||
],
|
||||
'@typescript-eslint/consistent-type-imports': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
// Behaviour modules run in the browser.
|
||||
files: ['src/scripts/**/*.ts'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
window: 'readonly',
|
||||
document: 'readonly',
|
||||
localStorage: 'readonly',
|
||||
navigator: 'readonly',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// Node config files.
|
||||
files: ['*.config.{js,mjs,ts}'],
|
||||
languageOptions: { globals: { process: 'readonly', URL: 'readonly' } },
|
||||
},
|
||||
{
|
||||
// Static browser scripts served as-is from public/.
|
||||
files: ['public/**/*.js'],
|
||||
languageOptions: {
|
||||
globals: { window: 'readonly', document: 'readonly', localStorage: 'readonly' },
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"ci": {
|
||||
"collect": {
|
||||
"staticDistDir": "./dist",
|
||||
"url": ["http://localhost/index.html", "http://localhost/projects/jobtrack/index.html"],
|
||||
"numberOfRuns": 1
|
||||
},
|
||||
"assert": {
|
||||
"assertions": {
|
||||
"categories:performance": ["error", { "minScore": 0.95 }],
|
||||
"categories:accessibility": ["error", { "minScore": 1 }],
|
||||
"categories:seo": ["error", { "minScore": 1 }],
|
||||
"largest-contentful-paint": ["error", { "maxNumericValue": 1800 }],
|
||||
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.02 }],
|
||||
"total-blocking-time": ["error", { "maxNumericValue": 100 }],
|
||||
"unused-javascript": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
# Site nginx config (DOCKER_SPEC §1, ARCHITECTURE §6). Serves the static Astro build
|
||||
# with security headers, immutable asset caching, revalidated HTML, canonical
|
||||
# trailing slashes, the preserved /Linkedin redirect, and legacy-WP 410s.
|
||||
|
||||
server {
|
||||
listen 8080;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
charset utf-8;
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
|
||||
# --- Security headers (applied to document responses) ---
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'self'; frame-ancestors 'none'; object-src 'none'; upgrade-insecure-requests" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), interest-cohort=()" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Cache-Control "no-cache" always;
|
||||
|
||||
# --- Compression ---
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 256;
|
||||
gzip_proxied any;
|
||||
gzip_types text/plain text/css application/javascript application/json image/svg+xml application/xml application/xml+rss;
|
||||
|
||||
# --- Preserved LinkedIn redirect (printed on the CVs). Replace slug before cutover. ---
|
||||
location = /Linkedin {
|
||||
return 301 https://www.linkedin.com/in/REPLACE-WITH-REAL-SLUG/;
|
||||
}
|
||||
|
||||
# --- Legacy WordPress URLs -> 410 Gone (crawler cleanup) ---
|
||||
location ~* ^/(wp-admin|wp-login|wp-content|wp-includes|wp-json|xmlrpc\.php|feed|comments/feed) {
|
||||
return 410;
|
||||
}
|
||||
|
||||
# --- Immutable, content-hashed build assets ---
|
||||
location /_astro/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable" always;
|
||||
}
|
||||
|
||||
# --- Other static media (moderate cache) ---
|
||||
location ~* \.(?:woff2?|ttf|png|jpe?g|webp|avif|svg|ico)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public" always;
|
||||
}
|
||||
location = /theme-init.js {
|
||||
expires 1h;
|
||||
add_header Cache-Control "public" always;
|
||||
}
|
||||
|
||||
# --- CVs update in place -> short cache so shared links fetch the newest ---
|
||||
location /cv/ {
|
||||
expires 1h;
|
||||
add_header Cache-Control "public, must-revalidate" always;
|
||||
}
|
||||
|
||||
# --- Canonical trailing slash for extensionless paths (SEO) ---
|
||||
rewrite ^([^.]*[^/])$ $1/ permanent;
|
||||
|
||||
# --- HTML documents: revalidate so deploys are instant (inherits headers above) ---
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
error_page 404 /404.html;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "resumesite",
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Connor Babbington — bilingual portfolio (Astro static site)",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.9",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"check": "astro check && tsc --noEmit",
|
||||
"lint": "eslint . && prettier --check .",
|
||||
"format": "prettier --write .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.1.1",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.1.2",
|
||||
"@fontsource-variable/space-grotesk": "^5.1.1",
|
||||
"@resvg/resvg-js": "^2.6.2",
|
||||
"astro": "^5.2.5",
|
||||
"satori": "^0.12.1",
|
||||
"sharp": "^0.33.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "^0.9.4",
|
||||
"@eslint/js": "^9.20.0",
|
||||
"@tailwindcss/vite": "^4.0.6",
|
||||
"@types/node": "^22.13.1",
|
||||
"eslint": "^9.20.1",
|
||||
"eslint-plugin-astro": "^1.3.1",
|
||||
"prettier": "^3.5.1",
|
||||
"prettier-plugin-astro": "^0.14.1",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"tailwindcss": "^4.0.6",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.24.0",
|
||||
"vitest": "^3.0.5"
|
||||
}
|
||||
}
|
||||
Generated
+6716
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="8" fill="#0B0E14" />
|
||||
<rect x="3.5" y="3.5" width="25" height="25" rx="6" fill="none" stroke="#2A303C" stroke-width="1" />
|
||||
<text x="16" y="21" font-family="JetBrains Mono, ui-monospace, monospace" font-size="13" font-weight="700" fill="#E8ECF2" text-anchor="middle">CB</text>
|
||||
<circle cx="25" cy="7" r="2.5" fill="#3ECFAE" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 456 B |
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
No-flash theme init (external so a strict CSP can use script-src 'self' with no
|
||||
inline hash). Loaded render-blocking in <head>, runs before first paint: marks
|
||||
that JS is available (reveal animations are gated behind html.js) and applies the
|
||||
stored or system theme. First visit follows the system; once set, the choice persists.
|
||||
*/
|
||||
(function () {
|
||||
document.documentElement.classList.add('js');
|
||||
try {
|
||||
var t = localStorage.getItem('theme');
|
||||
if (t !== 'light' && t !== 'dark') {
|
||||
t = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
||||
}
|
||||
document.documentElement.dataset.theme = t;
|
||||
} catch {
|
||||
document.documentElement.dataset.theme = 'dark';
|
||||
}
|
||||
})();
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,121 @@
|
||||
---
|
||||
/*
|
||||
Data-driven architecture diagram (COLOUR_SYSTEM §3.5): surface nodes, accent flow,
|
||||
amber for external systems, mono labels. Fully legible with zero interaction; the
|
||||
<title>/<desc> give it an accessible text alternative (A5).
|
||||
*/
|
||||
interface Node {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
kind: 'internal' | 'primary' | 'external';
|
||||
label: string;
|
||||
sub?: string;
|
||||
}
|
||||
interface Edge {
|
||||
d: string;
|
||||
kind: 'flow' | 'external';
|
||||
label?: string;
|
||||
labelX?: number;
|
||||
labelY?: number;
|
||||
}
|
||||
interface Props {
|
||||
diagram: {
|
||||
viewBox: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
};
|
||||
class?: string;
|
||||
}
|
||||
const { diagram, class: cls } = Astro.props;
|
||||
|
||||
const nodeStroke = {
|
||||
internal: 'var(--border-strong)',
|
||||
primary: 'var(--accent)',
|
||||
external: 'var(--amber)',
|
||||
};
|
||||
---
|
||||
|
||||
<figure class:list={['border-line bg-surface-1 overflow-hidden rounded-md border p-4', cls]}>
|
||||
<svg
|
||||
viewBox={diagram.viewBox}
|
||||
role="img"
|
||||
aria-labelledby="diagram-title diagram-desc"
|
||||
class="w-full"
|
||||
style="min-width: 640px;"
|
||||
>
|
||||
<title id="diagram-title">{diagram.title}</title>
|
||||
<desc id="diagram-desc">{diagram.desc}</desc>
|
||||
|
||||
{/* Edges first (under nodes) */}
|
||||
{
|
||||
diagram.edges.map((e) => (
|
||||
<g>
|
||||
<path
|
||||
d={e.d}
|
||||
fill="none"
|
||||
stroke={e.kind === 'external' ? 'var(--amber)' : 'var(--accent)'}
|
||||
stroke-width="1.5"
|
||||
stroke-dasharray={e.kind === 'external' ? '4 4' : undefined}
|
||||
/>
|
||||
{e.label && e.labelX !== undefined && e.labelY !== undefined && (
|
||||
<text
|
||||
x={e.labelX}
|
||||
y={e.labelY}
|
||||
fill={e.kind === 'external' ? 'var(--amber)' : 'var(--ink-faint)'}
|
||||
font-family="var(--font-mono)"
|
||||
font-size="11"
|
||||
text-anchor="middle"
|
||||
>
|
||||
{e.label}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
))
|
||||
}
|
||||
|
||||
{/* Nodes */}
|
||||
{
|
||||
diagram.nodes.map((n) => (
|
||||
<g>
|
||||
<rect
|
||||
x={n.x}
|
||||
y={n.y}
|
||||
width={n.w}
|
||||
height={n.h}
|
||||
rx="8"
|
||||
fill="var(--surface-2)"
|
||||
stroke={nodeStroke[n.kind]}
|
||||
stroke-opacity={n.kind === 'internal' ? '1' : '0.55'}
|
||||
/>
|
||||
<text
|
||||
x={n.x + n.w / 2}
|
||||
y={n.y + n.h / 2 + (n.sub ? -4 : 5)}
|
||||
fill="var(--ink)"
|
||||
font-family="var(--font-body)"
|
||||
font-size="14"
|
||||
text-anchor="middle"
|
||||
>
|
||||
{n.label}
|
||||
</text>
|
||||
{n.sub && (
|
||||
<text
|
||||
x={n.x + n.w / 2}
|
||||
y={n.y + n.h / 2 + 16}
|
||||
fill="var(--ink-faint)"
|
||||
font-family="var(--font-mono)"
|
||||
font-size="11"
|
||||
text-anchor="middle"
|
||||
>
|
||||
{n.sub}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
))
|
||||
}
|
||||
</svg>
|
||||
</figure>
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
import type { Locale } from '@i18n/locales';
|
||||
import type { ProjectPageId } from '@i18n/slugMap';
|
||||
import Base from '@layouts/Base.astro';
|
||||
import { getProject, getMeta } from '@lib/content';
|
||||
import CsHeader from '@components/case-study/CsHeader.astro';
|
||||
import TldrBox from '@components/case-study/TldrBox.astro';
|
||||
import MiniToc from '@components/case-study/MiniToc.astro';
|
||||
import SectionRenderer from '@components/case-study/SectionRenderer.astro';
|
||||
import NextPrev from '@components/case-study/NextPrev.astro';
|
||||
import FramedImage from '@components/ui/FramedImage.astro';
|
||||
|
||||
interface Props {
|
||||
id: ProjectPageId;
|
||||
locale: Locale;
|
||||
}
|
||||
const { id, locale } = Astro.props;
|
||||
const project = getProject(id, locale);
|
||||
const m = getMeta(id, locale);
|
||||
const hero = project.media[0];
|
||||
const isCapability = project.template === 'capability';
|
||||
const projectType = id === 'inboxintel' ? 'SoftwareSourceCode' : 'SoftwareApplication';
|
||||
---
|
||||
|
||||
<Base
|
||||
locale={locale}
|
||||
pageId={id}
|
||||
title={m.title}
|
||||
description={m.description}
|
||||
ogType="article"
|
||||
jsonLdOpts={isCapability
|
||||
? { projectName: project.name }
|
||||
: { projectName: project.name, projectType }}
|
||||
>
|
||||
<article class="mx-auto max-w-[--content-max] px-6 py-14">
|
||||
<CsHeader locale={locale} project={project} />
|
||||
|
||||
{
|
||||
hero && (
|
||||
<div class="mt-8">
|
||||
<FramedImage media={hero} priority index={0} url={`${id}.local`} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<div class:list={['mt-12 grid gap-12', !isCapability && 'xl:grid-cols-[1fr_240px]']}>
|
||||
<div>
|
||||
{!isCapability && <TldrBox locale={locale} tldr={project.tldr} />}
|
||||
<div>
|
||||
{
|
||||
project.sections.map((s) => (
|
||||
<SectionRenderer
|
||||
locale={locale}
|
||||
section={s}
|
||||
diagram={project.diagram}
|
||||
media={project.media}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isCapability && <MiniToc locale={locale} sections={project.sections} />}
|
||||
</div>
|
||||
|
||||
<NextPrev locale={locale} currentId={id} />
|
||||
</article>
|
||||
|
||||
<script>
|
||||
import '@scripts/lightbox';
|
||||
</script>
|
||||
</Base>
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
import type { Locale } from '@i18n/locales';
|
||||
import { useDict } from '@i18n/t';
|
||||
import type { ProjectVM } from '@lib/content';
|
||||
import SectionLabel from '@components/ui/SectionLabel.astro';
|
||||
import Chip from '@components/ui/Chip.astro';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
project: ProjectVM;
|
||||
}
|
||||
const { locale, project } = Astro.props;
|
||||
const d = useDict(locale);
|
||||
const eyebrow =
|
||||
project.template === 'case-study' ? d.project.caseStudyEyebrow : d.project.capabilityEyebrow;
|
||||
const linkLabel: Record<string, string> = {
|
||||
repo: d.project.repository,
|
||||
live: d.project.liveDemo,
|
||||
docs: 'Docs',
|
||||
};
|
||||
---
|
||||
|
||||
<header>
|
||||
<SectionLabel text={eyebrow} />
|
||||
<div class="mt-4 flex flex-wrap items-center gap-4">
|
||||
<h1 class="text-h1">{project.name}</h1>
|
||||
<Chip kind="status" status={project.status} label={d.project.status[project.status]} />
|
||||
</div>
|
||||
<p class="text-body-lg text-ink-muted mt-4 max-w-[60ch]">{project.valueProp}</p>
|
||||
|
||||
<div class="mt-5 flex flex-wrap items-center gap-2">
|
||||
{project.stack.map((s) => <Chip kind="stack" label={s.name} tooltip={s.context} />)}
|
||||
</div>
|
||||
|
||||
{
|
||||
project.links.length > 0 && (
|
||||
<div class="mt-5 flex flex-wrap gap-4">
|
||||
{project.links
|
||||
.filter((l) => l.public)
|
||||
.map((l) => (
|
||||
<a
|
||||
href={l.url}
|
||||
rel="noopener"
|
||||
class="text-body text-accent inline-flex items-center gap-1 hover:underline"
|
||||
>
|
||||
{linkLabel[l.type] ?? l.type} ↗
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</header>
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
interface Decision {
|
||||
n: number;
|
||||
choice: string;
|
||||
alternative: string;
|
||||
rationale: string;
|
||||
}
|
||||
interface Props {
|
||||
decisions: Decision[];
|
||||
}
|
||||
const { decisions } = Astro.props;
|
||||
---
|
||||
|
||||
<ol class="flex flex-col gap-5">
|
||||
{
|
||||
decisions.map((dec) => (
|
||||
<li class="border-line bg-surface-1 rounded-md border p-5">
|
||||
<p class="flex gap-3">
|
||||
<span class="text-body text-accent font-mono font-semibold">
|
||||
{String(dec.n).padStart(2, '0')}
|
||||
</span>
|
||||
<span class="text-ink font-semibold">{dec.choice}</span>
|
||||
</p>
|
||||
<p class="text-small text-ink-faint mt-2 pl-8">
|
||||
<span class="font-mono">alt:</span> {dec.alternative}
|
||||
</p>
|
||||
<p class="text-body text-ink-muted mt-2 pl-8">{dec.rationale}</p>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ol>
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
import FramedImage from '@components/ui/FramedImage.astro';
|
||||
|
||||
interface Media {
|
||||
src: string;
|
||||
width: number;
|
||||
height: number;
|
||||
alt: string;
|
||||
caption: string;
|
||||
}
|
||||
interface Props {
|
||||
media: Media[];
|
||||
/** Index offset so lightbox indices stay unique across the page. */
|
||||
startIndex?: number;
|
||||
}
|
||||
const { media, startIndex = 0 } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="grid gap-6 sm:grid-cols-2">
|
||||
{
|
||||
media.map((m, i) => (
|
||||
<figure>
|
||||
<FramedImage media={m} index={startIndex + i} />
|
||||
<figcaption class="text-mono-meta text-ink-faint mt-2 font-mono">{m.caption}</figcaption>
|
||||
</figure>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
import type { Locale } from '@i18n/locales';
|
||||
import { useDict } from '@i18n/t';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
sections: { heading: string; anchorId: string }[];
|
||||
}
|
||||
const { locale, sections } = Astro.props;
|
||||
const d = useDict(locale);
|
||||
---
|
||||
|
||||
<nav class="sticky top-24 hidden xl:block" aria-label={d.project.onThisPage}>
|
||||
<p class="mono-label mb-3">{d.project.onThisPage}</p>
|
||||
<ul class="border-line flex flex-col gap-2 border-l">
|
||||
{
|
||||
sections.map((s) => (
|
||||
<li>
|
||||
<a
|
||||
href={`#${s.anchorId}`}
|
||||
data-spy-link={s.anchorId}
|
||||
class="toc-link text-small text-ink-muted hover:text-ink -ml-px block border-l border-transparent py-1 pl-4 transition-colors"
|
||||
>
|
||||
{s.heading}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.toc-link.is-active {
|
||||
color: var(--ink);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
import type { Locale } from '@i18n/locales';
|
||||
import { useDict } from '@i18n/t';
|
||||
import { pathFor } from '@i18n/slugMap';
|
||||
import { getProjectCards } from '@lib/content';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
currentId: string;
|
||||
}
|
||||
const { locale, currentId } = Astro.props;
|
||||
const d = useDict(locale);
|
||||
const cards = getProjectCards(locale);
|
||||
const idx = cards.findIndex((c) => c.id === currentId);
|
||||
const prev = idx > 0 ? cards[idx - 1] : undefined;
|
||||
const next = idx < cards.length - 1 ? cards[idx + 1] : undefined;
|
||||
---
|
||||
|
||||
<nav
|
||||
class="border-line mt-20 flex flex-col gap-4 border-t pt-8 sm:flex-row sm:items-center sm:justify-between"
|
||||
aria-label={d.project.all}
|
||||
>
|
||||
<div>
|
||||
{
|
||||
prev && (
|
||||
<a
|
||||
href={prev.path}
|
||||
class="group text-body text-ink-muted hover:text-ink inline-flex items-center gap-2"
|
||||
>
|
||||
<span class="transition-transform group-hover:-translate-x-1">←</span>
|
||||
<span>
|
||||
<span class="mono-label block">{d.project.prev}</span>
|
||||
{prev.name}
|
||||
</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<a href={pathFor('projects', locale)} class="text-body text-accent hover:underline">
|
||||
{d.project.all}
|
||||
</a>
|
||||
|
||||
<div class="sm:text-right">
|
||||
{
|
||||
next && (
|
||||
<a
|
||||
href={next.path}
|
||||
class="group text-body text-ink-muted hover:text-ink inline-flex items-center gap-2"
|
||||
>
|
||||
<span>
|
||||
<span class="mono-label block">{d.project.next}</span>
|
||||
{next.name}
|
||||
</span>
|
||||
<span class="transition-transform group-hover:translate-x-1">→</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</nav>
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
import type { Locale } from '@i18n/locales';
|
||||
import ArchDiagram from '@components/case-study/ArchDiagram.astro';
|
||||
import DecisionList from '@components/case-study/DecisionList.astro';
|
||||
import Gallery from '@components/case-study/Gallery.astro';
|
||||
|
||||
interface Block {
|
||||
type: 'p' | 'ul';
|
||||
text?: string;
|
||||
items?: string[];
|
||||
}
|
||||
interface Section {
|
||||
kind: string;
|
||||
heading: string;
|
||||
anchorId: string;
|
||||
blocks?: Block[];
|
||||
decisions?: { n: number; choice: string; alternative: string; rationale: string }[];
|
||||
}
|
||||
interface Media {
|
||||
src: string;
|
||||
width: number;
|
||||
height: number;
|
||||
alt: string;
|
||||
caption: string;
|
||||
}
|
||||
interface DiagramNode {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
kind: 'internal' | 'primary' | 'external';
|
||||
label: string;
|
||||
sub?: string;
|
||||
}
|
||||
interface DiagramEdge {
|
||||
d: string;
|
||||
kind: 'flow' | 'external';
|
||||
label?: string;
|
||||
labelX?: number;
|
||||
labelY?: number;
|
||||
}
|
||||
interface Diagram {
|
||||
viewBox: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
nodes: DiagramNode[];
|
||||
edges: DiagramEdge[];
|
||||
}
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
section: Section;
|
||||
diagram?: Diagram;
|
||||
media?: Media[];
|
||||
}
|
||||
const { section, diagram, media = [] } = Astro.props;
|
||||
const galleryMedia = media.slice(1); // media[0] is the hero shown above the article
|
||||
---
|
||||
|
||||
<section id={section.anchorId} data-spy class="pt-14 first:pt-0">
|
||||
<h2 class="text-h3">{section.heading}</h2>
|
||||
|
||||
{
|
||||
section.blocks && (
|
||||
<div class="prose-measure mt-4 flex flex-col gap-4">
|
||||
{section.blocks.map((b) =>
|
||||
b.type === 'p' ? (
|
||||
<p class="text-body text-ink-muted leading-relaxed">{b.text}</p>
|
||||
) : (
|
||||
<ul class="flex flex-col gap-2">
|
||||
{b.items?.map((it) => (
|
||||
<li class="text-body text-ink-muted flex gap-3">
|
||||
<span class="bg-accent mt-2.5 h-1 w-1 shrink-0 rounded-full" aria-hidden="true" />
|
||||
<span>{it}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{section.kind === 'architecture' && diagram && <ArchDiagram diagram={diagram} class="mt-6" />}
|
||||
|
||||
{
|
||||
section.kind === 'decisions' && section.decisions && (
|
||||
<div class="mt-6">
|
||||
<DecisionList decisions={section.decisions} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
section.kind === 'screenshots' &&
|
||||
(galleryMedia.length > 0 ? (
|
||||
<div class="mt-6">
|
||||
<Gallery media={galleryMedia} startIndex={1} />
|
||||
</div>
|
||||
) : null)
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
import type { Locale } from '@i18n/locales';
|
||||
import { useDict } from '@i18n/t';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
tldr: { what: string; why: string; stack: string; role: string };
|
||||
}
|
||||
const { locale, tldr } = Astro.props;
|
||||
const d = useDict(locale);
|
||||
const rows: [string, string][] = [
|
||||
[d.project.tldr.what, tldr.what],
|
||||
[d.project.tldr.why, tldr.why],
|
||||
[d.project.tldr.stack, tldr.stack],
|
||||
[d.project.tldr.role, tldr.role],
|
||||
];
|
||||
---
|
||||
|
||||
<div class="bg-surface-2 relative overflow-hidden rounded-md p-6 pl-7">
|
||||
<span class="bg-accent absolute inset-y-0 left-0 w-[3px]" aria-hidden="true"></span>
|
||||
<p class="mono-label mb-4">TL;DR</p>
|
||||
<dl class="flex flex-col gap-2.5">
|
||||
{
|
||||
rows.map(([term, val]) => (
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:gap-3">
|
||||
<dt class="text-ink w-16 shrink-0 font-semibold">{term}</dt>
|
||||
<dd class="text-ink-muted">{val}</dd>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
import { useDict, interpolate } from '@i18n/t';
|
||||
import type { Locale } from '@i18n/locales';
|
||||
import { pathFor, type PageId } from '@i18n/slugMap';
|
||||
import { getProfile } from '@lib/content';
|
||||
import LangSwitch from '@components/ui/LangSwitch.astro';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
pageId: PageId;
|
||||
}
|
||||
const { locale, pageId } = Astro.props;
|
||||
const d = useDict(locale);
|
||||
const p = getProfile(locale);
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
const nav: { id: PageId; label: string }[] = [
|
||||
{ id: 'projects', label: d.nav.projects },
|
||||
{ id: 'experience', label: d.nav.experience },
|
||||
{ id: 'about', label: d.nav.about },
|
||||
{ id: 'contact', label: d.nav.contact },
|
||||
];
|
||||
---
|
||||
|
||||
<footer class="border-line bg-surface-1 mt-24 border-t">
|
||||
<div class="mx-auto grid max-w-[--content-max] gap-10 px-6 py-14 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="lg:col-span-2">
|
||||
<p class="font-display text-h4 text-ink font-semibold">Connor Babbington</p>
|
||||
<p class="text-small text-ink-muted mt-2 max-w-sm">{d.footer.tagline}</p>
|
||||
<p class="text-small text-ink-muted mt-1 max-w-sm">{d.footer.availability}</p>
|
||||
</div>
|
||||
|
||||
<nav aria-label={d.a11y.primaryNav}>
|
||||
<p class="mono-label mb-3">{d.nav.projects}</p>
|
||||
<ul class="text-small text-ink-muted flex flex-col gap-2">
|
||||
{
|
||||
nav.map((item) => (
|
||||
<li>
|
||||
<a class="hover:text-ink transition-colors" href={pathFor(item.id, locale)}>
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div>
|
||||
<p class="mono-label mb-3">Contact</p>
|
||||
<ul class="text-mono-meta text-ink-muted flex flex-col gap-2 font-mono">
|
||||
<li>
|
||||
<a class="hover:text-ink transition-colors" href={`mailto:${p.links.email}`}
|
||||
>{p.links.email}</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a class="hover:text-ink transition-colors" href={p.links.linkedin} rel="me noopener"
|
||||
>LinkedIn ↗</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a class="hover:text-ink transition-colors" href={p.links.gitea} rel="me noopener"
|
||||
>git.cesnimda.uk ↗</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a class="hover:text-ink transition-colors" href={pathFor('cv', locale)}
|
||||
>{d.cv.download}</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="border-line mx-auto flex max-w-[--content-max] flex-col items-start justify-between gap-4 border-t px-6 py-6 sm:flex-row sm:items-center"
|
||||
>
|
||||
<p class="text-mono-meta text-ink-faint font-mono">
|
||||
{interpolate(d.footer.copyright, { year })}
|
||||
</p>
|
||||
<div class="flex items-center gap-4">
|
||||
<a
|
||||
href={pathFor('colophon', locale)}
|
||||
class="text-mono-meta text-ink-muted hover:text-ink font-mono transition-colors"
|
||||
>
|
||||
{d.footer.colophon}
|
||||
</a>
|
||||
<LangSwitch locale={locale} pageId={pageId} />
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -0,0 +1,122 @@
|
||||
---
|
||||
import { useDict } from '@i18n/t';
|
||||
import type { Locale } from '@i18n/locales';
|
||||
import { pathFor, type PageId, PROJECT_PAGE_IDS } from '@i18n/slugMap';
|
||||
import LangSwitch from '@components/ui/LangSwitch.astro';
|
||||
import ThemeToggle from '@components/ui/ThemeToggle.astro';
|
||||
import SplitCvButton from '@components/ui/SplitCvButton.astro';
|
||||
import MobileNav from '@components/core/MobileNav.astro';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
pageId: PageId;
|
||||
}
|
||||
const { locale, pageId } = Astro.props;
|
||||
const d = useDict(locale);
|
||||
|
||||
const navItems: { id: PageId; label: string }[] = [
|
||||
{ id: 'projects', label: d.nav.projects },
|
||||
{ id: 'experience', label: d.nav.experience },
|
||||
{ id: 'about', label: d.nav.about },
|
||||
{ id: 'contact', label: d.nav.contact },
|
||||
];
|
||||
const isCurrent = (id: PageId) =>
|
||||
id === pageId || (id === 'projects' && (PROJECT_PAGE_IDS as readonly string[]).includes(pageId));
|
||||
---
|
||||
|
||||
<header
|
||||
id="site-header"
|
||||
class="bg-surface-0/80 sticky top-0 z-40 border-b border-transparent backdrop-blur-md"
|
||||
>
|
||||
<div
|
||||
class="mx-auto flex h-[--header-h] max-w-[--content-max] items-center justify-between px-5 transition-[height] duration-[--dur-standard] ease-[--ease-out] sm:px-6"
|
||||
>
|
||||
{/* Monogram → home */}
|
||||
<a
|
||||
href={pathFor('home', locale)}
|
||||
class="group border-line-strong bg-surface-1 text-small text-ink relative inline-flex h-8 w-8 items-center justify-center rounded-sm border font-mono font-bold"
|
||||
aria-label="Connor Babbington — home"
|
||||
>
|
||||
CB
|
||||
<span class="bg-accent absolute -top-0.5 -right-0.5 h-1.5 w-1.5 rounded-full"></span>
|
||||
</a>
|
||||
|
||||
{/* Desktop nav */}
|
||||
<nav class="hidden md:block" aria-label={d.a11y.primaryNav}>
|
||||
<ul class="flex items-center gap-7">
|
||||
{
|
||||
navItems.map((item) => (
|
||||
<li>
|
||||
<a
|
||||
href={pathFor(item.id, locale)}
|
||||
class="nav-link text-small text-ink-muted hover:text-ink transition-colors duration-[--dur-quick]"
|
||||
aria-current={isCurrent(item.id) ? 'page' : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* Desktop right cluster */}
|
||||
<div class="hidden items-center gap-3 md:flex">
|
||||
<LangSwitch locale={locale} pageId={pageId} />
|
||||
<ThemeToggle locale={locale} />
|
||||
<SplitCvButton locale={locale} />
|
||||
</div>
|
||||
|
||||
{/* Mobile cluster */}
|
||||
<div class="flex items-center gap-2 md:hidden">
|
||||
<a
|
||||
href={pathFor('cv', locale)}
|
||||
class="bg-accent text-small text-accent-ink inline-flex h-9 items-center rounded-sm px-3 font-semibold"
|
||||
aria-label={d.cv.download}
|
||||
>
|
||||
CV
|
||||
</a>
|
||||
<button
|
||||
id="mobile-nav-toggle"
|
||||
type="button"
|
||||
class="border-line text-ink inline-flex h-9 w-9 items-center justify-center rounded-sm border"
|
||||
aria-label={d.a11y.openMenu}
|
||||
aria-controls="mobile-nav"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M4 7h16M4 12h16M4 17h16"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.7"
|
||||
stroke-linecap="round"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<MobileNav locale={locale} pageId={pageId} />
|
||||
|
||||
<style>
|
||||
#site-header[data-scrolled] {
|
||||
--header-h: var(--header-h-scrolled);
|
||||
border-bottom-color: var(--border);
|
||||
}
|
||||
.nav-link[aria-current='page'] {
|
||||
color: var(--ink);
|
||||
}
|
||||
.nav-link {
|
||||
position: relative;
|
||||
}
|
||||
.nav-link[aria-current='page']::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: -6px;
|
||||
height: 2px;
|
||||
background: var(--accent);
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
/*
|
||||
First-visit language hint (ROUTING_SPEC §3, MOCKUPS M5e). Rendered only on EN pages,
|
||||
hidden by default; nav.ts reveals it when the browser prefers Norwegian and it hasn't
|
||||
been dismissed. Never auto-redirects. Text is Norwegian (invites a NO-preferring reader).
|
||||
*/
|
||||
import { useDict } from '@i18n/t';
|
||||
import type { Locale } from '@i18n/locales';
|
||||
import { pathFor, type PageId } from '@i18n/slugMap';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
pageId: PageId;
|
||||
}
|
||||
const { locale, pageId } = Astro.props;
|
||||
const d = useDict(locale);
|
||||
---
|
||||
|
||||
<div id="lang-hint" hidden class="border-line bg-surface-2 border-b">
|
||||
<div
|
||||
class="text-small text-ink mx-auto flex max-w-[--content-max] items-center gap-4 px-6 py-2.5"
|
||||
>
|
||||
<span>{d.hint.text}</span>
|
||||
<a
|
||||
href={pathFor(pageId, 'no')}
|
||||
class="bg-accent text-mono-label text-accent-ink rounded-sm px-3 py-1 font-semibold"
|
||||
>
|
||||
{d.hint.action}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
data-hint-dismiss
|
||||
class="text-ink-muted hover:text-ink ml-auto"
|
||||
aria-label={d.hint.dismiss}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
/*
|
||||
Full-screen mobile nav overlay (MOCKUPS M4). Hidden by default; nav.ts toggles
|
||||
[data-open], traps focus, locks body scroll, closes on Esc. Large tap targets.
|
||||
Language switch inside is plain links (works without JS).
|
||||
*/
|
||||
import { useDict } from '@i18n/t';
|
||||
import type { Locale } from '@i18n/locales';
|
||||
import { pathFor, type PageId } from '@i18n/slugMap';
|
||||
import { getProfile } from '@lib/content';
|
||||
import LangSwitch from '@components/ui/LangSwitch.astro';
|
||||
import ThemeToggle from '@components/ui/ThemeToggle.astro';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
pageId: PageId;
|
||||
}
|
||||
const { locale, pageId } = Astro.props;
|
||||
const d = useDict(locale);
|
||||
const p = getProfile(locale);
|
||||
|
||||
const navItems: { id: PageId; label: string }[] = [
|
||||
{ id: 'home', label: d.nav.home },
|
||||
{ id: 'projects', label: d.nav.projects },
|
||||
{ id: 'experience', label: d.nav.experience },
|
||||
{ id: 'about', label: d.nav.about },
|
||||
{ id: 'contact', label: d.nav.contact },
|
||||
];
|
||||
---
|
||||
|
||||
<div
|
||||
id="mobile-nav"
|
||||
class="bg-surface-1 fixed inset-0 z-50 hidden flex-col px-6 pt-4 pb-8 data-[open]:flex md:hidden"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={d.a11y.primaryNav}
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span
|
||||
class="border-line-strong bg-surface-2 text-small text-ink inline-flex h-8 w-8 items-center justify-center rounded-sm border font-mono font-bold"
|
||||
>CB</span
|
||||
>
|
||||
<button
|
||||
id="mobile-nav-close"
|
||||
type="button"
|
||||
class="border-line text-ink inline-flex h-9 w-9 items-center justify-center rounded-sm border"
|
||||
aria-label={d.a11y.closeMenu}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M6 6l12 12M18 6L6 18"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.7"
|
||||
stroke-linecap="round"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center gap-3">
|
||||
<LangSwitch locale={locale} pageId={pageId} />
|
||||
<ThemeToggle locale={locale} />
|
||||
</div>
|
||||
|
||||
<nav class="mt-8 flex flex-col" aria-label={d.a11y.primaryNav}>
|
||||
{
|
||||
navItems.map((item) => (
|
||||
<a
|
||||
href={pathFor(item.id, locale)}
|
||||
class="border-line font-display text-h3 text-ink border-b py-4 font-semibold"
|
||||
aria-current={item.id === pageId ? 'page' : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</nav>
|
||||
|
||||
<div class="mt-8 flex flex-col gap-3">
|
||||
<a
|
||||
href={p.cv.en.path}
|
||||
download
|
||||
class="bg-accent text-body text-accent-ink rounded-sm px-4 py-3 text-center font-semibold"
|
||||
>
|
||||
{d.cv.download} — {d.cv.english}
|
||||
</a>
|
||||
<a
|
||||
href={p.cv.no.path}
|
||||
download
|
||||
class="border-line-strong text-body text-ink rounded-sm border px-4 py-3 text-center"
|
||||
>
|
||||
{d.cv.download} — {d.cv.norsk}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="text-mono-meta text-ink-muted mt-auto flex flex-col gap-2 pt-8 font-mono">
|
||||
<a href={`mailto:${p.links.email}`}>{p.links.email}</a>
|
||||
<a href={p.links.linkedin} rel="me noopener">LinkedIn ↗</a>
|
||||
<a href={p.links.gitea} rel="me noopener">git.cesnimda.uk ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
/* Head metadata: title, description, canonical, hreflang, OpenGraph, JSON-LD (SEO_SPEC). */
|
||||
import type { Locale } from '@i18n/locales';
|
||||
import { HTML_LANG, OG_LOCALE, otherLocale } from '@i18n/locales';
|
||||
import { pathFor, type PageId } from '@i18n/slugMap';
|
||||
import { absUrl, alternates, ogImagePath, buildJsonLd } from '@lib/seo';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
pageId: PageId;
|
||||
title: string;
|
||||
description: string;
|
||||
ogType?: 'website' | 'profile' | 'article';
|
||||
jsonLdOpts?: {
|
||||
projectName?: string;
|
||||
projectType?: 'SoftwareApplication' | 'SoftwareSourceCode';
|
||||
};
|
||||
}
|
||||
const { locale, pageId, title, description, ogType = 'website', jsonLdOpts } = Astro.props;
|
||||
|
||||
const origin = Astro.site?.href ?? 'https://cesnimda.co.uk';
|
||||
const canonical = absUrl(pathFor(pageId, locale), origin);
|
||||
const alts = alternates(pageId, origin);
|
||||
const ogImage = absUrl(ogImagePath(pageId, locale), origin);
|
||||
const jsonLd = buildJsonLd(pageId, locale, origin, jsonLdOpts);
|
||||
---
|
||||
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description} />
|
||||
<link rel="canonical" href={canonical} />
|
||||
|
||||
{alts.map((a) => <link rel="alternate" hreflang={a.hreflang} href={a.href} />)}
|
||||
|
||||
<meta property="og:type" content={ogType} />
|
||||
<meta property="og:site_name" content="Connor Babbington" />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:url" content={canonical} />
|
||||
<meta property="og:locale" content={OG_LOCALE[locale]} />
|
||||
<meta property="og:locale:alternate" content={OG_LOCALE[otherLocale(locale)]} />
|
||||
<meta property="og:image" content={ogImage} />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={ogImage} />
|
||||
|
||||
<meta name="robots" content="index,follow,max-image-preview:large" />
|
||||
<meta http-equiv="content-language" content={HTML_LANG[locale]} />
|
||||
|
||||
{jsonLd.map((obj) => <script type="application/ld+json" set:html={JSON.stringify(obj)} />)}
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
import { useDict } from '@i18n/t';
|
||||
import type { Locale } from '@i18n/locales';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
}
|
||||
const { locale } = Astro.props;
|
||||
const d = useDict(locale);
|
||||
---
|
||||
|
||||
<a href="#main" class="skip-link">{d.a11y.skipToContent}</a>
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
/*
|
||||
No-flash theme init. Loaded as an external, render-blocking script (served from
|
||||
/theme-init.js) so a strict CSP can allow script-src 'self' without inline hashes.
|
||||
Runs before first paint. See public/theme-init.js.
|
||||
*/
|
||||
---
|
||||
|
||||
<script is:inline src="/theme-init.js"></script>
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
import type { Locale } from '@i18n/locales';
|
||||
import { useDict } from '@i18n/t';
|
||||
import { homeAnchor, pathFor } from '@i18n/slugMap';
|
||||
import type { ProfileVM } from '@lib/content';
|
||||
import SectionLabel from '@components/ui/SectionLabel.astro';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
profile: ProfileVM;
|
||||
}
|
||||
const { locale, profile } = Astro.props;
|
||||
const d = useDict(locale);
|
||||
---
|
||||
|
||||
<section
|
||||
id={homeAnchor('about', locale)}
|
||||
class="mx-auto max-w-[--content-max] px-6 pt-24"
|
||||
data-reveal
|
||||
>
|
||||
<SectionLabel number="04" text={d.home.aboutLabel} />
|
||||
<div class="mt-4 grid gap-8 lg:grid-cols-12">
|
||||
<div class="lg:col-span-8">
|
||||
<h2 class="text-h2">{profile.aboutHeading}</h2>
|
||||
<p class="text-body-lg text-ink-muted mt-4 max-w-[64ch]">{profile.aboutParagraphs[0]}</p>
|
||||
<a
|
||||
href={pathFor('about', locale)}
|
||||
class="text-body text-accent mt-6 inline-flex items-center gap-2 hover:underline"
|
||||
>
|
||||
{profile.aboutHeading} →
|
||||
</a>
|
||||
</div>
|
||||
<div class="lg:col-span-4">
|
||||
<p class="text-mono-label text-ink-faint font-mono font-medium tracking-[0.08em] uppercase">
|
||||
{d.about.languages}
|
||||
</p>
|
||||
<ul class="mt-3 flex flex-col gap-2">
|
||||
{
|
||||
profile.languages.map((lang) => (
|
||||
<li class="text-body text-ink">
|
||||
{lang.label} <span class="text-ink-muted">— {lang.level}</span>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
import type { Locale } from '@i18n/locales';
|
||||
import { useDict } from '@i18n/t';
|
||||
import { homeAnchor, pathFor } from '@i18n/slugMap';
|
||||
import type { ProfileVM } from '@lib/content';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
profile: ProfileVM;
|
||||
}
|
||||
const { locale, profile } = Astro.props;
|
||||
const d = useDict(locale);
|
||||
---
|
||||
|
||||
<section
|
||||
id={homeAnchor('contact', locale)}
|
||||
class="mx-auto max-w-[--content-max] px-6 pt-24"
|
||||
data-reveal
|
||||
>
|
||||
<div class="border-line bg-surface-2 rounded-lg border p-8 md:p-12">
|
||||
<h2 class="text-h2 max-w-[24ch]">{profile.contactHeading}</h2>
|
||||
<p class="text-body-lg text-ink-muted mt-3">{profile.contactBody}</p>
|
||||
<div class="mt-7 flex flex-wrap items-center gap-4">
|
||||
<a
|
||||
href={`mailto:${profile.links.email}`}
|
||||
data-copy={profile.links.email}
|
||||
data-copied-label={d.form.copied}
|
||||
class="bg-accent text-body text-accent-ink inline-flex items-center gap-2 rounded-sm px-5 py-3 font-semibold transition-[filter] duration-[--dur-quick] hover:brightness-105"
|
||||
>
|
||||
<span data-copy-label>{profile.links.email}</span>
|
||||
<span aria-hidden="true">⧉</span>
|
||||
</a>
|
||||
<a
|
||||
href={pathFor('contact', locale)}
|
||||
class="text-body text-ink-muted hover:text-ink inline-flex items-center gap-2"
|
||||
>
|
||||
{d.nav.contact} →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,98 @@
|
||||
---
|
||||
import type { Locale } from '@i18n/locales';
|
||||
import { useDict } from '@i18n/t';
|
||||
import { homeAnchor } from '@i18n/slugMap';
|
||||
import { getExperience } from '@lib/content';
|
||||
import SectionLabel from '@components/ui/SectionLabel.astro';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
eyebrow?: boolean; // false on the standalone experience page (it has its own H1)
|
||||
}
|
||||
const { locale, eyebrow = true } = Astro.props;
|
||||
const d = useDict(locale);
|
||||
const items = getExperience(locale);
|
||||
const firstAlongside = items.findIndex((i) => i.alongside);
|
||||
|
||||
const period = (from: number, to: number | null) => `${from}–${to ?? d.home.present}`;
|
||||
---
|
||||
|
||||
<section
|
||||
id={homeAnchor('experience', locale)}
|
||||
class:list={['mx-auto max-w-[--content-max] px-6', eyebrow && 'pt-24']}
|
||||
data-reveal
|
||||
>
|
||||
{
|
||||
eyebrow && (
|
||||
<>
|
||||
<SectionLabel number="03" text={d.home.experienceLabel} />
|
||||
<h2 class="text-h2 mt-4">{d.home.experienceHeading}</h2>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="relative mt-10 pl-8">
|
||||
<span
|
||||
class="timeline-spine bg-line-strong absolute top-2 bottom-2 left-[5px] w-px origin-top"
|
||||
aria-hidden="true"></span>
|
||||
|
||||
{
|
||||
items.map((item, i) => (
|
||||
<>
|
||||
{i === firstAlongside && (
|
||||
<p class="text-mono-label text-ink-faint mt-6 mb-3 font-mono font-medium tracking-[0.08em] uppercase">
|
||||
{d.home.earlierRoles}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{item.emphasis === 'featured' ? (
|
||||
<div class="relative pb-10">
|
||||
<span
|
||||
class="bg-accent ring-surface-0 absolute top-1.5 -left-8 h-2.5 w-2.5 rounded-full ring-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h3 class="text-h4 text-ink font-semibold">
|
||||
{item.role} — {item.employer}
|
||||
</h3>
|
||||
<p class="text-mono-meta text-ink-faint mt-1 font-mono">
|
||||
{period(item.period.from, item.period.to)} · {item.location}
|
||||
{item.progression &&
|
||||
` · ${item.progression.map((p) => `${p.from}–${p.to} ${p.label}`).join(' → ')}`}
|
||||
</p>
|
||||
{item.summary && <p class="text-body text-ink-muted mt-3">{item.summary}</p>}
|
||||
{item.highlights && (
|
||||
<ul class="mt-3 flex flex-col gap-2">
|
||||
{item.highlights.map((h) => (
|
||||
<li class="text-body text-ink-muted flex gap-3">
|
||||
<span
|
||||
class="bg-accent mt-2 h-1 w-1 shrink-0 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{h}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div class="relative py-2.5">
|
||||
<span
|
||||
class="bg-ink-faint ring-surface-0 absolute top-4 -left-8 h-2 w-2 rounded-full ring-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p class="text-small text-ink-muted">
|
||||
<span class="text-ink-faint font-mono">
|
||||
{period(item.period.from, item.period.to)}
|
||||
</span>
|
||||
<span class="mx-2">·</span>
|
||||
<span class="text-ink">{item.role}</span>
|
||||
<span class="mx-2">·</span>
|
||||
{item.employer}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
import type { Locale } from '@i18n/locales';
|
||||
import { useDict } from '@i18n/t';
|
||||
import { pathFor } from '@i18n/slugMap';
|
||||
import type { ProfileVM } from '@lib/content';
|
||||
import Chip from '@components/ui/Chip.astro';
|
||||
import Button from '@components/ui/Button.astro';
|
||||
import TraceMotif from '@components/ui/TraceMotif.astro';
|
||||
import Portrait from '@components/ui/Portrait.astro';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
profile: ProfileVM;
|
||||
}
|
||||
const { locale, profile } = Astro.props;
|
||||
const d = useDict(locale);
|
||||
---
|
||||
|
||||
<section class="relative overflow-hidden">
|
||||
<div
|
||||
class="dot-grid hero-halo pointer-events-none absolute inset-0 -z-10"
|
||||
style="mask-image: linear-gradient(to bottom, black, transparent 70%); -webkit-mask-image: linear-gradient(to bottom, black, transparent 70%);"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto grid max-w-[--content-max] gap-10 px-6 pt-16 pb-14 lg:grid-cols-12 lg:pt-24">
|
||||
<div class="lg:col-span-7">
|
||||
<p class="text-mono-label text-accent font-mono font-medium tracking-[0.08em] uppercase">
|
||||
{profile.eyebrow}
|
||||
</p>
|
||||
<h1 class="text-display mt-4 tracking-[-0.01em]">{profile.name}</h1>
|
||||
<p class="font-display text-h3 text-ink mt-4 font-medium">{profile.heroTagline}</p>
|
||||
<p class="text-body-lg text-ink-muted mt-4 max-w-[52ch]">{profile.heroSummary}</p>
|
||||
|
||||
<TraceMotif statusLine={profile.statusLine} class="mt-8" />
|
||||
|
||||
<ul class="mt-7 flex flex-wrap gap-2">
|
||||
{
|
||||
profile.chips.map((chip) => (
|
||||
<li>
|
||||
<Chip kind="fact" label={chip.label} />
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
|
||||
<div class="mt-8 flex flex-wrap gap-3">
|
||||
<Button variant="primary" href={pathFor('projects', locale)}>{d.hero.viewProjects}</Button>
|
||||
<Button variant="secondary" href={pathFor('cv', locale)}>
|
||||
{d.cv.download}
|
||||
<span class="text-mono-label font-mono opacity-70">· pdf</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-5">
|
||||
<Portrait src={profile.photo.src} alt={profile.photo.alt} caption={profile.photo.caption} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
/* Full homepage composition, locale-parameterised so both routes share one source. */
|
||||
import type { Locale } from '@i18n/locales';
|
||||
import Base from '@layouts/Base.astro';
|
||||
import { getProfile, getMeta } from '@lib/content';
|
||||
import Hero from '@components/home/Hero.astro';
|
||||
import ProofStrip from '@components/home/ProofStrip.astro';
|
||||
import SkillsGrid from '@components/home/SkillsGrid.astro';
|
||||
import ProjectCards from '@components/home/ProjectCards.astro';
|
||||
import ExperienceTimeline from '@components/home/ExperienceTimeline.astro';
|
||||
import AboutTeaser from '@components/home/AboutTeaser.astro';
|
||||
import ContactBand from '@components/home/ContactBand.astro';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
}
|
||||
const { locale } = Astro.props;
|
||||
const profile = getProfile(locale);
|
||||
const m = getMeta('home', locale);
|
||||
---
|
||||
|
||||
<Base
|
||||
locale={locale}
|
||||
pageId="home"
|
||||
title={m.title}
|
||||
description={m.description}
|
||||
ogType="profile"
|
||||
mainClass="pb-8"
|
||||
>
|
||||
<Hero locale={locale} profile={profile} />
|
||||
<div class="mt-14">
|
||||
<ProofStrip profile={profile} />
|
||||
</div>
|
||||
<SkillsGrid locale={locale} />
|
||||
<ProjectCards locale={locale} />
|
||||
<ExperienceTimeline locale={locale} />
|
||||
<AboutTeaser locale={locale} profile={profile} />
|
||||
<ContactBand locale={locale} profile={profile} />
|
||||
</Base>
|
||||
@@ -0,0 +1,131 @@
|
||||
---
|
||||
import type { Locale } from '@i18n/locales';
|
||||
import { useDict } from '@i18n/t';
|
||||
import { homeAnchor } from '@i18n/slugMap';
|
||||
import { getProjectCards } from '@lib/content';
|
||||
import SectionLabel from '@components/ui/SectionLabel.astro';
|
||||
import Chip from '@components/ui/Chip.astro';
|
||||
import FramedImage from '@components/ui/FramedImage.astro';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
number?: string;
|
||||
heading?: string;
|
||||
header?: boolean; // false on the standalone projects index (it has its own H1)
|
||||
}
|
||||
const { locale, number = '02', heading, header = true } = Astro.props;
|
||||
const d = useDict(locale);
|
||||
const projects = getProjectCards(locale);
|
||||
const caseStudies = projects.filter((p) => p.template === 'case-study');
|
||||
const capabilities = projects.filter((p) => p.template === 'capability');
|
||||
---
|
||||
|
||||
<section
|
||||
id={homeAnchor('projects', locale)}
|
||||
class:list={['mx-auto max-w-[--content-max] px-6', header && 'pt-24']}
|
||||
data-reveal
|
||||
>
|
||||
{
|
||||
header && (
|
||||
<>
|
||||
<SectionLabel number={number} text={d.home.projectsLabel} />
|
||||
<h2 class="text-h2 mt-4">{heading ?? d.home.projectsHeading}</h2>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="mt-8 grid gap-6 lg:grid-cols-2">
|
||||
{
|
||||
caseStudies.map((p) => (
|
||||
<a
|
||||
href={p.path}
|
||||
class="group border-line bg-surface-1 hover:border-line-strong block overflow-hidden rounded-md border transition-colors duration-[--dur-standard]"
|
||||
aria-label={`${p.name} — ${d.project.readCaseStudy}`}
|
||||
>
|
||||
<div class="relative overflow-hidden">
|
||||
{p.heroMedia && (
|
||||
<div class="transition-transform duration-[--dur-standard] group-hover:-translate-y-1">
|
||||
<FramedImage media={p.heroMedia} noZoom class="rounded-none border-0" />
|
||||
</div>
|
||||
)}
|
||||
<div class="absolute top-4 right-4">
|
||||
<Chip kind="status" status={p.status} label={d.project.status[p.status]} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<h3 class="text-h3 text-ink font-semibold">{p.name}</h3>
|
||||
<p class="text-body text-ink-muted mt-2 max-w-prose">{p.cardTeaser}</p>
|
||||
<ul class="mt-4 flex flex-wrap gap-2">
|
||||
{p.stack.slice(0, 5).map((s) => (
|
||||
<li>
|
||||
<Chip kind="stack" label={s.name} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p class="text-body text-accent mt-5 inline-flex items-center gap-2">
|
||||
{d.project.readCaseStudy}
|
||||
<span class="transition-transform duration-[--dur-quick] group-hover:translate-x-1">
|
||||
→
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
{
|
||||
capabilities.map((p) => (
|
||||
<a
|
||||
href={p.path}
|
||||
class="group border-line bg-surface-1 hover:border-line-strong mt-6 flex flex-col items-start gap-4 rounded-md border p-6 transition-colors duration-[--dur-standard] sm:flex-row sm:items-center"
|
||||
>
|
||||
<span
|
||||
class="border-accent/40 text-accent grid h-12 w-12 shrink-0 place-items-center rounded-sm border"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||
<rect
|
||||
x="3"
|
||||
y="3"
|
||||
width="7"
|
||||
height="7"
|
||||
rx="1.5"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<rect
|
||||
x="3"
|
||||
y="14"
|
||||
width="7"
|
||||
height="7"
|
||||
rx="1.5"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<rect
|
||||
x="14"
|
||||
y="14"
|
||||
width="7"
|
||||
height="7"
|
||||
rx="1.5"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<path d="M13.5 6.5H18a2 2 0 0 1 2 2V11" stroke="currentColor" stroke-width="1.5" />
|
||||
</svg>
|
||||
</span>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-h4 text-ink font-semibold">{p.name}</h3>
|
||||
<p class="text-small text-ink-muted mt-1">{p.cardTeaser}</p>
|
||||
</div>
|
||||
<span class="text-body text-accent inline-flex items-center gap-2">
|
||||
{d.project.explore}
|
||||
<span class="transition-transform duration-[--dur-quick] group-hover:translate-x-1">
|
||||
→
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
import type { ProfileVM } from '@lib/content';
|
||||
|
||||
interface Props {
|
||||
profile: ProfileVM;
|
||||
}
|
||||
const { profile } = Astro.props;
|
||||
---
|
||||
|
||||
<section class="mx-auto max-w-[--content-max] px-6" data-reveal>
|
||||
<ul class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
{
|
||||
profile.proof.map((tile) => (
|
||||
<li class="border-line bg-surface-1 rounded-md border p-5">
|
||||
<p class="font-display text-ink text-[22px] leading-tight font-semibold">{tile.stat}</p>
|
||||
<p class="text-mono-label text-ink-faint mt-2 font-mono font-medium tracking-[0.08em] uppercase">
|
||||
{tile.label}
|
||||
</p>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
import type { Locale } from '@i18n/locales';
|
||||
import { useDict } from '@i18n/t';
|
||||
import { homeAnchor } from '@i18n/slugMap';
|
||||
import { getSkills } from '@lib/content';
|
||||
import SectionLabel from '@components/ui/SectionLabel.astro';
|
||||
import Chip from '@components/ui/Chip.astro';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
}
|
||||
const { locale } = Astro.props;
|
||||
const d = useDict(locale);
|
||||
const groups = getSkills(locale);
|
||||
const num = ['01', '02', '03'];
|
||||
---
|
||||
|
||||
<section
|
||||
id={homeAnchor('skills', locale)}
|
||||
class="mx-auto max-w-[--content-max] px-6 pt-24"
|
||||
data-reveal
|
||||
>
|
||||
<SectionLabel number="01" text={d.home.skillsLabel} />
|
||||
<h2 class="text-h2 mt-4">{d.home.skillsHeading}</h2>
|
||||
<p class="text-body text-ink-muted mt-3 max-w-[60ch]">{d.home.skillsIntro}</p>
|
||||
|
||||
<div class="mt-8 grid gap-5 md:grid-cols-3">
|
||||
{
|
||||
groups.map((group, i) => (
|
||||
<div class="border-line bg-surface-1 hover:border-line-strong rounded-md border p-6 transition-colors duration-[--dur-standard]">
|
||||
<p class="text-mono-label text-ink-faint font-mono font-medium tracking-[0.08em] uppercase">
|
||||
{num[i]} — {group.title}
|
||||
</p>
|
||||
<p class="text-small text-ink-muted mt-3">{group.context}</p>
|
||||
<ul class="mt-4 flex flex-wrap gap-2">
|
||||
{group.skills.map((s) => (
|
||||
<li>
|
||||
<Chip kind="stack" label={s.name} tooltip={s.tooltip} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
import type { Locale } from '@i18n/locales';
|
||||
import { useDict } from '@i18n/t';
|
||||
import Base from '@layouts/Base.astro';
|
||||
import { getProfile, getMeta } from '@lib/content';
|
||||
import SectionLabel from '@components/ui/SectionLabel.astro';
|
||||
import Portrait from '@components/ui/Portrait.astro';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
}
|
||||
const { locale } = Astro.props;
|
||||
const d = useDict(locale);
|
||||
const profile = getProfile(locale);
|
||||
const m = getMeta('about', locale);
|
||||
---
|
||||
|
||||
<Base locale={locale} pageId="about" title={m.title} description={m.description} ogType="profile">
|
||||
<div class="mx-auto grid max-w-[--content-max] gap-12 px-6 py-14 lg:grid-cols-12">
|
||||
<div class="lg:col-span-7">
|
||||
<SectionLabel text={d.nav.about} />
|
||||
<h1 class="text-h1 mt-4">{profile.aboutHeading}</h1>
|
||||
<div class="mt-6 flex flex-col gap-4">
|
||||
{
|
||||
profile.aboutParagraphs.map((para) => (
|
||||
<p class="text-body-lg text-ink-muted max-w-[64ch]">{para}</p>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
<p class="mt-8">
|
||||
<span class="mono-label mb-2 block">{d.about.interests}</span>
|
||||
<span class="text-body text-ink-muted">{profile.interestsLine}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<aside class="lg:col-span-5">
|
||||
<Portrait src={profile.photo.src} alt={profile.photo.alt} caption={profile.photo.caption} />
|
||||
<div class="mt-8">
|
||||
<p class="mono-label mb-3">{d.about.languages}</p>
|
||||
<ul class="flex flex-col gap-2">
|
||||
{
|
||||
profile.languages.map((lang) => (
|
||||
<li class="text-body text-ink">
|
||||
{lang.label} <span class="text-ink-muted">— {lang.level}</span>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</Base>
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
/*
|
||||
Colophon — "how this site works" (CONTENT_STRATEGY §4). A deliberate P2 hook: it
|
||||
states the production decisions behind the site honestly. Copy authored per locale.
|
||||
*/
|
||||
import type { Locale } from '@i18n/locales';
|
||||
import { useDict } from '@i18n/t';
|
||||
import Base from '@layouts/Base.astro';
|
||||
import { getMeta } from '@lib/content';
|
||||
import SectionLabel from '@components/ui/SectionLabel.astro';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
}
|
||||
const { locale } = Astro.props;
|
||||
const d = useDict(locale);
|
||||
const m = getMeta('colophon', locale);
|
||||
|
||||
const copy = {
|
||||
en: {
|
||||
lead: 'This site is a small work sample in its own right. A few of the decisions behind it:',
|
||||
points: [
|
||||
[
|
||||
'Static by default',
|
||||
'Built with Astro and served as static HTML — no client framework runtime, so the largest content paint is just text and CSS. The three interactive parts (menu, lightbox, contact form) are tiny progressive-enhancement modules.',
|
||||
],
|
||||
[
|
||||
'Bilingual from the ground up',
|
||||
'English and Norwegian are equal citizens: a typed slug map is the single source of truth for routes, the language switch and hreflang, so they can never drift apart.',
|
||||
],
|
||||
[
|
||||
'Motion that confirms, never performs',
|
||||
'One signature animation, everything else is restraint. Every effect has a reduced-motion variant, and content is never gated behind an animation.',
|
||||
],
|
||||
[
|
||||
'Self-hosted',
|
||||
'Built and deployed from my own Gitea instance to Docker behind nginx. The contact form is a small stateless .NET service — even that is part of my own stack.',
|
||||
],
|
||||
[
|
||||
'No tracking',
|
||||
'No analytics beacons, no third-party fonts, no cookies — which is also why there is no cookie banner.',
|
||||
],
|
||||
],
|
||||
},
|
||||
no: {
|
||||
lead: 'Denne siden er også et lite arbeidsprøve i seg selv. Noen av valgene bak den:',
|
||||
points: [
|
||||
[
|
||||
'Statisk som standard',
|
||||
'Bygget med Astro og servert som statisk HTML — ingen klient-rammeverk i drift, så det første innholdet som vises er bare tekst og CSS. De tre interaktive delene (meny, lightbox, kontaktskjema) er små moduler for progressiv forbedring.',
|
||||
],
|
||||
[
|
||||
'Tospråklig fra bunnen',
|
||||
'Engelsk og norsk er likestilt: et typet slug-kart er én kilde til sannhet for ruter, språkbytte og hreflang, så de kan aldri komme i utakt.',
|
||||
],
|
||||
[
|
||||
'Bevegelse som bekrefter, aldri opptrer',
|
||||
'Én signaturanimasjon, ellers tilbakeholdenhet. Hver effekt har en variant for redusert bevegelse, og innhold er aldri sperret bak en animasjon.',
|
||||
],
|
||||
[
|
||||
'Egendriftet',
|
||||
'Bygget og rullet ut fra min egen Gitea-instans til Docker bak nginx. Kontaktskjemaet er en liten tilstandsløs .NET-tjeneste — også den er en del av min egen stack.',
|
||||
],
|
||||
[
|
||||
'Ingen sporing',
|
||||
'Ingen analyse-beacons, ingen tredjeparts-fonter, ingen informasjonskapsler — som også er grunnen til at det ikke finnes noe cookie-banner.',
|
||||
],
|
||||
],
|
||||
},
|
||||
}[locale];
|
||||
---
|
||||
|
||||
<Base locale={locale} pageId="colophon" title={m.title} description={m.description}>
|
||||
<div class="mx-auto max-w-3xl px-6 py-16">
|
||||
<SectionLabel text={d.footer.colophon} />
|
||||
<h1 class="text-h1 mt-4">{d.footer.colophon}</h1>
|
||||
<p class="text-body-lg text-ink-muted mt-4">{copy.lead}</p>
|
||||
|
||||
<dl class="mt-8 flex flex-col gap-6">
|
||||
{
|
||||
copy.points.map(([term, desc]) => (
|
||||
<div class="border-line bg-surface-1 rounded-md border p-5">
|
||||
<dt class="text-h4 text-ink font-semibold">{term}</dt>
|
||||
<dd class="text-body text-ink-muted mt-2">{desc}</dd>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
</Base>
|
||||
@@ -0,0 +1,144 @@
|
||||
---
|
||||
import type { Locale } from '@i18n/locales';
|
||||
import { useDict } from '@i18n/t';
|
||||
import Base from '@layouts/Base.astro';
|
||||
import { getProfile, getMeta } from '@lib/content';
|
||||
import SectionLabel from '@components/ui/SectionLabel.astro';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
}
|
||||
const { locale } = Astro.props;
|
||||
const d = useDict(locale);
|
||||
const profile = getProfile(locale);
|
||||
const m = getMeta('contact', locale);
|
||||
|
||||
const fieldClass =
|
||||
'w-full rounded-sm border border-line-strong bg-surface-1 px-3 py-2.5 text-body text-ink transition-colors focus:border-accent focus:outline-none aria-[invalid=true]:border-danger';
|
||||
---
|
||||
|
||||
<Base locale={locale} pageId="contact" title={m.title} description={m.description}>
|
||||
<div class="mx-auto grid max-w-[--content-max] gap-12 px-6 py-14 lg:grid-cols-12">
|
||||
<div class="lg:col-span-7">
|
||||
<SectionLabel text={d.nav.contact} />
|
||||
<h1 class="text-h1 mt-4">{profile.contactHeading}</h1>
|
||||
<p class="text-body-lg text-ink-muted mt-3">{profile.contactBody}</p>
|
||||
|
||||
<form
|
||||
id="contact-form"
|
||||
class="mt-8 flex flex-col gap-5"
|
||||
data-msg-required={d.form.required}
|
||||
data-msg-email={d.form.invalidEmail}
|
||||
novalidate
|
||||
>
|
||||
<div>
|
||||
<label for="cf-name" class="text-small text-ink mb-1.5 block font-medium"
|
||||
>{d.form.name}</label
|
||||
>
|
||||
<input id="cf-name" name="name" type="text" autocomplete="name" class={fieldClass} />
|
||||
<p data-error-for="name" hidden role="alert" class="text-small text-danger mt-1.5"></p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="cf-email" class="text-small text-ink mb-1.5 block font-medium"
|
||||
>{d.form.email}</label
|
||||
>
|
||||
<input id="cf-email" name="email" type="email" autocomplete="email" class={fieldClass} />
|
||||
<p data-error-for="email" hidden role="alert" class="text-small text-danger mt-1.5"></p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="cf-message" class="text-small text-ink mb-1.5 block font-medium"
|
||||
>{d.form.message}</label
|
||||
>
|
||||
<textarea id="cf-message" name="message" rows="5" class={fieldClass}></textarea>
|
||||
<p data-error-for="message" hidden role="alert" class="text-small text-danger mt-1.5"></p>
|
||||
</div>
|
||||
|
||||
{/* Honeypot — hidden from users and assistive tech */}
|
||||
<div class="sr-only" aria-hidden="true">
|
||||
<label for="cf-company">Company</label>
|
||||
<input id="cf-company" name="company" type="text" tabindex="-1" autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
data-sending={d.form.sending}
|
||||
class="bg-accent text-body text-accent-ink inline-flex w-fit items-center justify-center rounded-sm px-5 py-2.5 font-semibold transition-[filter] duration-[--dur-quick] hover:brightness-105"
|
||||
>
|
||||
<span data-label>{d.form.send}</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div
|
||||
id="form-success"
|
||||
hidden
|
||||
class="border-accent/40 bg-surface-2 mt-8 rounded-md border p-6"
|
||||
>
|
||||
<p class="text-body text-accent flex items-center gap-2 font-semibold">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
class="check-path"
|
||||
d="M5 12l5 5 9-11"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
{d.form.successTitle}
|
||||
</p>
|
||||
<p class="text-body text-ink-muted mt-2">{d.form.successBody}</p>
|
||||
</div>
|
||||
|
||||
<div id="form-error" hidden class="border-danger/40 bg-surface-2 mt-8 rounded-md border p-6">
|
||||
<p class="text-body text-danger font-semibold">{d.form.errorTitle}</p>
|
||||
<p class="text-body text-ink-muted mt-2">
|
||||
{d.form.errorBody}
|
||||
<a class="text-accent hover:underline" href={`mailto:${profile.links.email}`}
|
||||
>{profile.links.email}</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="lg:col-span-5">
|
||||
<p class="mono-label mb-3">{d.form.orEmail}</p>
|
||||
<ul class="flex flex-col gap-4">
|
||||
<li>
|
||||
<a
|
||||
href={`mailto:${profile.links.email}`}
|
||||
data-copy={profile.links.email}
|
||||
data-copied-label={d.form.copied}
|
||||
class="text-body text-ink hover:text-accent inline-flex items-center gap-2"
|
||||
>
|
||||
<span data-copy-label>{profile.links.email}</span>
|
||||
<span class="text-ink-faint" aria-hidden="true">⧉</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href={profile.links.linkedin}
|
||||
rel="me noopener"
|
||||
class="text-body text-ink hover:text-accent">LinkedIn ↗</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href={profile.links.gitea}
|
||||
rel="me noopener"
|
||||
class="text-body text-ink hover:text-accent">git.cesnimda.uk ↗</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href={profile.links.phoneHref} class="text-body text-ink hover:text-accent"
|
||||
>{profile.links.phone}</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
import '@scripts/form';
|
||||
</script>
|
||||
</Base>
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
import type { Locale } from '@i18n/locales';
|
||||
import { useDict, interpolate } from '@i18n/t';
|
||||
import Base from '@layouts/Base.astro';
|
||||
import { getProfile, getMeta } from '@lib/content';
|
||||
import SectionLabel from '@components/ui/SectionLabel.astro';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
}
|
||||
const { locale } = Astro.props;
|
||||
const d = useDict(locale);
|
||||
const profile = getProfile(locale);
|
||||
const m = getMeta('cv', locale);
|
||||
|
||||
const cards = [
|
||||
{ code: 'EN', label: d.cv.english, cv: profile.cv.en },
|
||||
{ code: 'NO', label: d.cv.norsk, cv: profile.cv.no },
|
||||
];
|
||||
---
|
||||
|
||||
<Base locale={locale} pageId="cv" title={m.title} description={m.description}>
|
||||
<div class="mx-auto max-w-3xl px-6 py-16">
|
||||
<SectionLabel text="CV" />
|
||||
<h1 class="text-h1 mt-4">{d.cvPage.heading}</h1>
|
||||
<p class="text-body-lg text-ink-muted mt-4">{d.cvPage.intro}</p>
|
||||
|
||||
<div class="mt-8 grid gap-4 sm:grid-cols-2">
|
||||
{
|
||||
cards.map((c) => (
|
||||
<div class="border-line bg-surface-1 flex flex-col rounded-md border p-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="bg-surface-2 text-mono-label text-accent rounded-sm px-2 py-1 font-mono">
|
||||
{c.code}
|
||||
</span>
|
||||
<span class="text-body text-ink font-semibold">{c.label}</span>
|
||||
</div>
|
||||
<p class="text-mono-meta text-ink-faint mt-2 font-mono">
|
||||
{interpolate(d.cv.fileMeta, { size: c.cv.sizeKb })} · {d.cvPage.updated}{' '}
|
||||
{c.cv.updated}
|
||||
</p>
|
||||
<a
|
||||
href={c.cv.path}
|
||||
download
|
||||
class="bg-accent text-body text-accent-ink mt-5 inline-flex items-center justify-center gap-2 rounded-sm px-4 py-2.5 font-semibold transition-[filter] duration-[--dur-quick] hover:brightness-105"
|
||||
>
|
||||
{d.cv.download}
|
||||
</a>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<p class="text-mono-meta text-ink-faint mt-6 font-mono">{d.cvPage.atsNote}</p>
|
||||
</div>
|
||||
</Base>
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
import type { Locale } from '@i18n/locales';
|
||||
import { useDict } from '@i18n/t';
|
||||
import Base from '@layouts/Base.astro';
|
||||
import { getMeta } from '@lib/content';
|
||||
import SectionLabel from '@components/ui/SectionLabel.astro';
|
||||
import ExperienceTimeline from '@components/home/ExperienceTimeline.astro';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
}
|
||||
const { locale } = Astro.props;
|
||||
const d = useDict(locale);
|
||||
const m = getMeta('experience', locale);
|
||||
---
|
||||
|
||||
<Base locale={locale} pageId="experience" title={m.title} description={m.description}>
|
||||
<div class="mx-auto max-w-[--content-max] px-6 pt-14">
|
||||
<SectionLabel text={d.home.experienceLabel} />
|
||||
<h1 class="text-h1 mt-4">{d.home.experienceHeading}</h1>
|
||||
<p class="text-body-lg text-ink-muted mt-4 max-w-[62ch]">{m.description}</p>
|
||||
</div>
|
||||
<div class="mt-8">
|
||||
<ExperienceTimeline locale={locale} eyebrow={false} />
|
||||
</div>
|
||||
</Base>
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
import type { Locale } from '@i18n/locales';
|
||||
import { useDict } from '@i18n/t';
|
||||
import Base from '@layouts/Base.astro';
|
||||
import { getMeta } from '@lib/content';
|
||||
import SectionLabel from '@components/ui/SectionLabel.astro';
|
||||
import ProjectCards from '@components/home/ProjectCards.astro';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
}
|
||||
const { locale } = Astro.props;
|
||||
const d = useDict(locale);
|
||||
const m = getMeta('projects', locale);
|
||||
---
|
||||
|
||||
<Base locale={locale} pageId="projects" title={m.title} description={m.description}>
|
||||
<div class="mx-auto max-w-[--content-max] px-6 pt-14">
|
||||
<SectionLabel text={d.home.projectsLabel} />
|
||||
<h1 class="text-h1 mt-4">{d.home.projectsHeading}</h1>
|
||||
<p class="text-body-lg text-ink-muted mt-4 max-w-[62ch]">{m.description}</p>
|
||||
</div>
|
||||
<div class="mt-10">
|
||||
<ProjectCards locale={locale} header={false} />
|
||||
</div>
|
||||
</Base>
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
/*
|
||||
Button / link (MICRO_INTERACTIONS). Renders <a> when href is set, else <button>.
|
||||
Motion only transforms/opacity; focus ring comes from the global :focus-visible.
|
||||
*/
|
||||
interface Props {
|
||||
variant?: 'primary' | 'secondary' | 'ghost';
|
||||
href?: string;
|
||||
type?: 'button' | 'submit';
|
||||
id?: string;
|
||||
class?: string;
|
||||
download?: boolean;
|
||||
rel?: string;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
const {
|
||||
variant = 'primary',
|
||||
href,
|
||||
type = 'button',
|
||||
id,
|
||||
class: cls,
|
||||
download,
|
||||
rel,
|
||||
ariaLabel,
|
||||
} = Astro.props;
|
||||
|
||||
const base =
|
||||
'inline-flex items-center justify-center gap-2 rounded-sm px-4 py-2.5 text-body font-medium transition-[transform,background-color,filter,color] duration-[--dur-quick] ease-[--ease-out]';
|
||||
const variants = {
|
||||
primary: 'bg-accent text-accent-ink font-semibold hover:-translate-y-px hover:brightness-105',
|
||||
secondary: 'border border-line-strong text-ink hover:bg-surface-1',
|
||||
ghost:
|
||||
'text-ink-muted hover:text-ink underline-offset-4 decoration-accent decoration-2 hover:underline px-2',
|
||||
};
|
||||
const classes = [base, variants[variant], cls];
|
||||
---
|
||||
|
||||
{
|
||||
href ? (
|
||||
<a
|
||||
href={href}
|
||||
id={id}
|
||||
class:list={classes}
|
||||
download={download}
|
||||
rel={rel}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<slot />
|
||||
</a>
|
||||
) : (
|
||||
<button type={type} id={id} class:list={classes} aria-label={ariaLabel}>
|
||||
<slot />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
/*
|
||||
Surface card (DESIGN_SYSTEM §3): flat, hairline border, depth via border-strong on
|
||||
hover — no drop shadow in dark. `interactive` makes the whole card a single link.
|
||||
*/
|
||||
interface Props {
|
||||
href?: string;
|
||||
interactive?: boolean;
|
||||
class?: string;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
const { href, interactive = false, class: cls, ariaLabel } = Astro.props;
|
||||
|
||||
const base = 'rounded-md border border-line bg-surface-1 shadow-[var(--shadow-card)]';
|
||||
const hover = interactive
|
||||
? 'group block transition-colors duration-[--dur-standard] hover:border-line-strong focus-visible:border-line-strong'
|
||||
: '';
|
||||
---
|
||||
|
||||
{
|
||||
href ? (
|
||||
<a href={href} class:list={[base, hover, cls]} aria-label={ariaLabel}>
|
||||
<slot />
|
||||
</a>
|
||||
) : (
|
||||
<div class:list={[base, hover, cls]}>
|
||||
<slot />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
/*
|
||||
Information atom (DESIGN_SYSTEM §2). kinds: fact (non-interactive), stack (with
|
||||
optional tooltip), status (dot + label; accent=active, amber=in development).
|
||||
Status meaning is never colour-only — dot + text always paired (COLOUR_SYSTEM §3.4).
|
||||
*/
|
||||
type Status = 'active' | 'in-development' | 'archived';
|
||||
|
||||
interface Props {
|
||||
kind?: 'fact' | 'stack' | 'status';
|
||||
status?: Status;
|
||||
label: string;
|
||||
tooltip?: string;
|
||||
class?: string;
|
||||
}
|
||||
const { kind = 'fact', status, label, tooltip, class: cls } = Astro.props;
|
||||
|
||||
const base =
|
||||
'inline-flex items-center gap-2 rounded-full border px-3 py-1.5 font-mono text-mono-label whitespace-nowrap';
|
||||
|
||||
const statusRing: Record<Status, string> = {
|
||||
active: 'border-accent/40 text-accent',
|
||||
'in-development': 'border-amber/40 text-amber',
|
||||
archived: 'border-line text-ink-muted',
|
||||
};
|
||||
const dotColor: Record<Status, string> = {
|
||||
active: 'bg-accent',
|
||||
'in-development': 'bg-amber',
|
||||
archived: 'bg-ink-faint',
|
||||
};
|
||||
---
|
||||
|
||||
{
|
||||
kind === 'status' && status ? (
|
||||
<span class:list={[base, statusRing[status], cls]}>
|
||||
<span
|
||||
class:list={[
|
||||
'inline-block h-2 w-2 rounded-full',
|
||||
dotColor[status],
|
||||
status === 'active' && 'status-dot--active',
|
||||
]}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{label}
|
||||
</span>
|
||||
) : (
|
||||
<span class:list={[base, 'border-line bg-surface-1 text-ink-muted', cls]} title={tooltip}>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
/*
|
||||
Screenshot in a browser-chrome frame (DESIGN_SYSTEM §5). Aspect-locked to prevent
|
||||
layout shift (CLS 0). `placeholder:` sources render a styled skeleton until the real
|
||||
capture lands (Phase 1/2 seeded-data capture prerequisite); real images are lazy and
|
||||
become lightbox targets (bound by lightbox.ts via data-lightbox).
|
||||
*/
|
||||
interface Media {
|
||||
src: string;
|
||||
width: number;
|
||||
height: number;
|
||||
alt: string;
|
||||
caption: string;
|
||||
}
|
||||
interface Props {
|
||||
media: Media;
|
||||
priority?: boolean;
|
||||
index?: number;
|
||||
url?: string;
|
||||
class?: string;
|
||||
/** When true, never render the lightbox button (e.g. inside a card that is itself a link). */
|
||||
noZoom?: boolean;
|
||||
}
|
||||
const { media, priority = false, index, url, class: cls, noZoom = false } = Astro.props;
|
||||
const isPlaceholder = media.src.startsWith('placeholder:');
|
||||
const zoomable = !isPlaceholder && !noZoom && index !== undefined;
|
||||
const ratio = `${media.width} / ${media.height}`;
|
||||
---
|
||||
|
||||
<figure class:list={['border-line bg-surface-2 overflow-hidden rounded-lg border', cls]}>
|
||||
{/* Browser chrome */}
|
||||
<div class="border-line bg-surface-1 flex items-center gap-2 border-b px-4 py-2.5">
|
||||
<span class="bg-ink-faint h-2.5 w-2.5 rounded-full"></span>
|
||||
<span class="bg-ink-faint h-2.5 w-2.5 rounded-full"></span>
|
||||
<span class="bg-ink-faint h-2.5 w-2.5 rounded-full"></span>
|
||||
{
|
||||
url && (
|
||||
<span class="bg-surface-2 text-ink-faint mx-auto rounded-full px-4 py-0.5 font-mono text-[11px]">
|
||||
{url}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
{
|
||||
isPlaceholder ? (
|
||||
<div
|
||||
class="dot-grid flex items-center justify-center px-6 text-center"
|
||||
style={`aspect-ratio:${ratio}`}
|
||||
>
|
||||
<span class="mono-meta text-ink-faint max-w-sm">{media.caption}</span>
|
||||
</div>
|
||||
) : !zoomable ? (
|
||||
<img
|
||||
src={media.src}
|
||||
width={media.width}
|
||||
height={media.height}
|
||||
alt={media.alt}
|
||||
loading={priority ? 'eager' : 'lazy'}
|
||||
decoding="async"
|
||||
style={`aspect-ratio:${ratio}`}
|
||||
class="w-full"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full cursor-zoom-in"
|
||||
data-lightbox={index}
|
||||
data-lightbox-src={media.src}
|
||||
data-lightbox-caption={media.caption}
|
||||
aria-label={media.alt}
|
||||
>
|
||||
<img
|
||||
src={media.src}
|
||||
width={media.width}
|
||||
height={media.height}
|
||||
alt={media.alt}
|
||||
loading={priority ? 'eager' : 'lazy'}
|
||||
decoding="async"
|
||||
style={`aspect-ratio:${ratio}`}
|
||||
class="w-full"
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
<figcaption class="sr-only">{media.alt}</figcaption>
|
||||
</figure>
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
/*
|
||||
Language switch (MICRO_INTERACTIONS): a two-segment control that maps the current
|
||||
page to its twin in the other locale via the slug map. Implemented as two links,
|
||||
so it works with no JavaScript; the active segment is styled as the thumb.
|
||||
*/
|
||||
import { useDict } from '@i18n/t';
|
||||
import type { Locale } from '@i18n/locales';
|
||||
import { pathFor, type PageId } from '@i18n/slugMap';
|
||||
import { LOCALE_LABEL } from '@i18n/locales';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
pageId: PageId;
|
||||
class?: string;
|
||||
}
|
||||
const { locale, pageId, class: cls } = Astro.props;
|
||||
const d = useDict(locale);
|
||||
|
||||
const segments: { code: Locale; href: string; label: string }[] = [
|
||||
{ code: 'en', href: pathFor(pageId, 'en'), label: LOCALE_LABEL.en },
|
||||
{ code: 'no', href: pathFor(pageId, 'no'), label: LOCALE_LABEL.no },
|
||||
];
|
||||
---
|
||||
|
||||
<div
|
||||
class:list={['border-line bg-surface-1 inline-flex rounded-full border p-[3px]', cls]}
|
||||
role="group"
|
||||
aria-label={d.lang.switchTo}
|
||||
>
|
||||
{
|
||||
segments.map((s) =>
|
||||
s.code === locale ? (
|
||||
<span
|
||||
class="border-line-strong bg-surface-2 text-mono-label text-ink rounded-full border px-3 py-1 font-mono"
|
||||
aria-current="true"
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
) : (
|
||||
<a
|
||||
href={s.href}
|
||||
class="text-mono-label text-ink-muted hover:text-ink rounded-full px-3 py-1 font-mono transition-colors duration-[--dur-quick]"
|
||||
aria-label={d.lang.switchTo}
|
||||
>
|
||||
{s.label}
|
||||
</a>
|
||||
),
|
||||
)
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
/* Framed portrait, placeholder-aware. Real headshot drops into the same content ref. */
|
||||
interface Props {
|
||||
src: string;
|
||||
alt: string;
|
||||
caption: string;
|
||||
class?: string;
|
||||
}
|
||||
const { src, alt, caption, class: cls } = Astro.props;
|
||||
const isPlaceholder = src.startsWith('placeholder:');
|
||||
---
|
||||
|
||||
<div class:list={['mx-auto max-w-sm', cls]}>
|
||||
<div class="border-line-strong bg-surface-2 overflow-hidden rounded-lg border">
|
||||
{
|
||||
isPlaceholder ? (
|
||||
<div class="dot-grid flex aspect-[4/5] items-center justify-center">
|
||||
<span class="mono-meta text-ink-faint">[ portrait ]</span>
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
width="800"
|
||||
height="1000"
|
||||
class="aspect-[4/5] w-full object-cover"
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<p class="text-mono-meta text-ink-faint mt-2 font-mono">{caption}</p>
|
||||
</div>
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
/* Numbered mono section eyebrow, e.g. "01 — PROJECTS" (DESIGN_SYSTEM §4). */
|
||||
interface Props {
|
||||
number?: string;
|
||||
text: string;
|
||||
id?: string;
|
||||
}
|
||||
const { number, text, id } = Astro.props;
|
||||
---
|
||||
|
||||
<p id={id} class="text-mono-label text-accent font-mono font-medium tracking-[0.08em] uppercase">
|
||||
{number ? `${number} — ${text}` : text}
|
||||
</p>
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
/*
|
||||
CV split button (MICRO_INTERACTIONS): primary zone downloads the current-locale CV;
|
||||
the chevron is a native <details> disclosure listing both languages with file sizes
|
||||
— works with no JavaScript. nav.ts enhances it with outside-click / Esc close.
|
||||
*/
|
||||
import { useDict, interpolate } from '@i18n/t';
|
||||
import type { Locale } from '@i18n/locales';
|
||||
import { getProfile } from '@lib/content';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
compact?: boolean;
|
||||
}
|
||||
const { locale, compact = false } = Astro.props;
|
||||
const d = useDict(locale);
|
||||
const p = getProfile(locale);
|
||||
const current = p.cv[locale];
|
||||
const other = locale === 'en' ? p.cv.no : p.cv.en;
|
||||
const otherLabel = locale === 'en' ? d.cv.norsk : d.cv.english;
|
||||
const currentLabel = locale === 'en' ? d.cv.english : d.cv.norsk;
|
||||
---
|
||||
|
||||
<div class="cv-split relative inline-flex items-stretch">
|
||||
<a
|
||||
href={current.path}
|
||||
download
|
||||
class="bg-accent text-small text-accent-ink inline-flex items-center rounded-l-sm px-4 py-2 font-semibold transition-[filter] duration-[--dur-quick] hover:brightness-105"
|
||||
>
|
||||
{d.cv.download}
|
||||
{!compact && <span class="text-mono-label ml-2 font-mono opacity-70">EN/NO</span>}
|
||||
</a>
|
||||
<details class="cv-menu">
|
||||
<summary
|
||||
class="border-accent-ink/20 bg-accent text-accent-ink flex cursor-pointer list-none items-center rounded-r-sm border-l px-2 transition-[filter] duration-[--dur-quick] hover:brightness-105"
|
||||
aria-label={d.cv.download}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
|
||||
</svg>
|
||||
</summary>
|
||||
<div
|
||||
class="border-line-strong bg-surface-2 absolute top-[calc(100%+8px)] right-0 z-30 w-64 overflow-hidden rounded-md border shadow-[var(--shadow-overlay)]"
|
||||
>
|
||||
<a
|
||||
href={current.path}
|
||||
download
|
||||
class="text-small text-ink hover:bg-surface-1 flex items-center justify-between gap-2 px-4 py-3"
|
||||
>
|
||||
<span
|
||||
><span class="text-mono-label text-accent mr-2 font-mono">{d.lang[locale]}</span>{
|
||||
currentLabel
|
||||
}</span
|
||||
>
|
||||
<span class="mono-meta">{interpolate(d.cv.fileMeta, { size: current.sizeKb })}</span>
|
||||
</a>
|
||||
<a
|
||||
href={other.path}
|
||||
download
|
||||
class="border-line text-small text-ink-muted hover:bg-surface-1 hover:text-ink flex items-center justify-between gap-2 border-t px-4 py-3"
|
||||
>
|
||||
<span
|
||||
><span class="text-mono-label mr-2 font-mono">{locale === 'en' ? 'NO' : 'EN'}</span>{
|
||||
otherLabel
|
||||
}</span
|
||||
>
|
||||
<span class="mono-meta">{interpolate(d.cv.fileMeta, { size: other.sizeKb })}</span>
|
||||
</a>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
import { useDict } from '@i18n/t';
|
||||
import type { Locale } from '@i18n/locales';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
}
|
||||
const { locale } = Astro.props;
|
||||
const d = useDict(locale);
|
||||
---
|
||||
|
||||
<button
|
||||
id="theme-toggle"
|
||||
type="button"
|
||||
class="border-line text-ink-muted hover:text-ink inline-flex h-9 w-9 items-center justify-center rounded-full border transition-colors duration-[--dur-quick]"
|
||||
aria-label={d.theme.toLight}
|
||||
data-to-light={d.theme.toLight}
|
||||
data-to-dark={d.theme.toDark}
|
||||
>
|
||||
{/* Moon (shown in dark) / Sun (shown in light) — swapped by CSS on [data-theme]. */}
|
||||
<svg class="icon-moon" width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.6"
|
||||
stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
<svg class="icon-sun" width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="4" stroke="currentColor" stroke-width="1.6"></circle>
|
||||
<path
|
||||
d="M12 2v2M12 20v2M2 12h2M20 12h2M5 5l1.5 1.5M17.5 17.5 19 19M19 5l-1.5 1.5M6.5 17.5 5 19"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.6"
|
||||
stroke-linecap="round"></path>
|
||||
</svg>
|
||||
</button>
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
/*
|
||||
The signature "trace" (A1): a thin accent line that draws on load, steps down,
|
||||
and terminates in a lit status dot. Static under reduced motion / repeat visits.
|
||||
*/
|
||||
interface Props {
|
||||
statusLine: string;
|
||||
class?: string;
|
||||
}
|
||||
const { statusLine, class: cls } = Astro.props;
|
||||
---
|
||||
|
||||
<div class:list={['flex items-center gap-3', cls]}>
|
||||
<svg width="200" height="24" viewBox="0 0 200 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
class="trace-line"
|
||||
d="M2 12 H150 l10 10 H194"
|
||||
stroke="var(--accent)"
|
||||
stroke-width="1.5"
|
||||
style="--trace-len: 212"></path>
|
||||
<circle class="trace-dot status-dot--active" cx="196" cy="22" r="4" fill="var(--accent)"
|
||||
></circle>
|
||||
</svg>
|
||||
<span class="mono-meta">{statusLine}</span>
|
||||
</div>
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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.',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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.',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,191 @@
|
||||
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.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,325 @@
|
||||
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.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
/// <reference path="../.astro/types.d.ts" />
|
||||
/// <reference types="astro/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly PUBLIC_SITE_URL?: string;
|
||||
}
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { Dictionary } from './dictionary';
|
||||
|
||||
export const en: Dictionary = {
|
||||
hero: {
|
||||
viewProjects: 'View projects',
|
||||
},
|
||||
home: {
|
||||
skillsLabel: 'Skills',
|
||||
skillsHeading: 'What I work with',
|
||||
skillsIntro: 'A practical profile: business value, maintainability and dependable delivery.',
|
||||
projectsLabel: 'Projects',
|
||||
projectsHeading: 'Built, shipped and running',
|
||||
experienceLabel: 'Experience',
|
||||
experienceHeading: 'Where I’ve worked',
|
||||
aboutLabel: 'About',
|
||||
earlierRoles: 'Earlier & alongside',
|
||||
present: 'Present',
|
||||
},
|
||||
nav: {
|
||||
home: 'Home',
|
||||
projects: 'Projects',
|
||||
experience: 'Experience',
|
||||
about: 'About',
|
||||
contact: 'Contact',
|
||||
},
|
||||
about: {
|
||||
languages: 'Languages',
|
||||
interests: 'Interests',
|
||||
},
|
||||
cvPage: {
|
||||
heading: 'Download my CV',
|
||||
intro:
|
||||
'For a full overview of my experience, skills and work history, download my CV in either language.',
|
||||
atsNote: 'These are plain, ATS-friendly PDFs — single column, real text, standard headings.',
|
||||
updated: 'Updated',
|
||||
},
|
||||
lang: {
|
||||
switchTo: 'Switch to Norwegian',
|
||||
en: 'EN',
|
||||
no: 'NO',
|
||||
},
|
||||
theme: {
|
||||
toLight: 'Switch to light mode',
|
||||
toDark: 'Switch to dark mode',
|
||||
},
|
||||
cv: {
|
||||
download: 'Download CV',
|
||||
english: 'English CV',
|
||||
norsk: 'Norwegian CV',
|
||||
fileMeta: 'pdf · {size} kB',
|
||||
},
|
||||
project: {
|
||||
readCaseStudy: 'Read case study',
|
||||
explore: 'Explore',
|
||||
all: 'All projects',
|
||||
caseStudyEyebrow: 'Case study · personal product',
|
||||
capabilityEyebrow: 'Capability · infrastructure',
|
||||
onThisPage: 'On this page',
|
||||
repository: 'Repository',
|
||||
liveDemo: 'Live demo',
|
||||
prev: 'Previous',
|
||||
next: 'Next',
|
||||
status: {
|
||||
active: 'Active',
|
||||
'in-development': 'In development',
|
||||
archived: 'Archived',
|
||||
},
|
||||
tldr: { what: 'What', why: 'Why', stack: 'Stack', role: 'Role' },
|
||||
},
|
||||
form: {
|
||||
name: 'Name',
|
||||
email: 'E-mail',
|
||||
message: 'Message',
|
||||
send: 'Send message',
|
||||
sending: 'Sending',
|
||||
successTitle: 'Message sent',
|
||||
successBody: 'Thanks — I usually reply within a day.',
|
||||
errorTitle: 'Something went wrong',
|
||||
errorBody: 'The message could not be sent. Please e-mail me directly instead.',
|
||||
required: 'This field is required',
|
||||
invalidEmail: 'Please enter a valid e-mail address',
|
||||
orEmail: 'Or reach me directly',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
},
|
||||
// The first-visit hint appears only on EN pages, inviting a Norwegian-preferring
|
||||
// visitor to switch — so its text is written in Norwegian (ROUTING_SPEC §3).
|
||||
hint: {
|
||||
text: 'Denne siden finnes på norsk',
|
||||
action: 'Bytt til norsk',
|
||||
dismiss: 'Lukk',
|
||||
},
|
||||
notFound: {
|
||||
code: 'route not found',
|
||||
title: 'No such page',
|
||||
body: 'The page you were looking for does not exist or has moved.',
|
||||
home: 'Go to homepage',
|
||||
projects: 'View projects',
|
||||
},
|
||||
a11y: {
|
||||
skipToContent: 'Skip to content',
|
||||
primaryNav: 'Primary',
|
||||
openMenu: 'Open menu',
|
||||
closeMenu: 'Close menu',
|
||||
breadcrumb: 'Breadcrumb',
|
||||
backToTop: 'Back to top',
|
||||
},
|
||||
footer: {
|
||||
tagline: 'Systems developer based in Tønsberg, Norway.',
|
||||
colophon: 'How this site works',
|
||||
availability: 'Open to remote, hybrid or on-site roles.',
|
||||
copyright: '© {year} Connor Babbington',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { Dictionary } from './dictionary';
|
||||
|
||||
/*
|
||||
Norwegian (Bokmål) UI strings. Written natively, not translated — shorter and
|
||||
plainer than the English where the language allows (CONTENT_STRATEGY §5).
|
||||
|
||||
Glossary (fixed term pairs — I18N_SPEC §6.3):
|
||||
Systems Developer → Systemutvikler · case study → prosjektgjennomgang
|
||||
projects → prosjekter · experience → erfaring
|
||||
skills → kompetanse · work permit → oppholdstillatelse
|
||||
self-hosted → egendriftet · Product names / tech names → untranslated
|
||||
*/
|
||||
|
||||
export const no: Dictionary = {
|
||||
hero: {
|
||||
viewProjects: 'Se prosjekter',
|
||||
},
|
||||
home: {
|
||||
skillsLabel: 'Kompetanse',
|
||||
skillsHeading: 'Det jeg jobber med',
|
||||
skillsIntro: 'En praktisk profil: forretningsverdi, vedlikeholdbarhet og pålitelig leveranse.',
|
||||
projectsLabel: 'Prosjekter',
|
||||
projectsHeading: 'Bygget, levert og i drift',
|
||||
experienceLabel: 'Erfaring',
|
||||
experienceHeading: 'Hvor jeg har jobbet',
|
||||
aboutLabel: 'Om meg',
|
||||
earlierRoles: 'Tidligere og ved siden av',
|
||||
present: 'nå',
|
||||
},
|
||||
nav: {
|
||||
home: 'Hjem',
|
||||
projects: 'Prosjekter',
|
||||
experience: 'Erfaring',
|
||||
about: 'Om meg',
|
||||
contact: 'Kontakt',
|
||||
},
|
||||
about: {
|
||||
languages: 'Språk',
|
||||
interests: 'Interesser',
|
||||
},
|
||||
cvPage: {
|
||||
heading: 'Last ned CV-en min',
|
||||
intro:
|
||||
'For en full oversikt over erfaring, kompetanse og arbeidshistorikk kan du laste ned CV-en min på ønsket språk.',
|
||||
atsNote:
|
||||
'Dette er enkle, ATS-vennlige PDF-er — én kolonne, ekte tekst og standard overskrifter.',
|
||||
updated: 'Oppdatert',
|
||||
},
|
||||
lang: {
|
||||
switchTo: 'Switch to English',
|
||||
en: 'EN',
|
||||
no: 'NO',
|
||||
},
|
||||
theme: {
|
||||
toLight: 'Bytt til lyst modus',
|
||||
toDark: 'Bytt til mørkt modus',
|
||||
},
|
||||
cv: {
|
||||
download: 'Last ned CV',
|
||||
english: 'CV på engelsk',
|
||||
norsk: 'CV på norsk',
|
||||
fileMeta: 'pdf · {size} kB',
|
||||
},
|
||||
project: {
|
||||
readCaseStudy: 'Les gjennomgang',
|
||||
explore: 'Utforsk',
|
||||
all: 'Alle prosjekter',
|
||||
caseStudyEyebrow: 'Prosjektgjennomgang · eget produkt',
|
||||
capabilityEyebrow: 'Kompetanse · infrastruktur',
|
||||
onThisPage: 'På denne siden',
|
||||
repository: 'Kildekode',
|
||||
liveDemo: 'Live demo',
|
||||
prev: 'Forrige',
|
||||
next: 'Neste',
|
||||
status: {
|
||||
active: 'Aktiv',
|
||||
'in-development': 'Under utvikling',
|
||||
archived: 'Arkivert',
|
||||
},
|
||||
tldr: { what: 'Hva', why: 'Hvorfor', stack: 'Stack', role: 'Rolle' },
|
||||
},
|
||||
form: {
|
||||
name: 'Navn',
|
||||
email: 'E-post',
|
||||
message: 'Melding',
|
||||
send: 'Send melding',
|
||||
sending: 'Sender',
|
||||
successTitle: 'Meldingen er sendt',
|
||||
successBody: 'Takk — jeg svarer vanligvis innen en dag.',
|
||||
errorTitle: 'Noe gikk galt',
|
||||
errorBody: 'Meldingen kunne ikke sendes. Send meg gjerne en e-post direkte.',
|
||||
required: 'Dette feltet er påkrevd',
|
||||
invalidEmail: 'Skriv inn en gyldig e-postadresse',
|
||||
orEmail: 'Eller kontakt meg direkte',
|
||||
copy: 'Kopier',
|
||||
copied: 'Kopiert',
|
||||
},
|
||||
// Never shown on NO pages; mirror kept for interface completeness.
|
||||
hint: {
|
||||
text: 'This page is available in English',
|
||||
action: 'Switch to English',
|
||||
dismiss: 'Close',
|
||||
},
|
||||
notFound: {
|
||||
code: 'fant ikke siden',
|
||||
title: 'Siden finnes ikke',
|
||||
body: 'Siden du lette etter finnes ikke eller har blitt flyttet.',
|
||||
home: 'Gå til forsiden',
|
||||
projects: 'Se prosjekter',
|
||||
},
|
||||
a11y: {
|
||||
skipToContent: 'Hopp til innhold',
|
||||
primaryNav: 'Hovedmeny',
|
||||
openMenu: 'Åpne meny',
|
||||
closeMenu: 'Lukk meny',
|
||||
breadcrumb: 'Brødsmulesti',
|
||||
backToTop: 'Til toppen',
|
||||
},
|
||||
footer: {
|
||||
tagline: 'Systemutvikler bosatt i Tønsberg.',
|
||||
colophon: 'Slik er siden bygget',
|
||||
availability: 'Åpen for remote, hybrid eller stedbaserte roller.',
|
||||
copyright: '© {year} Connor Babbington',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
UI-string dictionary shape (I18N_SPEC §2). Both locale files implement this
|
||||
interface, so a missing key is a compile error — never a runtime fallback.
|
||||
Keys are semantic, never English-sentence keys.
|
||||
*/
|
||||
|
||||
export interface Dictionary {
|
||||
hero: {
|
||||
viewProjects: string;
|
||||
};
|
||||
home: {
|
||||
skillsLabel: string;
|
||||
skillsHeading: string;
|
||||
skillsIntro: string;
|
||||
projectsLabel: string;
|
||||
projectsHeading: string;
|
||||
experienceLabel: string;
|
||||
experienceHeading: string;
|
||||
aboutLabel: string;
|
||||
earlierRoles: string;
|
||||
present: string;
|
||||
};
|
||||
nav: {
|
||||
home: string;
|
||||
projects: string;
|
||||
experience: string;
|
||||
about: string;
|
||||
contact: string;
|
||||
};
|
||||
about: {
|
||||
languages: string;
|
||||
interests: string;
|
||||
};
|
||||
cvPage: {
|
||||
heading: string;
|
||||
intro: string;
|
||||
atsNote: string;
|
||||
updated: string;
|
||||
};
|
||||
lang: {
|
||||
/** aria-label for the switch group, phrased in the target language. */
|
||||
switchTo: string;
|
||||
en: string;
|
||||
no: string;
|
||||
};
|
||||
theme: {
|
||||
toLight: string;
|
||||
toDark: string;
|
||||
};
|
||||
cv: {
|
||||
download: string;
|
||||
english: string;
|
||||
norsk: string;
|
||||
/** e.g. "pdf · {size} kB" — {size} interpolated. */
|
||||
fileMeta: string;
|
||||
};
|
||||
project: {
|
||||
readCaseStudy: string;
|
||||
explore: string;
|
||||
all: string;
|
||||
caseStudyEyebrow: string;
|
||||
capabilityEyebrow: string;
|
||||
onThisPage: string;
|
||||
repository: string;
|
||||
liveDemo: string;
|
||||
prev: string;
|
||||
next: string;
|
||||
status: {
|
||||
active: string;
|
||||
'in-development': string;
|
||||
archived: string;
|
||||
};
|
||||
tldr: { what: string; why: string; stack: string; role: string };
|
||||
};
|
||||
form: {
|
||||
name: string;
|
||||
email: string;
|
||||
message: string;
|
||||
send: string;
|
||||
sending: string;
|
||||
successTitle: string;
|
||||
successBody: string;
|
||||
errorTitle: string;
|
||||
errorBody: string;
|
||||
required: string;
|
||||
invalidEmail: string;
|
||||
orEmail: string;
|
||||
copy: string;
|
||||
copied: string;
|
||||
};
|
||||
hint: {
|
||||
text: string;
|
||||
action: string;
|
||||
dismiss: string;
|
||||
};
|
||||
notFound: {
|
||||
code: string;
|
||||
title: string;
|
||||
body: string;
|
||||
home: string;
|
||||
projects: string;
|
||||
};
|
||||
a11y: {
|
||||
skipToContent: string;
|
||||
primaryNav: string;
|
||||
openMenu: string;
|
||||
closeMenu: string;
|
||||
breadcrumb: string;
|
||||
backToTop: string;
|
||||
};
|
||||
footer: {
|
||||
tagline: string;
|
||||
colophon: string;
|
||||
availability: string;
|
||||
/** {year} interpolated. */
|
||||
copyright: string;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/* Locale definitions. Single source for locale codes, html lang, and labels. */
|
||||
|
||||
export const LOCALES = ['en', 'no'] as const;
|
||||
export type Locale = (typeof LOCALES)[number];
|
||||
|
||||
export const DEFAULT_LOCALE: Locale = 'en';
|
||||
|
||||
/** `<html lang>` value per locale (I18N_SPEC §5). */
|
||||
export const HTML_LANG: Record<Locale, string> = {
|
||||
en: 'en-GB',
|
||||
no: 'nb-NO',
|
||||
};
|
||||
|
||||
/** hreflang code per locale (used in alternates + sitemap). */
|
||||
export const HREFLANG: Record<Locale, string> = {
|
||||
en: 'en',
|
||||
no: 'nb',
|
||||
};
|
||||
|
||||
/** OpenGraph locale (SEO_SPEC §4). */
|
||||
export const OG_LOCALE: Record<Locale, string> = {
|
||||
en: 'en_GB',
|
||||
no: 'nb_NO',
|
||||
};
|
||||
|
||||
/** Human label for the switch control, written in the *target* language. */
|
||||
export const LOCALE_LABEL: Record<Locale, string> = {
|
||||
en: 'EN',
|
||||
no: 'NO',
|
||||
};
|
||||
|
||||
export function isLocale(value: string): value is Locale {
|
||||
return (LOCALES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function otherLocale(locale: Locale): Locale {
|
||||
return locale === 'en' ? 'no' : 'en';
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
The EN↔NO route contract (ROUTING_SPEC §1, ARCHITECTURE A5).
|
||||
Single source of truth consumed by: the language switch, hreflang generation,
|
||||
the sitemap, nav links, and breadcrumbs. CI asserts its bijection (see tests).
|
||||
*/
|
||||
|
||||
import { LOCALES, DEFAULT_LOCALE, type Locale } from './locales';
|
||||
|
||||
export const PAGE_IDS = [
|
||||
'home',
|
||||
'projects',
|
||||
'jobtrack',
|
||||
'inboxintel',
|
||||
'homelab',
|
||||
'experience',
|
||||
'about',
|
||||
'contact',
|
||||
'cv',
|
||||
'colophon',
|
||||
] as const;
|
||||
|
||||
export type PageId = (typeof PAGE_IDS)[number];
|
||||
|
||||
/** Canonical, trailing-slashed paths per page per locale. */
|
||||
export const ROUTES: Record<PageId, Record<Locale, string>> = {
|
||||
home: { en: '/', no: '/no/' },
|
||||
projects: { en: '/projects/', no: '/no/prosjekter/' },
|
||||
jobtrack: { en: '/projects/jobtrack/', no: '/no/prosjekter/jobtrack/' },
|
||||
inboxintel: { en: '/projects/inboxintel/', no: '/no/prosjekter/inboxintel/' },
|
||||
homelab: { en: '/projects/homelab/', no: '/no/prosjekter/hjemmelab/' },
|
||||
experience: { en: '/experience/', no: '/no/erfaring/' },
|
||||
about: { en: '/about/', no: '/no/om-meg/' },
|
||||
contact: { en: '/contact/', no: '/no/kontakt/' },
|
||||
cv: { en: '/cv/', no: '/no/cv/' },
|
||||
colophon: { en: '/colophon/', no: '/no/kolofon/' },
|
||||
};
|
||||
|
||||
/** Which case-study/capability pages appear as project cards, in order. */
|
||||
export const PROJECT_PAGE_IDS = ['jobtrack', 'inboxintel', 'homelab'] as const;
|
||||
export type ProjectPageId = (typeof PROJECT_PAGE_IDS)[number];
|
||||
|
||||
/**
|
||||
* Homepage section anchors (ROUTING_SPEC §5). `id` is the canonical key used by
|
||||
* scroll-spy; the localised fragment is what appears in the URL and nav link.
|
||||
*/
|
||||
export const HOME_SECTIONS = [
|
||||
{ id: 'skills', en: 'skills', no: 'kompetanse' },
|
||||
{ id: 'projects', en: 'projects', no: 'prosjekter' },
|
||||
{ id: 'experience', en: 'experience', no: 'erfaring' },
|
||||
{ id: 'about', en: 'about', no: 'om-meg' },
|
||||
{ id: 'contact', en: 'contact', no: 'kontakt' },
|
||||
] as const;
|
||||
|
||||
export function homeAnchor(
|
||||
sectionId: (typeof HOME_SECTIONS)[number]['id'],
|
||||
locale: Locale,
|
||||
): string {
|
||||
const s = HOME_SECTIONS.find((x) => x.id === sectionId);
|
||||
return s ? s[locale] : sectionId;
|
||||
}
|
||||
|
||||
/** Absolute-within-site path for a page in a locale. */
|
||||
export function pathFor(pageId: PageId, locale: Locale): string {
|
||||
return ROUTES[pageId][locale];
|
||||
}
|
||||
|
||||
/** Normalise a path to the canonical trailing-slash form for comparison. */
|
||||
function normalise(path: string): string {
|
||||
const clean = path.split('?')[0]!.split('#')[0]!;
|
||||
if (clean === '') return '/';
|
||||
return clean.endsWith('/') ? clean : `${clean}/`;
|
||||
}
|
||||
|
||||
/** Resolve the pageId that a URL path belongs to (or undefined). */
|
||||
export function pageIdFromPath(path: string): PageId | undefined {
|
||||
const target = normalise(path);
|
||||
for (const id of PAGE_IDS) {
|
||||
for (const locale of LOCALES) {
|
||||
if (ROUTES[id][locale] === target) return id;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Infer the locale of a URL path from its prefix. */
|
||||
export function localeFromPath(path: string): Locale {
|
||||
const clean = normalise(path);
|
||||
return clean === '/no/' || clean.startsWith('/no/') ? 'no' : DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the current path, return the equivalent page's path in `target` locale.
|
||||
* This is the language-switch mapping (ROUTING_SPEC §3, J5). Falls back to the
|
||||
* target-locale home if the current path is unmapped (e.g. 404).
|
||||
*/
|
||||
export function twinPath(currentPath: string, target: Locale): string {
|
||||
const id = pageIdFromPath(currentPath);
|
||||
if (!id) return ROUTES.home[target];
|
||||
return ROUTES[id][target];
|
||||
}
|
||||
|
||||
/** Every (pageId, locale, path) triple — used by the sitemap builder. */
|
||||
export function allRoutes(): Array<{ pageId: PageId; locale: Locale; path: string }> {
|
||||
const out: Array<{ pageId: PageId; locale: Locale; path: string }> = [];
|
||||
for (const pageId of PAGE_IDS) {
|
||||
for (const locale of LOCALES) {
|
||||
out.push({ pageId, locale, path: ROUTES[pageId][locale] });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
Dictionary accessor. Components read the fully-typed dictionary object directly
|
||||
(`d.nav.projects`), which keeps compile-time key safety (I18N_SPEC §2). Runtime
|
||||
string paths are deliberately avoided — they'd lose that guarantee.
|
||||
*/
|
||||
|
||||
import type { Dictionary } from './dictionary';
|
||||
import { en } from './dictionary.en';
|
||||
import { no } from './dictionary.no';
|
||||
import type { Locale } from './locales';
|
||||
|
||||
const DICTS: Record<Locale, Dictionary> = { en, no };
|
||||
|
||||
/** Typed dictionary for a locale. */
|
||||
export function useDict(locale: Locale): Dictionary {
|
||||
return DICTS[locale];
|
||||
}
|
||||
|
||||
/** Replace {token} placeholders. The only interpolation mechanism (no plural engine). */
|
||||
export function interpolate(
|
||||
template: string,
|
||||
params: Record<string, string | number> = {},
|
||||
): string {
|
||||
return template.replace(/\{(\w+)\}/g, (match, key: string) =>
|
||||
key in params ? String(params[key]) : match,
|
||||
);
|
||||
}
|
||||
|
||||
export type { Dictionary };
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
import '@fontsource-variable/space-grotesk';
|
||||
import '@fontsource-variable/inter';
|
||||
import '@fontsource-variable/jetbrains-mono';
|
||||
import '@styles/global.css';
|
||||
|
||||
import type { Locale } from '@i18n/locales';
|
||||
import { HTML_LANG } from '@i18n/locales';
|
||||
import type { PageId } from '@i18n/slugMap';
|
||||
import Seo from '@components/core/Seo.astro';
|
||||
import ThemeScript from '@components/core/ThemeScript.astro';
|
||||
import SkipLink from '@components/core/SkipLink.astro';
|
||||
import Header from '@components/core/Header.astro';
|
||||
import HintBar from '@components/core/HintBar.astro';
|
||||
import Footer from '@components/core/Footer.astro';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
pageId: PageId;
|
||||
title: string;
|
||||
description: string;
|
||||
ogType?: 'website' | 'profile' | 'article';
|
||||
jsonLdOpts?: {
|
||||
projectName?: string;
|
||||
projectType?: 'SoftwareApplication' | 'SoftwareSourceCode';
|
||||
};
|
||||
mainClass?: string;
|
||||
}
|
||||
const { locale, pageId, title, description, ogType, jsonLdOpts, mainClass } = Astro.props;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang={HTML_LANG[locale]} data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<ThemeScript />
|
||||
<Seo
|
||||
locale={locale}
|
||||
pageId={pageId}
|
||||
title={title}
|
||||
description={description}
|
||||
ogType={ogType}
|
||||
jsonLdOpts={jsonLdOpts}
|
||||
/>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<meta name="theme-color" content="#0B0E14" />
|
||||
<slot name="head" />
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="scroll-sentinel"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute top-0 h-24 w-px"
|
||||
>
|
||||
</div>
|
||||
<SkipLink locale={locale} />
|
||||
<Header locale={locale} pageId={pageId} />
|
||||
{locale === 'en' && <HintBar locale={locale} pageId={pageId} />}
|
||||
<main id="main" class={mainClass}>
|
||||
<slot />
|
||||
</main>
|
||||
<Footer locale={locale} pageId={pageId} />
|
||||
|
||||
{/* Global progressive-enhancement modules (deferred, fail-silent). */}
|
||||
<script>
|
||||
import '@scripts/theme';
|
||||
import '@scripts/nav';
|
||||
import '@scripts/observer';
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 '@/data/profile';
|
||||
import { skills as skillsData } from '@/data/skills';
|
||||
import { experience as experienceData } from '@/data/experience';
|
||||
import { meta as metaData } from '@/data/meta';
|
||||
import { jobtrack } from '@/data/projects/jobtrack';
|
||||
import { inboxintel } from '@/data/projects/inboxintel';
|
||||
import { homelab } from '@/data/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<T>(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<typeof getProfile>;
|
||||
|
||||
// ---------------- 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<typeof getProject>;
|
||||
|
||||
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];
|
||||
}
|
||||
@@ -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<typeof block>;
|
||||
|
||||
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<typeof project>;
|
||||
|
||||
/* ---- 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<typeof skills>;
|
||||
|
||||
/* ---- 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<typeof experience>;
|
||||
|
||||
/* ---- 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<typeof profile>;
|
||||
|
||||
/* ---- 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<typeof meta>;
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
SEO helpers (SEO_SPEC). hreflang alternates and the sitemap derive from the slug
|
||||
map (ARCHITECTURE A5), so they can never drift from the routes.
|
||||
*/
|
||||
|
||||
import { LOCALES, HREFLANG, HTML_LANG, DEFAULT_LOCALE, type Locale } from '@i18n/locales';
|
||||
import { pathFor, type PageId } from '@i18n/slugMap';
|
||||
import { getProfile } from '@lib/content';
|
||||
|
||||
export function absUrl(path: string, origin: string): string {
|
||||
return new URL(path, origin).href;
|
||||
}
|
||||
|
||||
/** hreflang alternates for a page: en, nb, and x-default → en (SEO_SPEC §2). */
|
||||
export function alternates(pageId: PageId, origin: string) {
|
||||
const list = LOCALES.map((l) => ({
|
||||
hreflang: HREFLANG[l],
|
||||
href: absUrl(pathFor(pageId, l), origin),
|
||||
}));
|
||||
list.push({ hreflang: 'x-default', href: absUrl(pathFor(pageId, DEFAULT_LOCALE), origin) });
|
||||
return list;
|
||||
}
|
||||
|
||||
export function ogImagePath(pageId: PageId, locale: Locale): string {
|
||||
return `/og/${pageId}-${locale}.png`;
|
||||
}
|
||||
|
||||
const JOB_TITLE: Record<Locale, string> = { en: 'Systems Developer', no: 'Systemutvikler' };
|
||||
|
||||
/** JSON-LD graph for a page (SEO_SPEC §3). */
|
||||
export function buildJsonLd(
|
||||
pageId: PageId,
|
||||
locale: Locale,
|
||||
origin: string,
|
||||
opts: { projectName?: string; projectType?: 'SoftwareApplication' | 'SoftwareSourceCode' } = {},
|
||||
): object[] {
|
||||
const p = getProfile(locale);
|
||||
const graph: object[] = [];
|
||||
|
||||
const person = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Person',
|
||||
name: p.name,
|
||||
jobTitle: JOB_TITLE[locale],
|
||||
url: absUrl(pathFor('home', locale), origin),
|
||||
email: `mailto:${p.links.email}`,
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
addressLocality: 'Tønsberg',
|
||||
addressCountry: 'NO',
|
||||
},
|
||||
sameAs: [p.links.linkedin, p.links.gitea],
|
||||
knowsLanguage: ['en', 'nb'],
|
||||
knowsAbout: ['C#', '.NET', 'React', 'TypeScript', 'Python', 'SQL', 'Docker', 'Linux'],
|
||||
};
|
||||
|
||||
if (pageId === 'home') {
|
||||
graph.push(person);
|
||||
graph.push({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: p.name,
|
||||
url: absUrl(pathFor('home', locale), origin),
|
||||
inLanguage: HTML_LANG[locale],
|
||||
});
|
||||
} else if (opts.projectName) {
|
||||
graph.push({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': opts.projectType ?? 'CreativeWork',
|
||||
name: opts.projectName,
|
||||
author: person,
|
||||
inLanguage: HTML_LANG[locale],
|
||||
url: absUrl(pathFor(pageId, locale), origin),
|
||||
});
|
||||
} else {
|
||||
// Breadcrumb for other inner pages.
|
||||
graph.push({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: p.name,
|
||||
item: absUrl(pathFor('home', locale), origin),
|
||||
},
|
||||
{ '@type': 'ListItem', position: 2, item: absUrl(pathFor(pageId, locale), origin) },
|
||||
],
|
||||
});
|
||||
}
|
||||
return graph;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
import '@fontsource-variable/space-grotesk';
|
||||
import '@fontsource-variable/inter';
|
||||
import '@fontsource-variable/jetbrains-mono';
|
||||
import '@styles/global.css';
|
||||
import ThemeScript from '@components/core/ThemeScript.astro';
|
||||
import { en } from '@i18n/dictionary.en';
|
||||
import { no } from '@i18n/dictionary.no';
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>404 — Connor Babbington</title>
|
||||
<meta name="robots" content="noindex,follow" />
|
||||
<ThemeScript />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="mx-auto flex min-h-screen max-w-2xl flex-col justify-center px-6">
|
||||
{/* Trace motif frays at the break (A10) */}
|
||||
<svg width="320" height="40" viewBox="0 0 320 40" fill="none" aria-hidden="true">
|
||||
<path d="M2 20 H140" stroke="var(--accent)" stroke-width="1.5"></path>
|
||||
<path
|
||||
d="M150 20 H318"
|
||||
stroke="var(--accent)"
|
||||
stroke-width="1.5"
|
||||
stroke-dasharray="2 6"
|
||||
opacity="0.6"></path>
|
||||
</svg>
|
||||
|
||||
<p class="text-mono-label text-accent mt-6 font-mono tracking-[0.08em] uppercase">
|
||||
{en.notFound.code} · {no.notFound.code}
|
||||
</p>
|
||||
<h1 class="text-h1 mt-3">{en.notFound.title}</h1>
|
||||
<p class="text-body-lg text-ink-muted mt-2">{en.notFound.body}</p>
|
||||
|
||||
<h2 class="text-h3 text-ink-muted mt-8">{no.notFound.title}</h2>
|
||||
<p class="text-body text-ink-muted mt-1">{no.notFound.body}</p>
|
||||
|
||||
<div class="mt-8 flex flex-wrap gap-3">
|
||||
<a href="/" class="bg-accent text-accent-ink rounded-sm px-4 py-2.5 font-semibold">
|
||||
{en.notFound.home}
|
||||
</a>
|
||||
<a href="/no/" class="border-line-strong text-ink rounded-sm border px-4 py-2.5">
|
||||
{no.notFound.home}
|
||||
</a>
|
||||
<a href="/projects/" class="border-line-strong text-ink rounded-sm border px-4 py-2.5">
|
||||
{en.notFound.projects}
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
import AboutPage from '@components/pages/AboutPage.astro';
|
||||
---
|
||||
|
||||
<AboutPage locale="en" />
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
import ColophonPage from '@components/pages/ColophonPage.astro';
|
||||
---
|
||||
|
||||
<ColophonPage locale="en" />
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
import ContactPage from '@components/pages/ContactPage.astro';
|
||||
---
|
||||
|
||||
<ContactPage locale="en" />
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
import CvPage from '@components/pages/CvPage.astro';
|
||||
---
|
||||
|
||||
<CvPage locale="en" />
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
import ExperiencePage from '@components/pages/ExperiencePage.astro';
|
||||
---
|
||||
|
||||
<ExperiencePage locale="en" />
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
import Homepage from '@components/home/Homepage.astro';
|
||||
---
|
||||
|
||||
<Homepage locale="en" />
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
import CvPage from '@components/pages/CvPage.astro';
|
||||
---
|
||||
|
||||
<CvPage locale="no" />
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
import ExperiencePage from '@components/pages/ExperiencePage.astro';
|
||||
---
|
||||
|
||||
<ExperiencePage locale="no" />
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
import Homepage from '@components/home/Homepage.astro';
|
||||
---
|
||||
|
||||
<Homepage locale="no" />
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
import ColophonPage from '@components/pages/ColophonPage.astro';
|
||||
---
|
||||
|
||||
<ColophonPage locale="no" />
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
import ContactPage from '@components/pages/ContactPage.astro';
|
||||
---
|
||||
|
||||
<ContactPage locale="no" />
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user