Project README

Rendered from README.md

Last updated: 10/20/2018, 1:46:40 AM

Ryan Doering — Portfolio Website

A personal portfolio website built with Next.js 16 (App Router), TypeScript, React 19, and Tailwind CSS v4. The site is deployed to Vercel and serves as the primary online presence I share with prospective employers and collaborators. It features a project showcase, a rotating photo gallery, an announcements carousel, a live calendar integration, a freelance inquiry form, and a protected admin dashboard for managing all site content without redeploying.

Live site: https://ryandoering.com


Table of Contents


Project Overview

This site was built as both a professional portfolio and a real production application — it's the same stack and infrastructure I'd use for a client project, just pointed at my own content.

The goals were:

  • Performance-first — static-first Next.js pages with server-side data fetching, image preloading, and a minimal JavaScript footprint on the client.
  • No-redeploy content updates — an authenticated admin UI lets me publish new projects, upload media, and post announcements from the browser. Content is persisted in Supabase (projects and announcements) and Vercel Blob (media), so changes are live instantly.
  • Clean, maintainable code — TypeScript throughout, clear separation between server and client components, and isolated server-side helpers in src/lib/.

Features

Home Page (/)

The homepage acts as the entry point for visitors and recruiters. It contains:

  • Hero section — name, title, and avatar.
  • Rotating project gallery — a client-side carousel that automatically cycles through portfolio projects every 15 seconds. Each card shows the project's thumbnail and title, and clicking it navigates to the full project detail page. On page load the server pre-embeds the project list as a JSON <script> tag so the gallery hydrates instantly without an extra API round-trip. The server also injects <link rel="preload"> tags for each project thumbnail and a preconnect hint to the Vercel Blob CDN host.
  • External links card — a companion card matching the gallery's dimensions, with buttons linking to GitHub, LinkedIn, and email.
  • Announcements carousel — fetches announcements from /api/announcements, displays the most recent, and auto-advances every 8 seconds. Supports keyboard navigation (left/right arrows) and manual prev/next buttons.

Projects (/projects and /projects/[slug])

  • Projects index — a responsive two-column grid of ProjectCard components, each showing the project title, summary, tech-stack chips, and thumbnail. Cards are fetched server-side and sorted by an importance field.
  • Project detail page — dynamically routed at /projects/[slug]. Displays the full description, a media gallery (images and videos), tech-stack tags, and external links (GitHub, live demo, etc.). Video sources include a WebM fallback if a matching file is found in public/. Image captions are derived from filenames automatically or from explicit captions metadata.

About (/about and /about/gallery)

  • About page — personal bio covering background, interests, and what I'm looking for in my next role. Includes an inline photo gallery powered by PhotoGalleryServer (a server component that reads the photo manifest) and PhotoGridClient (the client-side rotating/grid display).
  • Gallery subpage — full-page view of processed photos and videos from the /about/gallery route.

Resume (/resume)

  • Embedded PDF viewer using an <object> tag with an 80 vh height, so the resume is fully readable in-browser.
  • "Open in new tab" and "Download PDF" buttons for maximum compatibility across browsers and devices.

Calendar (/calendar and /calendar/event/[id])

  • Fetches events from a private iCloud calendar via a secret .ics URL configured in environment variables.
  • The server-side helper (src/lib/calendar.ts) parses the .ics feed with the ical package, expands recurring events using their RRULE, and returns only events within the next 7 days.
  • Results are cached in memory for 5 minutes to avoid hammering the calendar feed on every request.
  • Individual events are accessible at /calendar/event/[id] showing full details including description, location, and time.

Freelance (/freelance)

  • A dedicated page describing freelance web design and development services — landing pages, e-commerce, performance audits, and full deployments.
  • Includes a contact form (ContactForm) that collects name, email, project type, budget range, and a message, then posts to /api/contact.
  • The API route saves every submission to a Supabase contact_submissions table for a durable backup record, then forwards the inquiry via the SendGrid API if SENDGRID_API_KEY is set.
  • A per-IP rate limiter (configurable window via RATE_LIMIT_WINDOW env var, defaulting to 60 s) protects the endpoint from spam. Rate limiting respects x-forwarded-for, x-real-ip, and cf-connecting-ip headers so it works correctly behind Vercel's proxy layer.

Admin Dashboard (/admin)

