Personal portfolio at marcuspff.com. Built with Next.js 16 + React 19 + Tailwind v4. Includes a RAG-powered AI chatbot (both inline on the homepage and corner-mounted elsewhere), a spam-hardened contact form, an interactive 3D globe of motorcycle trips, an LLM-powered internship-report assessor, an interactive meditation quiz, a ⌘K command palette, a cursor-reactive particle field, and a print-ready dynamic CV.
| Route | What it is |
|---|---|
/ |
Hero with inline Q&A chatbot input. Selected projects, skills, GitHub activity, contact form, social links. |
/llm |
LLM-course timeline. Each row links to the course's blog; courses with apps (/llm/course3, /llm/course-5) get an "Open app" pill. |
/llm/<slug>/blog |
Per-course Markdown blog post. Sticky left-side timeline sidebar shows all course blogs. |
/llm/course3 |
Course 4 — Meditations-quiz. 29 questions across 5 sections in Danish; submit-validated, color-coded feedback. |
/llm/course-5 |
Course 5 + 6 — AI Assignment Assessor. Paste a Danish datamatiker internship report, get a Llama-3.3-70B-driven structured assessment against a 5-criterion rubric + Dare-Share-Care framework. |
/trips |
Motorcycle trip logbook. Sortable list, EN/DK/DE language switcher, optional 3D globe overlay (NASA Blue Marble, multi-tier device-aware mobile fallback). |
/trips/<slug> |
Individual trip with sticky year-grouped sidebar, story, highlights, photos. |
/cv |
Print-friendly CV — fully dynamic, pulls live from lib/data.ts and lib/trips.ts. Cmd/Ctrl-P to save as PDF. |
/secret |
Easter-egg page with a hint about the Konami code and ⌘K. noindex,nofollow. |
/sitemap.xml · /robots.txt · /opengraph-image |
Auto-generated by next/og and Next.js metadata routes. |
| Layer | Tools |
|---|---|
| Framework | Next.js 16 (Turbopack, App Router, View Transitions API), React 19 |
| Styling | Tailwind CSS v4 (config in app/globals.css) |
| Language | TypeScript |
| LLM | Groq (llama-3.3-70b-versatile) for chat + assessor |
| Embeddings | Google Generative AI (gemini-embedding-001) |
| Vector DB | Supabase + pgvector |
| 3D globe | react-globe.gl + three |
| Markdown | react-markdown + remark-gfm |
Resend (transactional outbound from hello@marcuspff.com) |
|
| Anti-bot | Cloudflare Turnstile (Managed widget) |
| Telemetry | Vercel Analytics + Vercel Speed Insights |
The chatbot answers questions using a vector retrieval pipeline rather than a single static system prompt — so it scales with new content (projects, trips, courses, course-blog posts) without prompt rewrites.
- Inline on homepage:
<HeroQA />under the name. Single Q&A view — each new question replaces the previous answer. Stateless (no message history sent), so it's a true Q&A rather than a multi-turn conversation. - Corner widget on other routes:
<ChatWidget />mounted via<ChatWidgetLazy />(dynamic import +ssr: false) on/llm,/trips, course detail / blog / quiz / assessor pages.
Both POST to the same /api/chat route.
- User query is embedded with
gemini-embedding-001. - Embedding is matched against the Supabase
document_chunkstable via thematch_document_chunksRPC (match_threshold: 0.2,match_count: 15). - Top-15 chunks are concatenated and injected into the Groq Llama-3.3-70B system prompt alongside a tiny static anchor (name + roles + status).
- Groq streams the response back to the consumer component.
marcus.md— long-form biolib/data.ts—personalDetails,skillGroups,projects,classeslib/trips.ts— every trip (each becomes 2 chunks: summary + story)content/course-blogs/*.md— per-course blog markdown
GitHub Actions workflow .github/workflows/update-embeddings.yml re-runs
scripts/embed.ts on every push to main that touches marcus.md,
lib/data.ts, lib/trips.ts, scripts/embed.ts, or package.json.
The chatbot in production reflects new data within ~60 s of merge.
A spam-hardened form on the homepage (components/ContactForm.tsx) that
posts to app/api/contact/route.ts. Eight layers of defense:
- Honeypot field — silently 200s when bots fill the hidden
websiteinput. - Min fill time — rejects sub-3 s submissions.
- Max fill time — rejects stale (>1 h) form sessions.
- URL-count cap — rejects messages with more than 2 links.
- Cloudflare Turnstile — server-side
siteverifyagainst the secret key. - Per-IP rate limit — 3/10 min, 10/24 h (
lib/rate-limit.ts). - Global daily cap — 50/day total emails.
- Origin allowlist —
lib/api-security.tsrejects browser requests from any origin not in{localhost:3000, localhost:3001, marcuspff.com, www.marcuspff.com}.
Mail goes out via Resend from Marcus Forsberg <hello@marcuspff.com>
(custom domain verified in Resend with DKIM/SPF/DMARC) to a personal
Gmail. Reply-To is set to the submitter so hitting reply in Gmail goes
back to them.
Inbound mail to *@marcuspff.com is forwarded by ImprovMX (free,
MX-based) to the same Gmail. Resend handles outbound, ImprovMX handles
inbound; they live on different DNS records (TXT vs MX) and don't
conflict.
A few details worth calling out:
- ⌘K command palette (
<CommandPalette />inapp/layout.tsx) — Cmd-K / Ctrl-K opens a search overlay anywhere on the site. Indexes every Page, Project, Trip, and Course blog live fromlib/data.ts/lib/trips.ts. ↑↓ to navigate, Enter to open, Esc to close. - Cursor-reactive particle field (
<ParticleField />, homepage only) — pure-canvas dot field that drifts across the entire document height. Particles within a 150 px radius of the cursor accelerate toward it with quadratic falloff and brighten from slate-500 to violet-500; faint tendrils draw from cursor to nearby particles. DPR-aware, rAF-paused on visibilitychange, disabled on touch + reduced-motion. - Active-section sliding dot in the nav (
<GlassNav />). A single violet dot animates between hash-link positions as you scroll past#projects,#skills,#contact. Driven byIntersectionObserver. Fades out in place above the first section. - Konami code (
<KonamiCode />) —↑↑↓↓←→←→ b atriggers a six-second confetti overlay with a "Cheat unlocked" card. - Click-to-copy email (
<CopyEmail />) below the contact form's social pills. Clickhello@marcuspff.com→ copied to clipboard, "Copied" toast above the button for 2 s. - Show-all projects animation — projects 5–8 expand/collapse smoothly
via the
grid-template-rows: 0fr → 1frmodern CSS trick, withinerton the collapsed wrapper for a11y.
Node 20+ recommended.
git clone https://github.com/MarcusPFF/portfolio.git
cd portfolio
npm installCreate .env.local (full set):
# Supabase (RAG vector DB)
NEXT_PUBLIC_SUPABASE_URL=...
SUPABASE_SERVICE_ROLE_KEY=...
# Embeddings + chat fallback
GOOGLE_GENERATIVE_AI_API_KEY=...
# LLM completion (chat + assessor)
GROQ_API_KEY=...
GROQ_COURSE5_API_KEY=... # separate key for the assessor
# Contact form (Resend + Turnstile)
RESEND_API_KEY=re_...
CONTACT_FROM_EMAIL="Marcus Forsberg <hello@marcuspff.com>"
CONTACT_TO_EMAIL=marcus.pff03@gmail.com # optional, default shown
NEXT_PUBLIC_TURNSTILE_SITE_KEY=0x4AAA...
TURNSTILE_SECRET_KEY=0x4AAA...Run:
npm run dev # dev server (Turbopack)
npm run build # production build
npm run lint # ESLint
npm run embed # re-embed knowledge into Supabase (manual)
npm run geocode # resolve trip waypoint coords via Nominatimapp/
api/
chat/route.ts # RAG chat — Supabase retrieval + Groq stream
contact/route.ts # Resend send + Turnstile + 8-layer defense
llm/assess/route.ts # Course 5+6 — structured assessor
llm/
page.tsx # Class timeline
course3/page.tsx # Meditations quiz wrapper
course-5/page.tsx # Assessor wrapper
[course]/blog/page.tsx # Dynamic Markdown blog with sidebar timeline
trips/
page.tsx # Trip list + 3D globe toggle
[slug]/page.tsx # Trip detail with sticky year sidebar
cv/page.tsx # Print-friendly dynamic CV
secret/page.tsx # Easter-egg page
layout.tsx # Metadata, OG, Twitter Card, JSON-LD,
# skip-to-content, KonamiCode, CommandPalette
not-found.tsx # Custom 404 ("Off the map.")
error.tsx # Custom error boundary ("Mechanical failure.")
sitemap.ts # /sitemap.xml — built from trips + classes
robots.ts # /robots.txt
manifest.ts # /manifest.webmanifest
opengraph-image.tsx # /opengraph-image — auto 1200×630
icon.tsx # /icon — auto 32×32 favicon
apple-icon.tsx # /apple-icon — auto 180×180
globals.css # Tailwind v4 + view transitions + nav-link styles
components/
GlassNav.tsx # Floating nav with sliding active-section dot
GlassHero.tsx # Cardless hero (typewriter + name + HeroQA)
GlassProjects.tsx # Magazine-list with show-all fold animation
GlassSkills.tsx # Magazine-list of skill rows
GlassContact.tsx # Form + socials + CopyEmail
HeroQA.tsx # Inline Q&A chat input on homepage
ContactForm.tsx # Form with Turnstile, honeypot, magnetic submit
CopyEmail.tsx # Click-to-copy email with toast
CommandPalette.tsx # ⌘K / Ctrl-K overlay
KonamiCode.tsx # Easter-egg trigger + overlay
ParticleField.tsx # Cursor-reactive canvas particles
GithubActivity.tsx # Recent GitHub events card
ChatWidget.tsx # Corner-mounted chat (used on /llm and /trips)
ChatWidgetLazy.tsx # dynamic-import wrapper (ssr: false)
TypewriterRoles.tsx # Rotating role cycler in hero eyebrow
LLMTimeline.tsx, Course3Quiz.tsx, Course5Assessor.tsx
TripsListClient.tsx, TripDetailClient.tsx,
TripsGlobe.tsx, TripsGlobeInner.tsx, TripsLangSwitcher.tsx, useTripsLang.ts
ScrollReveal.tsx
lib/
data.ts # personalDetails, skillGroups, projects, classes
trips.ts # Trip type + array + waypoint helpers
cityCoords.json # Nominatim cache for trip waypoints
course3Data.ts # Quiz sections + questions
rate-limit.ts # Chat + contact rate limiters
api-security.ts # Origin allowlist for API routes
tripsI18n.ts # EN/DK/DE strings for /trips
scripts/
embed.ts # Build chunks → Supabase
geocode.ts # Nominatim lookup + per-trip distance
content/course-blogs/
course1.md, course2.md, course4.md, course5.md
# Per-course blog content rendered via /llm/<slug>/blog
public/trips/
earth-16k.jpg, earth-8k.jpg, earth-2k.jpg
# Multi-tier earth textures (16K desktop / 8K capable
# mobile / 2K constrained mobile, picked at runtime)
earth-topology-4k.jpg, earth-water.png
borders.geojson # Natural Earth political borders
.github/workflows/
update-embeddings.yml # Auto re-embed on push to main
- Headers (
next.config.ts):X-Frame-Options: DENY,X-Content-Type-Options: nosniff,Referrer-Policy,Permissions-Policy,Strict-Transport-Security. - Origin allowlist on all three API routes via
lib/api-security.ts. - No
dangerouslySetInnerHTMLon user content — only static JSON-LD. - Prompt-injection defense on the assessor (system prompt instructs
the model to ignore overrides inside the
[STUDENT SUBMISSION]block). - Sitemap auto-generated from trip + course data at
/sitemap.xml. - OG image generated at build time via
next/ogImageResponse. - Person JSON-LD in
app/layout.tsxfor structured data. - Skip-to-content link (sr-only until focused) for keyboard users.
inertused overaria-hiddenfor collapsed UI subtrees so focus + AT exposure are correctly removed.
Hosted on Vercel. Static routes are built; dynamic routes are
/api/chat, /api/contact, /api/llm/assess. All env vars from the local
setup section need to exist in Vercel's project settings before deploy.
DNS for marcuspff.com is registered at Simply but uses Vercel
nameservers (ns1.vercel-dns.com, ns2.vercel-dns.com). MX records point
at ImprovMX for inbound mail forwarding; TXT records (DKIM/SPF/DMARC) on
send.marcuspff.com authenticate Resend's outbound mail.