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 }) { + + ); +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index b7bab90..64f08d0 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import App from "./App"; import "./styles.css"; +import "./theme.css"; createRoot(document.getElementById("root")).render( @@ -11,4 +12,3 @@ createRoot(document.getElementById("root")).render( , ); - diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 3b74649..c4f65af 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1,4 +1,10 @@ :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-soft: #52615c; --ink-faint: #77837f; @@ -46,8 +52,8 @@ body { overflow-x: clip; color: var(--ink); background: - radial-gradient(circle at 8% 3%, rgba(225, 238, 233, 0.8), transparent 24rem), - radial-gradient(circle at 93% 16%, rgba(235, 231, 243, 0.7), transparent 26rem), + radial-gradient(circle at 8% 3%, var(--glow-mint), transparent 24rem), + radial-gradient(circle at 93% 16%, var(--glow-lavender), transparent 26rem), var(--paper); } @@ -118,7 +124,7 @@ button { padding: 10px 16px; border-radius: 12px; color: white; - background: var(--sage-deep); + background: var(--accent-fill); transform: translateY(-150%); transition: transform 160ms ease; } @@ -157,7 +163,7 @@ button { place-items: center; border-radius: 15px; color: white; - background: var(--sage-deep); + background: var(--accent-fill); box-shadow: 0 8px 18px rgba(47, 81, 71, 0.2); font-size: 12px; letter-spacing: 0.08em; @@ -174,14 +180,14 @@ button { padding: 5px; border: 0; 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); backdrop-filter: blur(18px); } /* 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__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; } @media (min-width: 721px) and (max-width: 900px) { .brand-name { display: none; } } @@ -200,7 +206,7 @@ button { .nav-link.is-active { color: white; - background: var(--sage-deep); + background: var(--accent-fill); } .menu-button { @@ -286,12 +292,12 @@ button { .button--primary { color: white; - background: var(--sage-deep); + background: var(--accent-fill); box-shadow: 0 12px 26px rgba(47, 81, 71, 0.2); } .button--primary:hover { - background: #27463d; + background: var(--accent-fill-hover); box-shadow: 0 15px 31px rgba(47, 81, 71, 0.24); } @@ -304,11 +310,11 @@ button { .button--quiet { border: 1px solid var(--line); color: var(--ink); - background: rgba(255, 255, 255, 0.6); + background: rgba(var(--surface-rgb), 0.6); } .button--quiet:hover { - background: white; + background: var(--surface-solid); box-shadow: var(--shadow-soft); } @@ -404,7 +410,7 @@ button { overflow: hidden; border: 12px solid rgba(255, 255, 255, 0.88); border-radius: 50%; - background: white; + background: var(--surface-solid); box-shadow: var(--shadow); } @@ -439,7 +445,7 @@ button { display: flex; align-items: center; 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); backdrop-filter: blur(14px); } @@ -522,7 +528,7 @@ button { padding: 17px 19px; border: 1px solid var(--line); border-radius: 20px; - background: rgba(255, 255, 255, 0.55); + background: rgba(var(--surface-rgb), 0.55); } .heading-stat strong { @@ -658,13 +664,13 @@ button { overflow: hidden; border: 0; 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); transition: box-shadow 180ms ease, background 180ms ease, transform 180ms ease; } .timeline-card:hover { - background: rgba(255, 255, 255, 0.9); + background: rgba(var(--surface-rgb), 0.9); box-shadow: var(--shadow-soft); } @@ -857,7 +863,7 @@ button { border: 1px solid rgba(54, 78, 70, 0.07); border-radius: 999px; color: var(--ink-soft); - background: rgba(255, 255, 255, 0.62); + background: rgba(var(--surface-rgb), 0.62); font-size: 10px; font-weight: 660; } @@ -882,7 +888,7 @@ button { padding: 25px 28px; border: 0; border-radius: var(--radius-lg); - background: rgba(255, 255, 255, 0.58); + background: rgba(var(--surface-rgb), 0.58); } .principles-panel .eyebrow { @@ -991,7 +997,7 @@ button { } .skill-tags span { - background: rgba(255, 255, 255, 0.48); + background: rgba(var(--surface-rgb), 0.48); } .issue-count { @@ -1031,7 +1037,7 @@ button { border: 1px solid var(--line); border-radius: 15px; color: var(--ink-faint); - background: rgba(255, 255, 255, 0.65); + background: rgba(var(--surface-rgb), 0.65); } .search-box:focus-within { @@ -1081,7 +1087,7 @@ button { .filter-row button.is-active { border-color: transparent; color: var(--ink); - background: rgba(255, 255, 255, 0.72); + background: rgba(var(--surface-rgb), 0.72); } .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); } .others-filter svg { flex-shrink: 0; transition: transform 150ms ease; } .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 > 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 > small { color: var(--ink-soft); font-size: 11px; } .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 { display: grid; @@ -1109,7 +1115,7 @@ button { overflow: hidden; border: 0; 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); transition: transform 220ms ease, box-shadow 220ms ease; } @@ -1260,7 +1266,7 @@ button { .post-card:hover .read-arrow { color: white; - background: var(--sage-deep); + background: var(--accent-fill); } .empty-card { @@ -1328,7 +1334,7 @@ button { } .article-tags span { - background: rgba(255, 255, 255, 0.48); + background: rgba(var(--surface-rgb), 0.48); } .article-hero h1 { @@ -1377,7 +1383,7 @@ button { place-items: center; border-radius: 13px; color: white; - background: var(--sage-deep); + background: var(--accent-fill); font-size: 10px; font-weight: 750; } @@ -1498,7 +1504,7 @@ button { padding: clamp(25px, 4vw, 40px); border: 0; border-radius: var(--radius-xl); - background: rgba(255, 255, 255, 0.78); + background: rgba(var(--surface-rgb), 0.78); box-shadow: var(--shadow-soft); } @@ -1548,7 +1554,7 @@ button { .contact-form input:focus, .contact-form textarea:focus { 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); } @@ -1616,7 +1622,7 @@ button { } .social-list { - background: rgba(255, 255, 255, 0.65); + background: rgba(var(--surface-rgb), 0.65); } .social-list .eyebrow { @@ -1841,7 +1847,7 @@ button { height: 38px; place-items: center; border-radius: 13px; - background: rgba(255, 255, 255, 0.7); + background: rgba(var(--surface-rgb), 0.7); } .culture-link small, @@ -1892,7 +1898,7 @@ button { margin: -22px auto 30px; padding: clamp(28px, 5vw, 48px); border-radius: var(--radius-xl); - background: rgba(255, 255, 255, 0.8); + background: rgba(var(--surface-rgb), 0.8); box-shadow: var(--shadow); } @@ -1974,7 +1980,7 @@ button { .publisher-field textarea:focus, .publisher-field select:focus { 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); } @@ -1985,7 +1991,7 @@ button { margin-top: -25px; padding: clamp(28px, 5vw, 48px); border-radius: var(--radius-xl); - background: rgba(255, 255, 255, 0.8); + background: rgba(var(--surface-rgb), 0.8); box-shadow: var(--shadow-soft); } @@ -2074,7 +2080,7 @@ button { .topic-options button.is-active { color: white; - background: var(--sage-deep); + background: var(--accent-fill); box-shadow: 0 8px 19px rgba(47, 81, 71, 0.15); } @@ -2087,7 +2093,7 @@ button { padding-left: 13px; border-radius: 12px; color: white; - background: var(--sage-deep); + background: var(--accent-fill); font-size: 12px; font-weight: 680; 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.is-active { color: var(--ink); - background: white; + background: var(--surface-solid); box-shadow: 0 5px 15px rgba(46, 65, 58, 0.08); } @@ -2196,7 +2202,7 @@ button { padding-inline: 10px 28px; border: 0; border-radius: 10px; - background: white; + background: var(--surface-solid); font-size: 12px; } @@ -2394,8 +2400,8 @@ button { padding-top: 14px; } - .nav-positioner__inner { padding-right: 0; } - .site-header .header-actions { padding-right: 56px; } + .nav-positioner__inner { padding-right: 112px; } + .site-header .header-actions { padding-right: 0; } .header-inner { position: relative; @@ -2813,7 +2819,7 @@ button { padding: clamp(18px, 3vw, 30px); border: 1px solid var(--line); 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; } .section-help { grid-column: 1 / -1; } @@ -2842,10 +2848,10 @@ button { .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 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; } .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::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; } @@ -2921,7 +2927,7 @@ button { } .icon-button:hover { background: var(--mint); } .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-dialog { width: min(460px, calc(100vw - 32px)); @@ -2982,6 +2988,7 @@ button { button:disabled { cursor: not-allowed; opacity: 0.6; } @media (max-width: 600px) { .header-actions { gap: 8px; } + .nav-positioner__inner { padding-right: 104px; } .admin-dialog { padding: 22px; } } diff --git a/frontend/src/theme.css b/frontend/src/theme.css new file mode 100644 index 0000000..01be8d3 --- /dev/null +++ b/frontend/src/theme.css @@ -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); } diff --git a/frontend/vite.config.js b/frontend/vite.config.js index b08849d..26449cf 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -3,6 +3,9 @@ import react from "@vitejs/plugin-react"; export default defineConfig({ plugins: [react()], + define: { + "import.meta.env.VITE_MAX_UPLOAD_MB": JSON.stringify(process.env.VERCEL === "1" ? 4 : 20), + }, server: { port: 5173, proxy: { @@ -13,4 +16,3 @@ export default defineConfig({ }, }, }); - diff --git a/package.json b/package.json new file mode 100644 index 0000000..4cb5285 --- /dev/null +++ b/package.json @@ -0,0 +1,8 @@ +{ + "name": "alex-herlan-portfolio-deployment", + "private": true, + "engines": { "node": "24.x" }, + "scripts": { + "build": "npm run build --prefix frontend" + } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0f85bab --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +fastapi +httpx +dropbox==12.2.1 +Pillow +uvicorn[standard] +email-validator +supabase==2.31.0 diff --git a/scripts/test_theme.py b/scripts/test_theme.py new file mode 100644 index 0000000..0d0332c --- /dev/null +++ b/scripts/test_theme.py @@ -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() diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..a5b5332 --- /dev/null +++ b/vercel.json @@ -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 * * *" } + ] +}