A protected content management UI for updating the site without redeploying. In production, access requires signing in with a GitHub account whose email is in the ADMIN_EMAILS environment variable. Authentication is handled by NextAuth.js with the GitHub OAuth provider.

The dashboard has three tabbed sections:

  1. Media tab

    • Upload sub-tab — drag or browse to upload images. Files are streamed server-side to Vercel Blob via @vercel/blob and the resulting public URLs are saved to the Supabase photos table. The manifest drives both the homepage gallery and the about page gallery.
    • Delete gallery sub-tab — view all uploaded photos and delete individual ones from both Vercel Blob and the Supabase manifest atomically.
  2. Projects tab

    • Create sub-tab — form to add a new project: title, slug, summary, full description, tech-stack tags, image URLs, optional captions, external links, and an importance value. On submit, the new project is written to Supabase (projects_kv table) and immediately visible on the site.
    • Edit sub-tab — load any existing project by selecting it from a dropdown, edit any field inline, and save or delete it.
  3. Announcements tab

    • Form to publish a new announcement: title, body text, and an optional image. Records are inserted into Supabase's announcements table and appear in the homepage carousel within seconds.

API Routes

All API routes live under src/app/api/ and run as Node.js serverless functions on Vercel.

Route Method Purpose
/api/announcements GET Returns all announcements (Supabase → local file fallback)
/api/admin/announcements POST Create a new announcement (auth-gated)
/api/admin/projects GET / POST / PUT / DELETE CRUD for projects (auth-gated)
/api/admin/media POST Upload media to Vercel Blob (auth-gated)
/api/admin/media/migrate POST Migrate local media references to Blob URLs
/api/calendar GET Fetch and return upcoming calendar events
/api/contact POST Handle freelance inquiry form submissions
/api/auth/[...nextauth] GET / POST NextAuth.js handler (GitHub OAuth)
/api/blob various Direct blob helpers

Tech Stack

Layer Technology
Framework Next.js 16 (App Router)
Language TypeScript 5
UI React 19, Tailwind CSS v4
Fonts Geist Sans & Geist Mono (Google Fonts)
Database Supabase (PostgreSQL)
Media storage Vercel Blob
Auth NextAuth.js v4 — GitHub OAuth provider
Calendar ical package — parses iCal/.ics feeds
Email SendGrid REST API
Markdown marked (used in project descriptions)
Date utilities date-fns
UUIDs uuid
Hosting Vercel (serverless functions + Edge CDN)
Image processing sharp (build-time script)

Repository Layout

