Skip to content

MarcusPFF/portfolio

Repository files navigation

Marcus Forsberg — Portfolio

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.

What's on the site

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.

Tech stack

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
Email Resend (transactional outbound from hello@marcuspff.com)
Anti-bot Cloudflare Turnstile (Managed widget)
Telemetry Vercel Analytics + Vercel Speed Insights

RAG chatbot

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.

Two surfaces, one API

  • 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.

Pipeline

  1. User query is embedded with gemini-embedding-001.
  2. Embedding is matched against the Supabase document_chunks table via the match_document_chunks RPC (match_threshold: 0.2, match_count: 15).
  3. Top-15 chunks are concatenated and injected into the Groq Llama-3.3-70B system prompt alongside a tiny static anchor (name + roles + status).
  4. Groq streams the response back to the consumer component.

Knowledge sources (re-embedded on every relevant push)

  • marcus.md — long-form bio
  • lib/data.tspersonalDetails, skillGroups, projects, classes
  • lib/trips.ts — every trip (each becomes 2 chunks: summary + story)
  • content/course-blogs/*.md — per-course blog markdown

Embedding automation

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.

Contact form

A spam-hardened form on the homepage (components/ContactForm.tsx) that posts to app/api/contact/route.ts. Eight layers of defense:

  1. Honeypot field — silently 200s when bots fill the hidden website input.
  2. Min fill time — rejects sub-3 s submissions.
  3. Max fill time — rejects stale (>1 h) form sessions.
  4. URL-count cap — rejects messages with more than 2 links.
  5. Cloudflare Turnstile — server-side siteverify against the secret key.
  6. Per-IP rate limit — 3/10 min, 10/24 h (lib/rate-limit.ts).
  7. Global daily cap — 50/day total emails.
  8. Origin allowlistlib/api-security.ts rejects 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.

Interactive surface

A few details worth calling out:

  • ⌘K command palette (<CommandPalette /> in app/layout.tsx) — Cmd-K / Ctrl-K opens a search overlay anywhere on the site. Indexes every Page, Project, Trip, and Course blog live from lib/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 by IntersectionObserver. Fades out in place above the first section.
  • Konami code (<KonamiCode />) — ↑↑↓↓←→←→ b a triggers a six-second confetti overlay with a "Cheat unlocked" card.
  • Click-to-copy email (<CopyEmail />) below the contact form's social pills. Click hello@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 → 1fr modern CSS trick, with inert on the collapsed wrapper for a11y.

Local setup

Node 20+ recommended.

git clone https://github.com/MarcusPFF/portfolio.git
cd portfolio
npm install

Create .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 Nominatim

Project structure

app/
  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

Security & SEO

  • 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 dangerouslySetInnerHTML on 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/og ImageResponse.
  • Person JSON-LD in app/layout.tsx for structured data.
  • Skip-to-content link (sr-only until focused) for keyboard users.
  • inert used over aria-hidden for collapsed UI subtrees so focus + AT exposure are correctly removed.

Deployment

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.

About

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors