diff --git a/.gitignore b/.gitignore
index 75c36d8..56cab00 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,9 +12,14 @@ venv/
.env
backend/.env
backend/data/uploads/
+.vercel/
+node_modules/
# Editors and operating systems
.DS_Store
Thumbs.db
.idea/
.vscode/
+
+.vercel
+.env*
diff --git a/.python-version b/.python-version
new file mode 100644
index 0000000..e4fba21
--- /dev/null
+++ b/.python-version
@@ -0,0 +1 @@
+3.12
diff --git a/.vercelignore b/.vercelignore
new file mode 100644
index 0000000..7109bab
--- /dev/null
+++ b/.vercelignore
@@ -0,0 +1,20 @@
+.git
+.venv
+venv
+**/__pycache__
+**/*.pyc
+**/node_modules
+frontend/dist
+**/.env
+**/.env.*
+backend/data/uploads
+scripts
+backend/test_*.py
+backend/supabase
+backend/seed_*.py
+backend/connect_dropbox.py
+backend/migrate_media_dropbox.py
+resumes/*
+!resumes/resume-v5.pdf
+Alexander Herlan Resume 2024.md
+Alexander Herlan Resume 2024.pdf
diff --git a/README.md b/README.md
index 7bc5a04..b38d986 100644
--- a/README.md
+++ b/README.md
@@ -51,6 +51,8 @@ For content updates, edit the Markdown first and rerun the generator to create a
## Run locally
+The sun/moon control between the navigation and admin account button switches the entire website between light and dark themes. It follows the device preference until a visitor selects a mode, then saves that choice locally and synchronizes it across tabs. The theme applies before rendering, remains selected after sign-out, and also covers forms and admin dialogs. After building the frontend, run `.venv/Scripts/python.exe -m scripts.test_theme` for isolated browser checks of persistence, device preferences, keyboard interaction, mobile header layout, and unavailable browser storage (requires Playwright and Microsoft Edge).
+
Create and install the Python environment from the repository root:
```powershell
@@ -186,6 +188,80 @@ Edit the files in `backend/data` to update profile, career, and skill content. T
## Production build
+### Vercel: React and FastAPI in one project
+
+Production: **https://alex-herlan-portfolio.vercel.app**
+
+Vercel project: `storm-runners-projects/alex-herlan-portfolio`. Production
+Supabase, Dropbox, admin, and cron secrets are configured. SMTP is not yet
+configured. Deploy updates from this directory with `npx.cmd vercel --prod`.
+Automatic GitHub deployments need a GitHub login connection in Vercel followed
+by `npx.cmd vercel git connect` for `StormRunner06106/Alex_Portfolio`.
+
+Deploy from the **repository root**, not `frontend/` or `backend/`. The checked-in
+`vercel.json` builds React into `frontend/dist`, serves static files from Vercel's
+CDN, routes `/api/*` to `api/index.py`, and handles React deep links with
+`index.html`. Python 3.12 and Node.js 24 are selected in the root configuration.
+Leave `VITE_API_BASE_URL` unset so browser requests use the same origin.
+
+From a PowerShell terminal:
+
+```powershell
+npx.cmd vercel login
+npx.cmd vercel link
+```
+
+Choose the desired account/team, create or select the portfolio project, and use
+`.` as its root directory. Keep the framework preset as **Other**; the build,
+install, output, and routing settings are already in `vercel.json`.
+
+Before deployment, add these **server-only** environment variables to the Vercel
+project's Production environment (and Preview if preview deployments need them).
+Use the configured values from your local `backend/.env`; do not upload that file
+or prefix secrets with `VITE_`.
+
+| Variables | Purpose |
+| --- | --- |
+| `SUPABASE_URL`, `SUPABASE_SECRET_KEY`, `SUPABASE_ARTICLES_TABLE` | Persistent content and media registry |
+| `JOURNAL_ADMIN_PASSWORD`, `JOURNAL_TOKEN_SECRET` | Admin login and session signing |
+| `JOURNAL_MEDIA_STORAGE=dropbox` | Durable uploaded files |
+| `DROPBOX_APP_KEY`, `DROPBOX_APP_SECRET`, `DROPBOX_REFRESH_TOKEN` | Dropbox connection |
+| `CRON_SECRET` | A separate random secret of at least 32 characters for scheduled cleanup |
+| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_FROM_EMAIL`, `SMTP_USE_TLS`, `SMTP_USE_SSL`, `CONTACT_TO_EMAIL` | Contact email delivery; omitted SMTP settings leave the form unavailable |
+
+The CLI also accepts each value interactively, for example
+`npx.cmd vercel env add SUPABASE_SECRET_KEY production`. Management credentials
+such as `SUPABASE_ACCESS_TOKEN` and `SUPABASE_PROJECT_REF` are not runtime
+requirements and should remain local. The Supabase schema and portfolio seed
+must already be applied as described above; deployment does not overwrite data.
+Use separate storage and credentials for previews if preview edits should not
+affect production content.
+
+```powershell
+npx.cmd vercel --prod
+```
+
+After deployment, check `/`, a direct link such as `/experience`,
+`/api/health`, `/api/posts`, and `/api/resume`. Check admin sign-in and upload a
+small file before publishing new content. The project retains the selected
+`resumes/resume-v5.pdf`; local secrets, upload backups, and draft resumes are
+excluded from deployment by `.vercelignore`.
+
+Vercel uploads are limited to **4 MB per file** in both the UI and API, below
+[Vercel's 4.5 MB function payload limit](https://vercel.com/docs/functions/limitations#request-body-size).
+Previously stored larger Dropbox files download through short-lived redirects.
+Local development retains its 8 MB banner and 20 MB attachment limits. Publishing
+on Vercel requires Supabase, and uploads require Dropbox; the local filesystem
+fallback is for development. Failed media deletions stay in Supabase and are
+retried after article writes and by the daily production cron job (10:00 UTC).
+Set `CRON_SECRET` before enabling that job. Serverless instances do not start
+the continuous cleanup loop used by the local server.
+
+References: [Python functions](https://vercel.com/docs/functions/runtimes/python/api-directory)
+and [Vercel configuration](https://vercel.com/docs/project-configuration/vercel-json).
+
+### Conventional server
+
```powershell
cd frontend
npm run build
diff --git a/api/index.py b/api/index.py
new file mode 100644
index 0000000..05e65c5
--- /dev/null
+++ b/api/index.py
@@ -0,0 +1,4 @@
+"""Vercel ASGI entrypoint; the React build is served separately by the CDN."""
+from backend.main import app
+
+__all__ = ["app"]
diff --git a/backend/.env.example b/backend/.env.example
index b20564b..149bd14 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -17,6 +17,10 @@ FRONTEND_ORIGINS=http://localhost:5173
JOURNAL_ADMIN_PASSWORD=replace-with-a-strong-password
JOURNAL_TOKEN_SECRET=replace-with-a-long-random-secret
+# Vercel's daily media cleanup job authenticates with this server-only secret.
+# Generate a unique random value of at least 32 characters for production.
+CRON_SECRET=
+
# Persistent upload directory; defaults to backend/data/uploads.
# Mount durable storage here when deploying to a host with an ephemeral filesystem.
# JOURNAL_UPLOAD_DIR=/var/lib/portfolio/uploads
diff --git a/backend/dropbox_storage.py b/backend/dropbox_storage.py
index f874091..6f30d2b 100644
--- a/backend/dropbox_storage.py
+++ b/backend/dropbox_storage.py
@@ -40,6 +40,16 @@ def upload(data: bytes, upload_id: str, name: str) -> str:
raise HTTPException(502, "Dropbox could not store this file. Please retry.") from exc
+def temporary_link(file_id: str) -> str:
+ """Serve existing large files directly, outside the function payload limit."""
+ try:
+ return get_dropbox().files_get_temporary_link(file_id).link
+ except AuthError as exc:
+ raise HTTPException(503, "Dropbox needs to be reconnected by the site owner.") from exc
+ except (DropboxException, RequestException) as exc:
+ raise HTTPException(502, "This file is temporarily unavailable from Dropbox. Please retry.") from exc
+
+
def download(file_id: str, limit: int) -> bytes:
try:
metadata, response = get_dropbox().files_download(file_id)
diff --git a/backend/main.py b/backend/main.py
index 469955c..33d6233 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -22,7 +22,7 @@ from typing import Any, Literal
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, status
from PIL import Image, UnidentifiedImageError
from fastapi.middleware.cors import CORSMiddleware
-from fastapi.responses import FileResponse, Response
+from fastapi.responses import FileResponse, RedirectResponse, Response
from pydantic import BaseModel, EmailStr, Field
from starlette.concurrency import run_in_threadpool
from httpx import TransportError
@@ -44,6 +44,10 @@ MAX_UPLOAD_BYTES = 20 * 1024 * 1024
logger = logging.getLogger("uvicorn.error")
+def on_vercel() -> bool:
+ return os.getenv("VERCEL") == "1"
+
+
class ArticleMedia(BaseModel):
url: str = Field(pattern=r"^/api/uploads/[a-f0-9]{32}$")
name: str = Field(min_length=1, max_length=200)
@@ -193,6 +197,8 @@ def tiptap_plain_text(document: dict[str, Any]) -> str:
def journal_credentials() -> tuple[str, str]:
+ if on_vercel() and get_supabase() is None:
+ raise HTTPException(503, "Configure Supabase before publishing on Vercel.")
password = os.getenv("JOURNAL_ADMIN_PASSWORD", "")
token_secret = os.getenv("JOURNAL_TOKEN_SECRET", "")
if not password or not token_secret:
@@ -258,6 +264,15 @@ def health() -> dict[str, str]:
}
+@app.get("/api/cron/media-cleanup", include_in_schema=False)
+def scheduled_media_cleanup(authorization: str | None = Header(default=None)):
+ secret = os.getenv("CRON_SECRET", "")
+ if not secret or not hmac.compare_digest(authorization or "", f"Bearer {secret}"):
+ raise HTTPException(401, "Unauthorized")
+ media_cleanup.cleanup_pending()
+ return {"status": "completed"}
+
+
@app.get("/api/profile")
def profile() -> dict[str, Any]:
return read_data("profile.json")
@@ -344,6 +359,8 @@ def save_media_metadata(upload_id: str, metadata: dict[str, Any]) -> None:
def store_upload(data: bytes, upload_id: str, metadata: dict[str, Any]) -> dict[str, Any]:
storage = os.getenv("JOURNAL_MEDIA_STORAGE", "dropbox").strip().lower()
+ if on_vercel() and (storage != "dropbox" or get_supabase() is None):
+ raise HTTPException(503, "Vercel uploads require Dropbox and Supabase storage.")
if storage == "dropbox":
metadata = {**metadata, "storage": "dropbox", "dropbox_file_id": dropbox_storage.upload(data, upload_id, metadata["name"])}
save_media_metadata(upload_id, metadata)
@@ -364,6 +381,8 @@ async def upload_media(
_: None = Depends(require_admin),
) -> dict[str, Any]:
limit = 8 * 1024 * 1024 if purpose == "banner" else MAX_UPLOAD_BYTES
+ if on_vercel():
+ limit = min(limit, 4 * 1024 * 1024)
data = bytearray()
async for chunk in request.stream():
data.extend(chunk)
@@ -389,6 +408,9 @@ async def upload_media(
def download_media(upload_id: str):
media = uploaded_media(upload_id)
if media.get("storage") == "dropbox":
+ if on_vercel() and media["size"] > 4 * 1024 * 1024:
+ return RedirectResponse(dropbox_storage.temporary_link(media["dropbox_file_id"]),
+ status_code=307, headers={"Cache-Control": "no-store"})
data = dropbox_storage.download(media["dropbox_file_id"], MAX_UPLOAD_BYTES)
disposition = "inline" if media["media_type"].startswith("image/") else "attachment"
return Response(data, media_type=media["media_type"], headers={
@@ -656,7 +678,7 @@ async def contact(payload: ContactPayload) -> dict[str, str]:
return {"status": "accepted", "message": "Thanks — your note is on its way."}
-if FRONTEND_DIST.is_dir():
+if FRONTEND_DIST.is_dir() and not on_vercel():
@app.get("/{path:path}", include_in_schema=False)
def frontend(path: str) -> FileResponse:
diff --git a/backend/media_cleanup.py b/backend/media_cleanup.py
index 311bdf9..e4eed2e 100644
--- a/backend/media_cleanup.py
+++ b/backend/media_cleanup.py
@@ -130,6 +130,12 @@ def cleanup_pending():
@asynccontextmanager
async def lifespan(app):
+ from backend import main
+ if main.on_vercel():
+ # Serverless instances can stop between requests. Cron retries the
+ # durable queue; article mutations still attempt cleanup immediately.
+ yield
+ return
async def retry():
while True:
await run_in_threadpool(cleanup_pending)
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 0f85bab..3c8d7e7 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -1,7 +1 @@
-fastapi
-httpx
-dropbox==12.2.1
-Pillow
-uvicorn[standard]
-email-validator
-supabase==2.31.0
+-r ../requirements.txt
diff --git a/backend/test_vercel.py b/backend/test_vercel.py
new file mode 100644
index 0000000..095930a
--- /dev/null
+++ b/backend/test_vercel.py
@@ -0,0 +1,72 @@
+"""Serverless persistence, payload limits, and scheduled maintenance checks."""
+import os
+import unittest
+from unittest.mock import MagicMock, patch
+
+from fastapi import HTTPException
+from fastapi.testclient import TestClient
+
+from backend import main
+
+
+class VercelTests(unittest.TestCase):
+ def setUp(self):
+ self.client = TestClient(main.app)
+ environment = patch.dict(os.environ, {"VERCEL": "1", "CRON_SECRET": "test-cron-secret"})
+ environment.start()
+ self.addCleanup(environment.stop)
+
+ def test_no_publishing_without_durable_database(self):
+ with patch.object(main, "get_supabase", return_value=None):
+ response = self.client.post("/api/auth/login", json={"password": "example"})
+ self.assertEqual(response.status_code, 503)
+
+ def test_no_local_uploads_even_with_database(self):
+ with patch.dict(os.environ, {"JOURNAL_MEDIA_STORAGE": "local"}), \
+ patch.object(main, "get_supabase", return_value=MagicMock()), \
+ patch.object(main.Path, "mkdir") as mkdir:
+ with self.assertRaises(HTTPException) as error:
+ main.store_upload(b"file", "a" * 32, {"name": "file.txt"})
+ self.assertEqual(error.exception.status_code, 503)
+ mkdir.assert_not_called()
+
+ def test_oversized_upload_is_rejected_before_storage(self):
+ main.app.dependency_overrides[main.require_admin] = lambda: None
+ try:
+ with patch.object(main, "store_upload") as store:
+ response = self.client.post("/api/uploads?name=large.txt",
+ content=b"x" * (4 * 1024 * 1024 + 1))
+ self.assertEqual(response.status_code, 413)
+ store.assert_not_called()
+ finally:
+ main.app.dependency_overrides.pop(main.require_admin, None)
+
+ def test_large_existing_download_bypasses_function_payload(self):
+ media = {"storage": "dropbox", "size": 5 * 1024 * 1024, "dropbox_file_id": "id:abc"}
+ with patch.object(main, "uploaded_media", return_value=media), \
+ patch.object(main.dropbox_storage, "temporary_link", return_value="https://example.com/file"), \
+ patch.object(main.dropbox_storage, "download") as download:
+ response = self.client.get("/api/uploads/" + "a" * 32, follow_redirects=False)
+ self.assertEqual(response.status_code, 307)
+ self.assertEqual(response.headers["cache-control"], "no-store")
+ download.assert_not_called()
+
+ def test_cron_requires_secret_and_runs_cleanup(self):
+ with patch.object(main.media_cleanup, "cleanup_pending") as cleanup:
+ for headers in ({}, {"Authorization": "Bearer wrong"}):
+ self.assertEqual(self.client.get("/api/cron/media-cleanup", headers=headers).status_code, 401)
+ cleanup.assert_not_called()
+ response = self.client.get("/api/cron/media-cleanup",
+ headers={"Authorization": "Bearer test-cron-secret"})
+ self.assertEqual(response.status_code, 200)
+ cleanup.assert_called_once()
+
+ def test_vercel_lifespan_does_not_start_background_loop(self):
+ with patch.object(main.media_cleanup.asyncio, "create_task") as create_task:
+ with TestClient(main.app) as client:
+ self.assertEqual(client.get("/api/profile").status_code, 200)
+ create_task.assert_not_called()
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/frontend/index.html b/frontend/index.html
index 5969a4a..5046cec 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -8,6 +8,7 @@
content="Alex Herlan is a senior software engineer building full-stack products, applied AI, and dependable cloud systems."
/>
+
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index e020e3a..2351d64 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -22,7 +22,6 @@
},
"devDependencies": {
"@vitejs/plugin-react": "^6.1.1",
- "lightningcss-win32-x64-msvc": "^1.33.0",
"vite": "^8.3.0"
}
},
@@ -1242,6 +1241,7 @@
],
"dev": true,
"license": "MPL-2.0",
+ "optional": true,
"os": [
"win32"
],
diff --git a/frontend/package.json b/frontend/package.json
index 0c1faab..4999d74 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -23,7 +23,6 @@
},
"devDependencies": {
"@vitejs/plugin-react": "^6.1.1",
- "lightningcss-win32-x64-msvc": "^1.33.0",
"vite": "^8.3.0"
}
}
diff --git a/frontend/public/theme-init.js b/frontend/public/theme-init.js
new file mode 100644
index 0000000..3b9fdd5
--- /dev/null
+++ b/frontend/public/theme-init.js
@@ -0,0 +1,15 @@
+// Apply before the stylesheet loads to avoid a light flash on dark-mode visits.
+(() => {
+ let preference;
+ try {
+ preference = localStorage.getItem("portfolio-theme");
+ } catch {
+ // Theme selection still works when browser storage is unavailable.
+ }
+ const theme = preference === "light" || preference === "dark"
+ ? preference
+ : window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
+ document.documentElement.dataset.theme = theme;
+ document.documentElement.style.colorScheme = theme;
+ document.querySelector('meta[name="theme-color"]')?.setAttribute("content", theme === "dark" ? "#141d19" : "#f7f8f4");
+})();
diff --git a/frontend/src/components/ArticleUploads.jsx b/frontend/src/components/ArticleUploads.jsx
index fa61b9d..1ee1ea3 100644
--- a/frontend/src/components/ArticleUploads.jsx
+++ b/frontend/src/components/ArticleUploads.jsx
@@ -4,6 +4,8 @@ import Icon from "./Icon";
import AttachmentList from "./AttachmentList";
const imageTypes = ["image/jpeg", "image/png", "image/webp", "image/gif"];
+const maxUploadMB = Number(import.meta.env.VITE_MAX_UPLOAD_MB ?? 20);
+const uploadLimitMB = (purpose) => Math.min(purpose === "banner" ? 8 : 20, maxUploadMB);
const isImage = (file) => imageTypes.includes(file.type) || (!file.type && /\.(jpe?g|png|webp|gif)$/i.test(file.name));
const fileSize = (size) => size < 1024 * 1024 ? `${Math.max(1, Math.round(size / 1024))} KB` : `${(size / 1024 / 1024).toFixed(1)} MB`;
@@ -27,7 +29,7 @@ function Dropzone({ purpose, disabled, onFiles, hasBanner }) {
- {banner ? "JPG, PNG, WebP or GIF · Up to 8 MB" : "Up to 10 files · 20 MB each"}
+ {banner ? `JPG, PNG, WebP or GIF · Up to ${uploadLimitMB(purpose)} MB` : `Up to 10 files · ${uploadLimitMB(purpose)} MB each`}
task.purpose === "attachment").map((task) => task.file)];
for (const file of files) {
- const limit = (purpose === "banner" ? 8 : 20) * 1024 * 1024;
+ const limit = uploadLimitMB(purpose) * 1024 * 1024;
if (!file.size) { errors.push(`${file.name}: this file is empty.`); continue; }
- if (file.size > limit) { errors.push(`${file.name}: exceeds the ${purpose === "banner" ? 8 : 20} MB limit.`); continue; }
+ if (file.size > limit) { errors.push(`${file.name}: exceeds the ${uploadLimitMB(purpose)} MB limit.`); continue; }
if (purpose === "banner" && !isImage(file)) { errors.push(`${file.name}: choose a JPG, PNG, WebP, or GIF image.`); continue; }
if (purpose === "attachment" && existing.some((item) => item.name === file.name && item.size === file.size)) {
errors.push(`${file.name}: already added.`); continue;
diff --git a/frontend/src/components/Icon.jsx b/frontend/src/components/Icon.jsx
index 2d3947c..aae4c5e 100644
--- a/frontend/src/components/Icon.jsx
+++ b/frontend/src/components/Icon.jsx
@@ -1,4 +1,6 @@
const paths = {
+ sun: <>>,
+ moon: ,
grip: ,
upload: ,
photo: <>>,
diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx
index e03b07c..9b78a0c 100644
--- a/frontend/src/components/Layout.jsx
+++ b/frontend/src/components/Layout.jsx
@@ -2,6 +2,7 @@ import { useEffect, useId, useState } from "react";
import { NavLink, useLocation } from "react-router-dom";
import Icon from "./Icon";
import { useAdmin } from "./AdminSession";
+import ThemeToggle from "./ThemeToggle";
const navItems = [
{ label: "About", to: "/" },
@@ -55,6 +56,7 @@ function Header({ name }) {
+