.
├── data/                        # Runtime-generated JSON data files (gitignored in prod)
│   ├── photos.json              # Manifest of uploaded gallery photos
│   └── projects.json            # Local project data fallback
├── public/                      # Static assets served at the root
│   ├── avatar.png               # Homepage avatar image
│   ├── headshot.jpg             # Photo used on the about page
│   ├── resume.pdf               # Downloadable resume
│   ├── *.svg                    # Icons (GitHub, LinkedIn, mail, etc.)
│   └── favicon.*                # Favicons for all platforms
├── scripts/
│   └── process-photos.js        # Sharp-based photo resizing and optimization script
├── src/
│   ├── app/                     # Next.js App Router pages and API routes
│   │   ├── layout.tsx           # Root layout — NavBar, fonts, metadata
│   │   ├── page.tsx             # Home page
│   │   ├── about/page.tsx       # About / bio page
│   │   ├── about/gallery/       # Full photo gallery subpage
│   │   ├── projects/page.tsx    # Projects index
│   │   ├── projects/[slug]/     # Dynamic project detail pages
│   │   ├── resume/page.tsx      # Resume viewer
│   │   ├── calendar/page.tsx    # Calendar listing
│   │   ├── freelance/page.tsx   # Freelance services + contact form
│   │   ├── admin/page.tsx       # Admin dashboard
│   │   ├── admin/login/page.tsx # Sign-in page
│   │   └── api/                 # Serverless API routes (see API Routes table above)
│   ├── components/              # Reusable React components
│   │   ├── NavBar.tsx           # Primary navigation with dropdown menus
│   │   ├── RotatingGallery.tsx  # Auto-cycling project gallery card
│   │   ├── AnnouncementCard.tsx # Auto-advancing announcements carousel
│   │   ├── ProjectCard.tsx      # Project summary card for the index page
│   │   ├── PhotoGalleryServer.tsx  # Server component: reads photo manifest
│   │   ├── PhotoGridClient.tsx     # Client component: renders/animates photos
│   │   ├── ExternalLinksCard.tsx   # GitHub / LinkedIn / email link card
│   │   ├── ContactForm.tsx         # Freelance inquiry form
│   │   ├── CalendarEvent.tsx       # Calendar event display component
│   │   ├── AdminContentManager.tsx # Tabbed admin UI (projects/media/announcements)
│   │   ├── AdminMediaManager.tsx   # Media upload UI
│   │   ├── AdminDeleteGallery.tsx  # Media deletion UI
│   │   ├── MeetingRequestForm.tsx  # Meeting / scheduling request form
│   │   ├── SignInButton.tsx        # NextAuth sign-in trigger
│   │   └── SignOutButton.tsx       # NextAuth sign-out trigger
│   ├── content/
│   │   └── projects.ts          # Project type definition and static seed data
│   ├── lib/                     # Server-side helpers
│   │   ├── announcementsStore.ts   # Read/write announcements (Supabase-first, local file fallback)
│   │   ├── projectsStore.ts        # Supabase-first project CRUD: upsertProject, deleteProject, getAllProjects
│   │   ├── projectsDBStore.ts      # Supabase-backed read/write used by the migration utility
│   │   ├── photosStore.ts          # Supabase-first photo manifest: insertPhoto, deletePhoto, readPhotos
│   │   ├── auth.ts                 # isAdmin() helper — checks session email against allowlist
│   │   ├── authOptions.ts          # NextAuth configuration (GitHub provider)
│   │   ├── calendar.ts             # iCal fetch, parse, and 5-minute in-memory cache
│   │   └── rateLimit.ts            # In-memory per-IP rate limiter
│   └── types/
│       ├── ical.d.ts            # Type declarations for the `ical` package
│       └── jsx.d.ts             # JSX ambient type patches
├── next.config.ts               # Next.js config — remote image patterns for Vercel Blob
├── tsconfig.json                # TypeScript compiler config
├── eslint.config.mjs            # ESLint config
└── postcss.config.mjs           # PostCSS / Tailwind config

Environment Variables

The following environment variables are set in the Vercel project dashboard and are never committed to the repository.

