Configure Vercel deployment and preserve deployed theme support

This commit is contained in:
StormRunner06106
2026-09-13 14:56:10 -07:00
parent d52495945b
commit cb8d062481
27 changed files with 610 additions and 60 deletions
+5
View File
@@ -12,9 +12,14 @@ venv/
.env .env
backend/.env backend/.env
backend/data/uploads/ backend/data/uploads/
.vercel/
node_modules/
# Editors and operating systems # Editors and operating systems
.DS_Store .DS_Store
Thumbs.db Thumbs.db
.idea/ .idea/
.vscode/ .vscode/
.vercel
.env*
+1
View File
@@ -0,0 +1 @@
3.12
+20
View File
@@ -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
+76
View File
@@ -51,6 +51,8 @@ For content updates, edit the Markdown first and rerun the generator to create a
## Run locally ## 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: Create and install the Python environment from the repository root:
```powershell ```powershell
@@ -186,6 +188,80 @@ Edit the files in `backend/data` to update profile, career, and skill content. T
## Production build ## 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 ```powershell
cd frontend cd frontend
npm run build npm run build
+4
View File
@@ -0,0 +1,4 @@
"""Vercel ASGI entrypoint; the React build is served separately by the CDN."""
from backend.main import app
__all__ = ["app"]
+4
View File
@@ -17,6 +17,10 @@ FRONTEND_ORIGINS=http://localhost:5173
JOURNAL_ADMIN_PASSWORD=replace-with-a-strong-password JOURNAL_ADMIN_PASSWORD=replace-with-a-strong-password
JOURNAL_TOKEN_SECRET=replace-with-a-long-random-secret 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. # Persistent upload directory; defaults to backend/data/uploads.
# Mount durable storage here when deploying to a host with an ephemeral filesystem. # Mount durable storage here when deploying to a host with an ephemeral filesystem.
# JOURNAL_UPLOAD_DIR=/var/lib/portfolio/uploads # JOURNAL_UPLOAD_DIR=/var/lib/portfolio/uploads
+10
View File
@@ -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 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: def download(file_id: str, limit: int) -> bytes:
try: try:
metadata, response = get_dropbox().files_download(file_id) metadata, response = get_dropbox().files_download(file_id)
+24 -2
View File
@@ -22,7 +22,7 @@ from typing import Any, Literal
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, status from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, status
from PIL import Image, UnidentifiedImageError from PIL import Image, UnidentifiedImageError
from fastapi.middleware.cors import CORSMiddleware 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 pydantic import BaseModel, EmailStr, Field
from starlette.concurrency import run_in_threadpool from starlette.concurrency import run_in_threadpool
from httpx import TransportError from httpx import TransportError
@@ -44,6 +44,10 @@ MAX_UPLOAD_BYTES = 20 * 1024 * 1024
logger = logging.getLogger("uvicorn.error") logger = logging.getLogger("uvicorn.error")
def on_vercel() -> bool:
return os.getenv("VERCEL") == "1"
class ArticleMedia(BaseModel): class ArticleMedia(BaseModel):
url: str = Field(pattern=r"^/api/uploads/[a-f0-9]{32}$") url: str = Field(pattern=r"^/api/uploads/[a-f0-9]{32}$")
name: str = Field(min_length=1, max_length=200) 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]: 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", "") password = os.getenv("JOURNAL_ADMIN_PASSWORD", "")
token_secret = os.getenv("JOURNAL_TOKEN_SECRET", "") token_secret = os.getenv("JOURNAL_TOKEN_SECRET", "")
if not password or not 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") @app.get("/api/profile")
def profile() -> dict[str, Any]: def profile() -> dict[str, Any]:
return read_data("profile.json") 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]: def store_upload(data: bytes, upload_id: str, metadata: dict[str, Any]) -> dict[str, Any]:
storage = os.getenv("JOURNAL_MEDIA_STORAGE", "dropbox").strip().lower() 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": if storage == "dropbox":
metadata = {**metadata, "storage": "dropbox", "dropbox_file_id": dropbox_storage.upload(data, upload_id, metadata["name"])} metadata = {**metadata, "storage": "dropbox", "dropbox_file_id": dropbox_storage.upload(data, upload_id, metadata["name"])}
save_media_metadata(upload_id, metadata) save_media_metadata(upload_id, metadata)
@@ -364,6 +381,8 @@ async def upload_media(
_: None = Depends(require_admin), _: None = Depends(require_admin),
) -> dict[str, Any]: ) -> dict[str, Any]:
limit = 8 * 1024 * 1024 if purpose == "banner" else MAX_UPLOAD_BYTES limit = 8 * 1024 * 1024 if purpose == "banner" else MAX_UPLOAD_BYTES
if on_vercel():
limit = min(limit, 4 * 1024 * 1024)
data = bytearray() data = bytearray()
async for chunk in request.stream(): async for chunk in request.stream():
data.extend(chunk) data.extend(chunk)
@@ -389,6 +408,9 @@ async def upload_media(
def download_media(upload_id: str): def download_media(upload_id: str):
media = uploaded_media(upload_id) media = uploaded_media(upload_id)
if media.get("storage") == "dropbox": 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) data = dropbox_storage.download(media["dropbox_file_id"], MAX_UPLOAD_BYTES)
disposition = "inline" if media["media_type"].startswith("image/") else "attachment" disposition = "inline" if media["media_type"].startswith("image/") else "attachment"
return Response(data, media_type=media["media_type"], headers={ 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."} 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) @app.get("/{path:path}", include_in_schema=False)
def frontend(path: str) -> FileResponse: def frontend(path: str) -> FileResponse:
+6
View File
@@ -130,6 +130,12 @@ def cleanup_pending():
@asynccontextmanager @asynccontextmanager
async def lifespan(app): 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(): async def retry():
while True: while True:
await run_in_threadpool(cleanup_pending) await run_in_threadpool(cleanup_pending)
+1 -7
View File
@@ -1,7 +1 @@
fastapi -r ../requirements.txt
httpx
dropbox==12.2.1
Pillow
uvicorn[standard]
email-validator
supabase==2.31.0
+72
View File
@@ -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()
+1
View File
@@ -8,6 +8,7 @@
content="Alex Herlan is a senior software engineer building full-stack products, applied AI, and dependable cloud systems." content="Alex Herlan is a senior software engineer building full-stack products, applied AI, and dependable cloud systems."
/> />
<meta name="theme-color" content="#f7f8f4" /> <meta name="theme-color" content="#f7f8f4" />
<script src="/theme-init.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet" />
+1 -1
View File
@@ -22,7 +22,6 @@
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "^6.1.1", "@vitejs/plugin-react": "^6.1.1",
"lightningcss-win32-x64-msvc": "^1.33.0",
"vite": "^8.3.0" "vite": "^8.3.0"
} }
}, },
@@ -1242,6 +1241,7 @@
], ],
"dev": true, "dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true,
"os": [ "os": [
"win32" "win32"
], ],
-1
View File
@@ -23,7 +23,6 @@
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "^6.1.1", "@vitejs/plugin-react": "^6.1.1",
"lightningcss-win32-x64-msvc": "^1.33.0",
"vite": "^8.3.0" "vite": "^8.3.0"
} }
} }
+15
View File
@@ -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");
})();
+5 -3
View File
@@ -4,6 +4,8 @@ import Icon from "./Icon";
import AttachmentList from "./AttachmentList"; import AttachmentList from "./AttachmentList";
const imageTypes = ["image/jpeg", "image/png", "image/webp", "image/gif"]; 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 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`; 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 }) {
<button type="button" className="upload-browse" disabled={disabled} aria-describedby={hintId} onClick={() => input.current.click()}> <button type="button" className="upload-browse" disabled={disabled} aria-describedby={hintId} onClick={() => input.current.click()}>
<Icon name="plus" size={16} /> {banner ? hasBanner ? "Replace banner" : "Choose banner" : "Browse files"} <Icon name="plus" size={16} /> {banner ? hasBanner ? "Replace banner" : "Choose banner" : "Browse files"}
</button> </button>
<small id={hintId}>{banner ? "JPG, PNG, WebP or GIF · Up to 8 MB" : "Up to 10 files · 20 MB each"}</small> <small id={hintId}>{banner ? `JPG, PNG, WebP or GIF · Up to ${uploadLimitMB(purpose)} MB` : `Up to 10 files · ${uploadLimitMB(purpose)} MB each`}</small>
<input ref={input} className="upload-input" type="file" tabIndex={-1} <input ref={input} className="upload-input" type="file" tabIndex={-1}
aria-label={banner ? "Banner image" : "Additional photos & files"} aria-label={banner ? "Banner image" : "Additional photos & files"}
accept={banner ? "image/jpeg,image/png,image/webp,image/gif" : undefined} accept={banner ? "image/jpeg,image/png,image/webp,image/gif" : undefined}
@@ -106,9 +108,9 @@ export default function ArticleUploads({ banner, attachments, busy, title, subti
const additions = []; const additions = [];
const existing = [...attachments, ...queue.current.filter((task) => task.purpose === "attachment").map((task) => task.file)]; const existing = [...attachments, ...queue.current.filter((task) => task.purpose === "attachment").map((task) => task.file)];
for (const file of files) { 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) { 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 === "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)) { if (purpose === "attachment" && existing.some((item) => item.name === file.name && item.size === file.size)) {
errors.push(`${file.name}: already added.`); continue; errors.push(`${file.name}: already added.`); continue;
+2
View File
@@ -1,4 +1,6 @@
const paths = { const paths = {
sun: <><circle cx="12" cy="12" r="4" /><path d="M12 2v2m0 16v2M2 12h2m16 0h2M4.93 4.93l1.42 1.42m11.3 11.3 1.42 1.42M4.93 19.07l1.42-1.42m11.3-11.3 1.42-1.42" /></>,
moon: <path d="M20.9 13.1A9 9 0 0 1 10.9 3.1a9 9 0 1 0 10 10Z" />,
grip: <path d="M9 5h.01M15 5h.01M9 12h.01M15 12h.01M9 19h.01M15 19h.01" strokeWidth="3" />, grip: <path d="M9 5h.01M15 5h.01M9 12h.01M15 12h.01M9 19h.01M15 19h.01" strokeWidth="3" />,
upload: <path d="M12 16V3m-5 5 5-5 5 5M4 16v5h16v-5" />, upload: <path d="M12 16V3m-5 5 5-5 5 5M4 16v5h16v-5" />,
photo: <><rect x="3" y="3" width="18" height="18" rx="3" /><circle cx="8" cy="8" r="1.5" /><path d="m3 17 6-6 4 4 3-3 5 5" /></>, photo: <><rect x="3" y="3" width="18" height="18" rx="3" /><circle cx="8" cy="8" r="1.5" /><path d="m3 17 6-6 4 4 3-3 5 5" /></>,
+2
View File
@@ -2,6 +2,7 @@ import { useEffect, useId, useState } from "react";
import { NavLink, useLocation } from "react-router-dom"; import { NavLink, useLocation } from "react-router-dom";
import Icon from "./Icon"; import Icon from "./Icon";
import { useAdmin } from "./AdminSession"; import { useAdmin } from "./AdminSession";
import ThemeToggle from "./ThemeToggle";
const navItems = [ const navItems = [
{ label: "About", to: "/" }, { label: "About", to: "/" },
@@ -55,6 +56,7 @@ function Header({ name }) {
</button> </button>
</div> </div>
</div> </div>
<ThemeToggle />
<button type="button" className={`icon-button header-signin ${isAdmin ? "is-admin" : ""}`} <button type="button" className={`icon-button header-signin ${isAdmin ? "is-admin" : ""}`}
aria-label={isAdmin ? "Admin account" : "Admin sign in"} title={isAdmin ? "Admin account" : "Admin sign in"} aria-label={isAdmin ? "Admin account" : "Admin sign in"} title={isAdmin ? "Admin account" : "Admin sign in"}
aria-haspopup="dialog" disabled={checking} onClick={openSignIn}> aria-haspopup="dialog" disabled={checking} onClick={openSignIn}>
+59
View File
@@ -0,0 +1,59 @@
import { useEffect, useState } from "react";
import Icon from "./Icon";
const STORAGE_KEY = "portfolio-theme";
const isTheme = (value) => value === "light" || value === "dark";
function storedTheme() {
try {
const value = localStorage.getItem(STORAGE_KEY);
return isTheme(value) ? value : null;
} catch {
return null;
}
}
export default function ThemeToggle() {
const [preference, setPreference] = useState(storedTheme);
const [systemDark, setSystemDark] = useState(() => window.matchMedia("(prefers-color-scheme: dark)").matches);
const theme = preference ?? (systemDark ? "dark" : "light");
const nextTheme = theme === "dark" ? "light" : "dark";
useEffect(() => {
const media = window.matchMedia("(prefers-color-scheme: dark)");
const onSystemChange = (event) => setSystemDark(event.matches);
const onStorageChange = (event) => {
if (event.key === STORAGE_KEY || event.key === null) {
setPreference(isTheme(event.newValue) ? event.newValue : null);
}
};
media.addEventListener("change", onSystemChange);
window.addEventListener("storage", onStorageChange);
return () => {
media.removeEventListener("change", onSystemChange);
window.removeEventListener("storage", onStorageChange);
};
}, []);
useEffect(() => {
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", theme === "dark" ? "#141d19" : "#f7f8f4");
}, [theme]);
function toggle() {
setPreference(nextTheme);
try {
localStorage.setItem(STORAGE_KEY, nextTheme);
} catch {
// Keep the in-memory preference for this visit.
}
}
return (
<button type="button" className="icon-button theme-toggle" onClick={toggle}
aria-label={`Switch to ${nextTheme} mode`} title={`Switch to ${nextTheme} mode`}>
<Icon name={nextTheme === "dark" ? "moon" : "sun"} />
</button>
);
}
+1 -1
View File
@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import App from "./App"; import App from "./App";
import "./styles.css"; import "./styles.css";
import "./theme.css";
createRoot(document.getElementById("root")).render( createRoot(document.getElementById("root")).render(
<StrictMode> <StrictMode>
@@ -11,4 +12,3 @@ createRoot(document.getElementById("root")).render(
</BrowserRouter> </BrowserRouter>
</StrictMode>, </StrictMode>,
); );
+51 -44
View File
@@ -1,4 +1,10 @@
:root { :root {
color-scheme: light;
--surface-rgb: 255, 255, 255;
--accent-fill: #2f5147;
--accent-fill-hover: #27463d;
--glow-mint: rgba(225, 238, 233, 0.8);
--glow-lavender: rgba(235, 231, 243, 0.7);
--ink: #24342f; --ink: #24342f;
--ink-soft: #52615c; --ink-soft: #52615c;
--ink-faint: #77837f; --ink-faint: #77837f;
@@ -46,8 +52,8 @@ body {
overflow-x: clip; overflow-x: clip;
color: var(--ink); color: var(--ink);
background: background:
radial-gradient(circle at 8% 3%, rgba(225, 238, 233, 0.8), transparent 24rem), radial-gradient(circle at 8% 3%, var(--glow-mint), transparent 24rem),
radial-gradient(circle at 93% 16%, rgba(235, 231, 243, 0.7), transparent 26rem), radial-gradient(circle at 93% 16%, var(--glow-lavender), transparent 26rem),
var(--paper); var(--paper);
} }
@@ -118,7 +124,7 @@ button {
padding: 10px 16px; padding: 10px 16px;
border-radius: 12px; border-radius: 12px;
color: white; color: white;
background: var(--sage-deep); background: var(--accent-fill);
transform: translateY(-150%); transform: translateY(-150%);
transition: transform 160ms ease; transition: transform 160ms ease;
} }
@@ -157,7 +163,7 @@ button {
place-items: center; place-items: center;
border-radius: 15px; border-radius: 15px;
color: white; color: white;
background: var(--sage-deep); background: var(--accent-fill);
box-shadow: 0 8px 18px rgba(47, 81, 71, 0.2); box-shadow: 0 8px 18px rgba(47, 81, 71, 0.2);
font-size: 12px; font-size: 12px;
letter-spacing: 0.08em; letter-spacing: 0.08em;
@@ -174,14 +180,14 @@ button {
padding: 5px; padding: 5px;
border: 0; border: 0;
border-radius: 18px; border-radius: 18px;
background: rgba(255, 255, 255, 0.67); background: rgba(var(--surface-rgb), 0.67);
box-shadow: 0 8px 28px rgba(46, 65, 58, 0.05); box-shadow: 0 8px 28px rgba(46, 65, 58, 0.05);
backdrop-filter: blur(18px); backdrop-filter: blur(18px);
} }
/* Only navigation is pinned. Identity and account controls stay in normal flow. */ /* Only navigation is pinned. Identity and account controls stay in normal flow. */
.nav-positioner { position: fixed; top: var(--header-top); inset-inline: 0; pointer-events: none; } .nav-positioner { position: fixed; top: var(--header-top); inset-inline: 0; pointer-events: none; }
.nav-positioner__inner { position: relative; display: flex; align-items: center; justify-content: flex-end; min-height: 58px; padding-right: 56px; } .nav-positioner__inner { position: relative; display: flex; align-items: center; justify-content: flex-end; min-height: 58px; padding-right: 112px; }
.nav-positioner .nav-shell, .nav-positioner .menu-button { pointer-events: auto; } .nav-positioner .nav-shell, .nav-positioner .menu-button { pointer-events: auto; }
@media (min-width: 721px) and (max-width: 900px) { .brand-name { display: none; } } @media (min-width: 721px) and (max-width: 900px) { .brand-name { display: none; } }
@@ -200,7 +206,7 @@ button {
.nav-link.is-active { .nav-link.is-active {
color: white; color: white;
background: var(--sage-deep); background: var(--accent-fill);
} }
.menu-button { .menu-button {
@@ -286,12 +292,12 @@ button {
.button--primary { .button--primary {
color: white; color: white;
background: var(--sage-deep); background: var(--accent-fill);
box-shadow: 0 12px 26px rgba(47, 81, 71, 0.2); box-shadow: 0 12px 26px rgba(47, 81, 71, 0.2);
} }
.button--primary:hover { .button--primary:hover {
background: #27463d; background: var(--accent-fill-hover);
box-shadow: 0 15px 31px rgba(47, 81, 71, 0.24); box-shadow: 0 15px 31px rgba(47, 81, 71, 0.24);
} }
@@ -304,11 +310,11 @@ button {
.button--quiet { .button--quiet {
border: 1px solid var(--line); border: 1px solid var(--line);
color: var(--ink); color: var(--ink);
background: rgba(255, 255, 255, 0.6); background: rgba(var(--surface-rgb), 0.6);
} }
.button--quiet:hover { .button--quiet:hover {
background: white; background: var(--surface-solid);
box-shadow: var(--shadow-soft); box-shadow: var(--shadow-soft);
} }
@@ -404,7 +410,7 @@ button {
overflow: hidden; overflow: hidden;
border: 12px solid rgba(255, 255, 255, 0.88); border: 12px solid rgba(255, 255, 255, 0.88);
border-radius: 50%; border-radius: 50%;
background: white; background: var(--surface-solid);
box-shadow: var(--shadow); box-shadow: var(--shadow);
} }
@@ -439,7 +445,7 @@ button {
display: flex; display: flex;
align-items: center; align-items: center;
border: 1px solid rgba(54, 78, 70, 0.08); border: 1px solid rgba(54, 78, 70, 0.08);
background: rgba(255, 255, 255, 0.9); background: rgba(var(--surface-rgb), 0.9);
box-shadow: 0 18px 46px rgba(46, 65, 58, 0.12); box-shadow: 0 18px 46px rgba(46, 65, 58, 0.12);
backdrop-filter: blur(14px); backdrop-filter: blur(14px);
} }
@@ -522,7 +528,7 @@ button {
padding: 17px 19px; padding: 17px 19px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 20px; border-radius: 20px;
background: rgba(255, 255, 255, 0.55); background: rgba(var(--surface-rgb), 0.55);
} }
.heading-stat strong { .heading-stat strong {
@@ -658,13 +664,13 @@ button {
overflow: hidden; overflow: hidden;
border: 0; border: 0;
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
background: rgba(255, 255, 255, 0.72); background: rgba(var(--surface-rgb), 0.72);
box-shadow: 0 10px 38px rgba(46, 65, 58, 0.04); box-shadow: 0 10px 38px rgba(46, 65, 58, 0.04);
transition: box-shadow 180ms ease, background 180ms ease, transform 180ms ease; transition: box-shadow 180ms ease, background 180ms ease, transform 180ms ease;
} }
.timeline-card:hover { .timeline-card:hover {
background: rgba(255, 255, 255, 0.9); background: rgba(var(--surface-rgb), 0.9);
box-shadow: var(--shadow-soft); box-shadow: var(--shadow-soft);
} }
@@ -857,7 +863,7 @@ button {
border: 1px solid rgba(54, 78, 70, 0.07); border: 1px solid rgba(54, 78, 70, 0.07);
border-radius: 999px; border-radius: 999px;
color: var(--ink-soft); color: var(--ink-soft);
background: rgba(255, 255, 255, 0.62); background: rgba(var(--surface-rgb), 0.62);
font-size: 10px; font-size: 10px;
font-weight: 660; font-weight: 660;
} }
@@ -882,7 +888,7 @@ button {
padding: 25px 28px; padding: 25px 28px;
border: 0; border: 0;
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
background: rgba(255, 255, 255, 0.58); background: rgba(var(--surface-rgb), 0.58);
} }
.principles-panel .eyebrow { .principles-panel .eyebrow {
@@ -991,7 +997,7 @@ button {
} }
.skill-tags span { .skill-tags span {
background: rgba(255, 255, 255, 0.48); background: rgba(var(--surface-rgb), 0.48);
} }
.issue-count { .issue-count {
@@ -1031,7 +1037,7 @@ button {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 15px; border-radius: 15px;
color: var(--ink-faint); color: var(--ink-faint);
background: rgba(255, 255, 255, 0.65); background: rgba(var(--surface-rgb), 0.65);
} }
.search-box:focus-within { .search-box:focus-within {
@@ -1081,7 +1087,7 @@ button {
.filter-row button.is-active { .filter-row button.is-active {
border-color: transparent; border-color: transparent;
color: var(--ink); color: var(--ink);
background: rgba(255, 255, 255, 0.72); background: rgba(var(--surface-rgb), 0.72);
} }
.journal-filters { flex: 1; min-width: 0; } .journal-filters { flex: 1; min-width: 0; }
@@ -1091,13 +1097,13 @@ button {
.filter-row .others-filter { display: inline-flex; align-items: center; gap: 5px; border: 1px solid var(--line); } .filter-row .others-filter { display: inline-flex; align-items: center; gap: 5px; border: 1px solid var(--line); }
.others-filter svg { flex-shrink: 0; transition: transform 150ms ease; } .others-filter svg { flex-shrink: 0; transition: transform 150ms ease; }
.others-filter[aria-expanded="true"] svg { transform: rotate(180deg); } .others-filter[aria-expanded="true"] svg { transform: rotate(180deg); }
.custom-topic-filters { margin-top: 12px; padding: 15px; border: 1px solid var(--line); border-radius: 16px; background: rgba(255, 255, 255, 0.45); } .custom-topic-filters { margin-top: 12px; padding: 15px; border: 1px solid var(--line); border-radius: 16px; background: rgba(var(--surface-rgb), 0.45); }
.custom-topic-filters[hidden] { display: none; } .custom-topic-filters[hidden] { display: none; }
.custom-topic-filters > p { display: flex; align-items: center; gap: 8px; margin: 0 0 10px; color: var(--ink-soft); font-size: 11px; font-weight: 650; } .custom-topic-filters > p { display: flex; align-items: center; gap: 8px; margin: 0 0 10px; color: var(--ink-soft); font-size: 11px; font-weight: 650; }
.custom-topic-filters > p span { padding: 2px 7px; border-radius: 10px; background: var(--mint); font-size: 10px; } .custom-topic-filters > p span { padding: 2px 7px; border-radius: 10px; background: var(--mint); font-size: 10px; }
.custom-topic-filters > small { color: var(--ink-soft); font-size: 11px; } .custom-topic-filters > small { color: var(--ink-soft); font-size: 11px; }
.custom-topic-filters .filter-row button { background: var(--surface); } .custom-topic-filters .filter-row button { background: var(--surface); }
.custom-topic-filters .filter-row button.is-active { color: white; background: var(--sage-deep); } .custom-topic-filters .filter-row button.is-active { color: white; background: var(--accent-fill); }
.posts-grid { .posts-grid {
display: grid; display: grid;
@@ -1109,7 +1115,7 @@ button {
overflow: hidden; overflow: hidden;
border: 0; border: 0;
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
background: rgba(255, 255, 255, 0.74); background: rgba(var(--surface-rgb), 0.74);
box-shadow: 0 8px 28px rgba(46, 65, 58, 0.04); box-shadow: 0 8px 28px rgba(46, 65, 58, 0.04);
transition: transform 220ms ease, box-shadow 220ms ease; transition: transform 220ms ease, box-shadow 220ms ease;
} }
@@ -1260,7 +1266,7 @@ button {
.post-card:hover .read-arrow { .post-card:hover .read-arrow {
color: white; color: white;
background: var(--sage-deep); background: var(--accent-fill);
} }
.empty-card { .empty-card {
@@ -1328,7 +1334,7 @@ button {
} }
.article-tags span { .article-tags span {
background: rgba(255, 255, 255, 0.48); background: rgba(var(--surface-rgb), 0.48);
} }
.article-hero h1 { .article-hero h1 {
@@ -1377,7 +1383,7 @@ button {
place-items: center; place-items: center;
border-radius: 13px; border-radius: 13px;
color: white; color: white;
background: var(--sage-deep); background: var(--accent-fill);
font-size: 10px; font-size: 10px;
font-weight: 750; font-weight: 750;
} }
@@ -1498,7 +1504,7 @@ button {
padding: clamp(25px, 4vw, 40px); padding: clamp(25px, 4vw, 40px);
border: 0; border: 0;
border-radius: var(--radius-xl); border-radius: var(--radius-xl);
background: rgba(255, 255, 255, 0.78); background: rgba(var(--surface-rgb), 0.78);
box-shadow: var(--shadow-soft); box-shadow: var(--shadow-soft);
} }
@@ -1548,7 +1554,7 @@ button {
.contact-form input:focus, .contact-form input:focus,
.contact-form textarea:focus { .contact-form textarea:focus {
border-color: rgba(47, 81, 71, 0.43); border-color: rgba(47, 81, 71, 0.43);
background: white; background: var(--surface-solid);
box-shadow: 0 0 0 4px rgba(47, 81, 71, 0.07); box-shadow: 0 0 0 4px rgba(47, 81, 71, 0.07);
} }
@@ -1616,7 +1622,7 @@ button {
} }
.social-list { .social-list {
background: rgba(255, 255, 255, 0.65); background: rgba(var(--surface-rgb), 0.65);
} }
.social-list .eyebrow { .social-list .eyebrow {
@@ -1841,7 +1847,7 @@ button {
height: 38px; height: 38px;
place-items: center; place-items: center;
border-radius: 13px; border-radius: 13px;
background: rgba(255, 255, 255, 0.7); background: rgba(var(--surface-rgb), 0.7);
} }
.culture-link small, .culture-link small,
@@ -1892,7 +1898,7 @@ button {
margin: -22px auto 30px; margin: -22px auto 30px;
padding: clamp(28px, 5vw, 48px); padding: clamp(28px, 5vw, 48px);
border-radius: var(--radius-xl); border-radius: var(--radius-xl);
background: rgba(255, 255, 255, 0.8); background: rgba(var(--surface-rgb), 0.8);
box-shadow: var(--shadow); box-shadow: var(--shadow);
} }
@@ -1974,7 +1980,7 @@ button {
.publisher-field textarea:focus, .publisher-field textarea:focus,
.publisher-field select:focus { .publisher-field select:focus {
border-color: rgba(47, 81, 71, 0.43); border-color: rgba(47, 81, 71, 0.43);
background: white; background: var(--surface-solid);
box-shadow: 0 0 0 4px rgba(47, 81, 71, 0.07); box-shadow: 0 0 0 4px rgba(47, 81, 71, 0.07);
} }
@@ -1985,7 +1991,7 @@ button {
margin-top: -25px; margin-top: -25px;
padding: clamp(28px, 5vw, 48px); padding: clamp(28px, 5vw, 48px);
border-radius: var(--radius-xl); border-radius: var(--radius-xl);
background: rgba(255, 255, 255, 0.8); background: rgba(var(--surface-rgb), 0.8);
box-shadow: var(--shadow-soft); box-shadow: var(--shadow-soft);
} }
@@ -2074,7 +2080,7 @@ button {
.topic-options button.is-active { .topic-options button.is-active {
color: white; color: white;
background: var(--sage-deep); background: var(--accent-fill);
box-shadow: 0 8px 19px rgba(47, 81, 71, 0.15); box-shadow: 0 8px 19px rgba(47, 81, 71, 0.15);
} }
@@ -2087,7 +2093,7 @@ button {
padding-left: 13px; padding-left: 13px;
border-radius: 12px; border-radius: 12px;
color: white; color: white;
background: var(--sage-deep); background: var(--accent-fill);
font-size: 12px; font-size: 12px;
font-weight: 680; font-weight: 680;
box-shadow: 0 8px 19px rgba(47, 81, 71, 0.15); box-shadow: 0 8px 19px rgba(47, 81, 71, 0.15);
@@ -2186,7 +2192,7 @@ button {
.editor-tool-group button:hover, .editor-tool-group button:hover,
.editor-tool-group button.is-active { .editor-tool-group button.is-active {
color: var(--ink); color: var(--ink);
background: white; background: var(--surface-solid);
box-shadow: 0 5px 15px rgba(46, 65, 58, 0.08); box-shadow: 0 5px 15px rgba(46, 65, 58, 0.08);
} }
@@ -2196,7 +2202,7 @@ button {
padding-inline: 10px 28px; padding-inline: 10px 28px;
border: 0; border: 0;
border-radius: 10px; border-radius: 10px;
background: white; background: var(--surface-solid);
font-size: 12px; font-size: 12px;
} }
@@ -2394,8 +2400,8 @@ button {
padding-top: 14px; padding-top: 14px;
} }
.nav-positioner__inner { padding-right: 0; } .nav-positioner__inner { padding-right: 112px; }
.site-header .header-actions { padding-right: 56px; } .site-header .header-actions { padding-right: 0; }
.header-inner { .header-inner {
position: relative; position: relative;
@@ -2813,7 +2819,7 @@ button {
padding: clamp(18px, 3vw, 30px); padding: clamp(18px, 3vw, 30px);
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 22px; border-radius: 22px;
background: rgba(255, 255, 255, 0.45); background: rgba(var(--surface-rgb), 0.45);
} }
.article-uploads legend { padding-inline: 10px; font-size: 16px; } .article-uploads legend { padding-inline: 10px; font-size: 16px; }
.section-help { grid-column: 1 / -1; } .section-help { grid-column: 1 / -1; }
@@ -2842,10 +2848,10 @@ button {
.upload-dropzone strong { font-size: 15px; font-weight: 600; } .upload-dropzone strong { font-size: 15px; font-weight: 600; }
.upload-dropzone p { margin: 0; color: var(--ink-soft); font-size: 12px; line-height: 1.6; } .upload-dropzone p { margin: 0; color: var(--ink-soft); font-size: 12px; line-height: 1.6; }
.upload-dropzone small { font-size: 11px; } .upload-dropzone small { font-size: 11px; }
.upload-browse { display: inline-flex; justify-content: center; align-items: center; gap: 7px; min-height: 40px; margin-top: 5px; padding: 9px 15px; border: 1px solid var(--line); border-radius: 11px; background: white; color: var(--sage-deep); font-size: 12px; font-weight: 650; cursor: pointer; } .upload-browse { display: inline-flex; justify-content: center; align-items: center; gap: 7px; min-height: 40px; margin-top: 5px; padding: 9px 15px; border: 1px solid var(--line); border-radius: 11px; background: var(--surface-solid); color: var(--sage-deep); font-size: 12px; font-weight: 650; cursor: pointer; }
.article-uploads .upload-input { display: none; } .article-uploads .upload-input { display: none; }
.upload-tip { margin: 0; color: var(--ink-soft); font-size: 11px; line-height: 1.6; } .upload-tip { margin: 0; color: var(--ink-soft); font-size: 11px; line-height: 1.6; }
.upload-hero-preview { position: relative; isolation: isolate; display: grid; align-items: end; min-height: 220px; overflow: hidden; border-radius: 16px; background: var(--sage-deep); color: white; } .upload-hero-preview { position: relative; isolation: isolate; display: grid; align-items: end; min-height: 220px; overflow: hidden; border-radius: 16px; background: var(--accent-fill); color: white; }
.upload-hero-preview > img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; z-index: -2; } .upload-hero-preview > img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; z-index: -2; }
.upload-hero-preview::before { content: ""; position: absolute; inset: 0; z-index: -1; background: linear-gradient(110deg, rgba(15,29,24,.82), rgba(15,29,24,.58)); } .upload-hero-preview::before { content: ""; position: absolute; inset: 0; z-index: -1; background: linear-gradient(110deg, rgba(15,29,24,.82), rgba(15,29,24,.58)); }
.upload-hero-preview > div { display: grid; gap: 12px; padding: 25px; } .upload-hero-preview > div { display: grid; gap: 12px; padding: 25px; }
@@ -2921,7 +2927,7 @@ button {
} }
.icon-button:hover { background: var(--mint); } .icon-button:hover { background: var(--mint); }
.header-signin { position: relative; } .header-signin { position: relative; }
.header-signin.is-admin { color: #fff; background: var(--sage-deep); } .header-signin.is-admin { color: #fff; background: var(--accent-fill); }
.admin-indicator { position: absolute; right: 3px; top: 3px; width: 8px; height: 8px; border-radius: 50%; background: #bde9a8; } .admin-indicator { position: absolute; right: 3px; top: 3px; width: 8px; height: 8px; border-radius: 50%; background: #bde9a8; }
.admin-dialog { .admin-dialog {
width: min(460px, calc(100vw - 32px)); width: min(460px, calc(100vw - 32px));
@@ -2982,6 +2988,7 @@ button {
button:disabled { cursor: not-allowed; opacity: 0.6; } button:disabled { cursor: not-allowed; opacity: 0.6; }
@media (max-width: 600px) { @media (max-width: 600px) {
.header-actions { gap: 8px; } .header-actions { gap: 8px; }
.nav-positioner__inner { padding-right: 104px; }
.admin-dialog { padding: 22px; } .admin-dialog { padding: 22px; }
} }
+93
View File
@@ -0,0 +1,93 @@
:root[data-theme="dark"] {
color-scheme: dark;
--ink: #e4eee8;
--ink-soft: #bccac2;
--ink-faint: #a0b0a7;
--line: rgba(188, 211, 197, 0.18);
--paper: #141d19;
--surface-rgb: 35, 48, 41;
--surface: rgba(var(--surface-rgb), 0.92);
--surface-solid: #233029;
--sage: #293e33;
--sage-deep: #a4d4b8;
--accent-fill: #345d48;
--accent-fill-hover: #416e56;
--blue: #263640;
--blue-deep: #aacde3;
--lavender: #342f42;
--lavender-deep: #c8bce6;
--peach: #41322c;
--peach-deep: #e2baa6;
--yellow: #3b3827;
--yellow-deep: #d9cc99;
--mint: #263e33;
--glow-mint: rgba(48, 89, 64, 0.22);
--glow-lavender: rgba(69, 53, 94, 0.18);
--shadow: 0 24px 70px rgba(0, 0, 0, 0.25);
--shadow-soft: 0 14px 38px rgba(0, 0, 0, 0.2);
}
:root[data-theme="dark"] ::selection { background: #385745; }
:root[data-theme="dark"] .about-copy h2,
:root[data-theme="dark"] .article-content section p,
:root[data-theme="dark"] .rich-editor .tiptap,
:root[data-theme="dark"] .rich-article .tiptap { color: var(--ink-soft); }
:root[data-theme="dark"] .portrait-frame { border-color: var(--surface-solid); }
:root[data-theme="dark"] .portrait-orbit,
:root[data-theme="dark"] .portrait-orbit--two,
:root[data-theme="dark"] .experience-note,
:root[data-theme="dark"] .availability-note,
:root[data-theme="dark"] .tag-row span,
:root[data-theme="dark"] .skill-tags span,
:root[data-theme="dark"] .mini-tags span,
:root[data-theme="dark"] .article-tags span,
:root[data-theme="dark"] .article-hero { border-color: var(--line); }
:root[data-theme="dark"] .skill-dot { border-color: var(--ink-soft); }
:root[data-theme="dark"] .skill-orbit { box-shadow: 0 0 0 12px var(--mint); }
:root[data-theme="dark"] .expand-button,
:root[data-theme="dark"] .project-links a,
:root[data-theme="dark"] .topic-options button,
:root[data-theme="dark"] .topic-picker kbd,
:root[data-theme="dark"] .rich-editor__toolbar { background: var(--paper); }
:root[data-theme="dark"] .read-arrow,
:root[data-theme="dark"] .upload-dropzone__icon { background: var(--mint); }
:root[data-theme="dark"] .topic-options button.is-active,
:root[data-theme="dark"] .post-card:hover .read-arrow { background: var(--accent-fill); }
:root[data-theme="dark"] .topic-options .topic-chip__remove { background: transparent; }
:root[data-theme="dark"] .contact-form input,
:root[data-theme="dark"] .contact-form textarea,
:root[data-theme="dark"] .publisher-login input,
:root[data-theme="dark"] .publisher-field input,
:root[data-theme="dark"] .publisher-field textarea,
:root[data-theme="dark"] .publisher-field select,
:root[data-theme="dark"] .custom-topic input,
:root[data-theme="dark"] .rich-editor {
background: var(--paper);
border-color: var(--line);
}
:root[data-theme="dark"] :is(input, textarea, select):focus,
:root[data-theme="dark"] .rich-editor:focus-within,
:root[data-theme="dark"] .search-box:focus-within { border-color: var(--sage-deep); }
:root[data-theme="dark"] :is(input, textarea)::placeholder,
:root[data-theme="dark"] .rich-editor .tiptap p.is-editor-empty:first-child::before { color: var(--ink-faint); }
:root[data-theme="dark"] .culture-card { background: linear-gradient(145deg, var(--lavender), #262631); }
:root[data-theme="dark"] .culture-link--spotify { background: #284334; }
:root[data-theme="dark"] .culture-link--instagram { background: #44303c; }
:root[data-theme="dark"] .culture-link--steam { background: #293b49; }
:root[data-theme="dark"] .publisher-badge,
:root[data-theme="dark"] .form-status--success { color: var(--sage-deep); }
:root[data-theme="dark"] .state-card--error,
:root[data-theme="dark"] .upload-feedback,
:root[data-theme="dark"] .upload-list .has-error { background: #3c2825; border-color: #7b5148; color: #f2b7a7; }
:root[data-theme="dark"] .form-status--error,
:root[data-theme="dark"] .social-error,
:root[data-theme="dark"] .upload-file-info p,
:root[data-theme="dark"] .text-button--danger { color: #f2b7a7; }
:root[data-theme="dark"] .upload-dropzone { border-color: #668979; background: linear-gradient(135deg, #253a2e, #1c2c23); }
:root[data-theme="dark"] .upload-dropzone.is-dragging,
:root[data-theme="dark"] .upload-list .upload-sortable.is-sorting { background: var(--mint); }
.theme-toggle { color: var(--sage-deep); }
+3 -1
View File
@@ -3,6 +3,9 @@ import react from "@vitejs/plugin-react";
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
define: {
"import.meta.env.VITE_MAX_UPLOAD_MB": JSON.stringify(process.env.VERCEL === "1" ? 4 : 20),
},
server: { server: {
port: 5173, port: 5173,
proxy: { proxy: {
@@ -13,4 +16,3 @@ export default defineConfig({
}, },
}, },
}); });
+8
View File
@@ -0,0 +1,8 @@
{
"name": "alex-herlan-portfolio-deployment",
"private": true,
"engines": { "node": "24.x" },
"scripts": {
"build": "npm run build --prefix frontend"
}
}
+7
View File
@@ -0,0 +1,7 @@
fastapi
httpx
dropbox==12.2.1
Pillow
uvicorn[standard]
email-validator
supabase==2.31.0
+117
View File
@@ -0,0 +1,117 @@
"""Browser checks for the built frontend: python -m scripts.test_theme.
Requires Playwright and Microsoft Edge. Uses an isolated static server and mocked
API reads; never signs into or changes the live portfolio.
"""
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
import json
from pathlib import Path
from threading import Thread
from urllib.parse import urlsplit
from playwright.sync_api import expect, sync_playwright
ROOT = Path(__file__).resolve().parents[1]
class StaticApp(SimpleHTTPRequestHandler):
def do_GET(self):
if not Path(urlsplit(self.path).path).suffix:
self.path = "/index.html"
super().do_GET()
def log_message(self, *_):
pass
def run():
dist = ROOT / "frontend/dist"
assert (dist / "index.html").is_file(), "Build the frontend first."
fixtures = {
f"/api/{name}": json.loads((ROOT / f"backend/data/{name}.json").read_text(encoding="utf-8"))
for name in ("profile", "experience", "skills", "posts")
}
fixtures["/api/auth/session"] = {"role": "admin"}
fixtures.update({f"/api/posts/{post['slug']}": post for post in fixtures["/api/posts"]})
server = ThreadingHTTPServer(("127.0.0.1", 0), partial(StaticApp, directory=str(dist)))
thread = Thread(target=server.serve_forever, daemon=True)
thread.start()
base_url = f"http://127.0.0.1:{server.server_port}"
try:
with sync_playwright() as playwright:
browser = playwright.chromium.launch(channel="msedge", headless=True)
context = browser.new_context(base_url=base_url, color_scheme="light")
context.route("**/api/**", lambda route: route.fulfill(json=fixtures.get(urlsplit(route.request.url).path, {})))
context.route("https://**", lambda route: route.abort())
page = context.new_page()
errors = []
page.on("pageerror", lambda error: errors.append(str(error)))
page.goto("/")
expect(page.locator("html")).to_have_attribute("data-theme", "light")
page.emulate_media(color_scheme="dark")
expect(page.locator("html")).to_have_attribute("data-theme", "dark")
page.get_by_role("button", name="Switch to light mode").focus()
page.keyboard.press("Enter")
expect(page.locator("html")).to_have_attribute("data-theme", "light")
page.reload()
expect(page.locator("html")).to_have_attribute("data-theme", "light")
other = context.new_page()
other.goto("/")
page.get_by_role("button", name="Switch to dark mode").click()
expect(other.locator("html")).to_have_attribute("data-theme", "dark")
other.close()
for path in ("/", "/experience", "/skills", "/blog", f"/blog/{fixtures['/api/posts'][0]['slug']}", "/contact"):
page.goto(path)
expect(page.locator("html")).to_have_attribute("data-theme", "dark")
expect(page.locator(".theme-toggle")).to_be_visible()
for width in (1280, 901, 800, 721, 720, 600, 390, 320):
page.set_viewport_size({"width": width, "height": 900})
page.goto("/")
expect(page.locator(".theme-toggle")).to_be_visible()
brand = page.locator(".brand").bounding_box()
nav = page.locator(".nav-shell" if width > 720 else ".menu-button").bounding_box()
toggle = page.locator(".theme-toggle").bounding_box()
account = page.locator(".header-signin").bounding_box()
assert brand["x"] + brand["width"] <= nav["x"] + 1, width
assert nav["x"] + nav["width"] <= toggle["x"] + 1, width
assert toggle["x"] + toggle["width"] <= account["x"] + 1, width
assert account["x"] + account["width"] <= width, width
if width <= 720:
page.get_by_role("button", name="Open navigation").click()
page.get_by_role("navigation").get_by_role("link", name="Skills", exact=True).click()
expect(page.locator(".nav-shell")).not_to_be_visible()
page.evaluate('sessionStorage.setItem("alex-journal-admin", `${Math.floor(Date.now() / 1000) + 3600}.isolated-test-token`)')
page.reload()
page.get_by_role("button", name="Admin account").click()
expect(page.get_by_role("button", name="Sign out", exact=True)).to_be_visible()
assert page.locator("dialog").evaluate("e => getComputedStyle(e).backgroundColor") == "rgb(20, 29, 25)"
page.get_by_role("button", name="Sign out", exact=True).click()
expect(page.locator("html")).to_have_attribute("data-theme", "dark")
assert page.evaluate('localStorage.getItem("portfolio-theme")') == "dark"
# Browser privacy restrictions must not break the toggle.
restricted = browser.new_context(base_url=base_url, color_scheme="dark")
restricted.route("**/api/**", lambda route: route.fulfill(json=fixtures.get(urlsplit(route.request.url).path, {})))
restricted.route("https://**", lambda route: route.abort())
restricted.add_init_script('Object.defineProperty(window, "localStorage", {get() { throw new DOMException("Blocked", "SecurityError"); }});')
blocked = restricted.new_page()
blocked.on("pageerror", lambda error: errors.append(str(error)))
blocked.goto("/")
expect(blocked.locator("html")).to_have_attribute("data-theme", "dark")
blocked.get_by_role("button", name="Switch to light mode").click()
expect(blocked.locator("html")).to_have_attribute("data-theme", "light")
assert not errors, errors
browser.close()
finally:
server.shutdown()
server.server_close()
thread.join(timeout=2)
print("PASS: system preference, keyboard toggle, persistence, tab sync, six public routes, eight header widths, admin sign-out, and blocked storage.")
if __name__ == "__main__":
run()
+22
View File
@@ -0,0 +1,22 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"framework": null,
"installCommand": "npm ci --prefix frontend",
"buildCommand": "npm run build --prefix frontend",
"outputDirectory": "frontend/dist",
"functions": {
"api/index.py": {
"maxDuration": 60,
"includeFiles": "{backend/**,resumes/resume-v5.pdf}",
"excludeFiles": "{frontend/**,node_modules/**,.venv/**,scripts/**,backend/test_*.py,backend/.env*,backend/data/uploads/**}"
}
},
"routes": [
{ "src": "/api(?:/.*)?", "dest": "/api/index.py" },
{ "handle": "filesystem" },
{ "src": "/.*", "dest": "/index.html" }
],
"crons": [
{ "path": "/api/cron/media-cleanup", "schedule": "0 10 * * *" }
]
}