Configure Vercel deployment and preserve deployed theme support
This commit is contained in:
@@ -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*
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
3.12
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
"""Vercel ASGI entrypoint; the React build is served separately by the CDN."""
|
||||
from backend.main import app
|
||||
|
||||
__all__ = ["app"]
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+24
-2
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,7 +1 @@
|
||||
fastapi
|
||||
httpx
|
||||
dropbox==12.2.1
|
||||
Pillow
|
||||
uvicorn[standard]
|
||||
email-validator
|
||||
supabase==2.31.0
|
||||
-r ../requirements.txt
|
||||
|
||||
@@ -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()
|
||||
@@ -8,6 +8,7 @@
|
||||
content="Alex Herlan is a senior software engineer building full-stack products, applied AI, and dependable cloud systems."
|
||||
/>
|
||||
<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.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet" />
|
||||
|
||||
Generated
+1
-1
@@ -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"
|
||||
],
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"lightningcss-win32-x64-msvc": "^1.33.0",
|
||||
"vite": "^8.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
})();
|
||||
@@ -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 }) {
|
||||
<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"}
|
||||
</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}
|
||||
aria-label={banner ? "Banner image" : "Additional photos & files"}
|
||||
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 existing = [...attachments, ...queue.current.filter((task) => 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;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
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" />,
|
||||
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" /></>,
|
||||
|
||||
@@ -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 }) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ThemeToggle />
|
||||
<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-haspopup="dialog" disabled={checking} onClick={openSignIn}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
<StrictMode>
|
||||
@@ -11,4 +12,3 @@ createRoot(document.getElementById("root")).render(
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
|
||||
+51
-44
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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({
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "alex-herlan-portfolio-deployment",
|
||||
"private": true,
|
||||
"engines": { "node": "24.x" },
|
||||
"scripts": {
|
||||
"build": "npm run build --prefix frontend"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fastapi
|
||||
httpx
|
||||
dropbox==12.2.1
|
||||
Pillow
|
||||
uvicorn[standard]
|
||||
email-validator
|
||||
supabase==2.31.0
|
||||
@@ -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
@@ -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 * * *" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user