Variable Required Description
SUPABASE_URL Yes Supabase project REST URL
SUPABASE_SERVICE_ROLE_KEY Yes Supabase service-role key (server-side writes)
SUPABASE_ANON_KEY Optional Supabase anon key (read-only fallback)
BLOB_READ_WRITE_TOKEN Yes Vercel Blob read/write token for media uploads
GITHUB_ID Yes GitHub OAuth App client ID (for admin auth)
GITHUB_SECRET Yes GitHub OAuth App client secret
NEXTAUTH_SECRET Yes Random secret used to sign NextAuth session tokens
NEXTAUTH_URL Yes Canonical URL of the site (https://ryandoering.com)
ADMIN_EMAILS Yes Comma-separated list of email addresses granted admin access
ICLOUD_CALENDAR_URL Yes Secret iCloud .ics feed URL for calendar events
SENDGRID_API_KEY Yes SendGrid API key for forwarding contact form submissions
CONTACT_EMAIL Optional Destination email for contact form messages
SENDER_EMAIL Optional Sender address used in SendGrid requests
RATE_LIMIT_WINDOW Optional Contact form rate-limit window in seconds (default: 60)

Production Build & Deployment

The site is deployed automatically via Vercel's GitHub integration. Every push to the main branch triggers a new production deployment. Preview deployments are created for all other branches.

Build command: next build
Output: A hybrid of statically pre-rendered pages and Node.js serverless functions (one per API route).

Vercel handles:

  • Edge CDN caching for static assets and pages
  • Serverless function cold-start optimisation
  • Automatic HTTPS and domain routing for ryandoering.com
  • Environment variable injection at build and runtime

The next.config.ts file whitelists Vercel Blob CDN hostnames (**.public.blob.vercel-storage.com, **.vercel-storage.com) so Next.js's <Image> component can optimise remotely hosted photos.


Database Migrations (Supabase)

The site uses four Supabase tables:

announcements

Stores site announcements shown in the homepage carousel.

create table announcements (
  id          text primary key,
  title       text not null,
  body        text not null,
  image       text,
  created_at  timestamptz default now()
);

projects_kv

Stores project data. Each project is saved as an individual row where id is the project slug and payload is the full project object.

create table projects_kv (
  id      text primary key,
  payload jsonb not null
);

photos

Stores the photo manifest for the gallery. Each row is one uploaded photo URL.

create table photos (
  url        text primary key,
  created_at timestamptz default now()
);

contact_submissions

Durable log of every freelance inquiry submitted via the contact form.

create table contact_submissions (
  id           bigint generated always as identity primary key,
  ts           timestamptz default now(),
  name         text,
  email        text,
  project_type text,
  budget       text,
  message      text
);

All tables use Row Level Security (RLS) with the service-role key for server-side writes. Every store (announcementsStore.ts, projectsStore.ts, photosStore.ts) follows a Supabase-first pattern and falls back to reading from local JSON files in data/ if the database is unreachable, so the site keeps serving content even during a Supabase outage.


Media / Blob Storage

All gallery images and project media are stored in Vercel Blob — a CDN-backed object store. The upload flow is:

  1. Admin selects files in the Media tab of the admin dashboard.
  2. The browser sends a multipart/form-data POST to /api/admin/media.
  3. The API route streams each file directly to Vercel Blob via the @vercel/blob SDK (put(key, stream, { access: 'public' })).
  4. The resulting public CDN URL is saved to the Supabase photos table via insertPhoto(), so it appears immediately in the gallery.

Blob naming convention: Files are stored under the photos_processed/ prefix with a Unix timestamp prepended to the original filename to prevent collisions: photos_processed/1700000000000-my-photo.jpg.

Deletion is handled by /api/admin/media DELETE requests, which call del() from the Vercel Blob SDK and call deletePhoto() to remove the entry from Supabase simultaneously.

Remote image patternsnext.config.ts explicitly whitelists Vercel Blob hostnames so the Next.js <Image> optimisation pipeline can safely fetch and transform blob-hosted images.


Admin Area & Content Management

The admin dashboard at /admin is the primary interface for keeping the site up to date after initial deployment.

Authentication

Production access requires signing in via GitHub OAuth (NextAuth.js). After signing in, the session email is checked against the ADMIN_EMAILS environment variable. Any email not in that list is rejected and redirected back to the sign-in page. The isAdmin() helper in src/lib/auth.ts performs this check on every protected page and API route.

Content Manager tabs

Media

  • Upload — multi-file uploader. Images are streamed to Vercel Blob server-side. Supports common formats: JPEG, PNG, WebP, AVIF, SVG, MP4, WebM.
  • Delete gallery — renders all current photos from the Supabase photos table with a delete button on each. Deletion hits the server, removes the blob from Vercel, and deletes the row from Supabase atomically.

Projects

  • Create — fill in all project fields (title, slug, summary, description, tech tags, images, captions, links, importance score) and submit. The new project is upserted into Supabase and immediately live.
  • Edit — select an existing project from a dropdown to load all its fields into the editor. Make changes and save, or delete the project entirely with a confirmation prompt.

Announcements

  • Publish short update posts with a title, body text, and optional image URL. Announcements are inserted into Supabase and show up in the homepage carousel on the next page load.

Data persistence hierarchy

Every store follows the same pattern: Supabase first, local file fallback. This means:

  • In production with Supabase configured, all reads and writes go to the database.
  • If the database is unreachable (e.g. during a Supabase outage), the site continues to serve the last-known data from local JSON files in data/ (reads only — writes require Supabase in production, since Vercel's serverless filesystem is read-only).
  • This prevents a complete site outage due to a third-party service being temporarily unavailable.

Photo Processing

A Node.js script at scripts/process-photos.js handles batch photo optimization using the sharp library. Run it with:

npm run photos:process

The script:

  1. Reads source images from a configurable input directory.
  2. Resizes images to a maximum dimension (width and height capped) while preserving aspect ratio.
  3. Converts to optimized JPEG or WebP depending on the source format.
  4. Writes the processed files to public/photos/ so they are available at static URLs.
  5. Optionally generates multiple sizes for responsive srcset usage.

Processed photos in public/photos/ are then uploaded to Vercel Blob via the admin UI to make them available on the CDN, or referenced directly via their /photos/... public path for the about page gallery.

Video files (.mp4) are handled separately — the project detail page checks for a matching .webm file at the same path and includes it as a <source> fallback in the <video> element for broader browser compatibility.


Contact: ryan.w.doering@gmail.com · LinkedIn · GitHub