Implemented. Sign in, then open Experience or Skills to:

- Add, edit, and delete experience entries and project links.
- Manage skill categories and individual skill labels.
- Edit the skills introduction and “How I work” text.
Changes save directly to Supabase—no code edits or Git push needed. Existing content is preserved.
This commit is contained in:
StormRunner06106
2026-09-13 10:03:21 -07:00
parent 6e3779e500
commit 404e0dc16d
25 changed files with 1337 additions and 82 deletions
+12 -2
View File
@@ -75,11 +75,13 @@ Reading the journal is public and independent of the admin session. Journal list
Use **+ Section** in the editor to add a section heading and opening paragraph. Section headings receive the same divider and uppercase drop cap as the starter articles, with formatting visible in the editor. Existing H2 headings use this styling automatically.
The media picker supports drag-and-drop or file browsing, a live banner preview with the article title and subtitle, file-size and format checks, and duplicate detection by file name and size. Uploads have individual progress, cancel, and retry controls; a failed file does not stop the remaining queue. Retry or remove pending/failed uploads before publishing. Use the up/down controls to set the order of additional photos and files. Replacing a banner keeps the previous image until the replacement uploads successfully.
The media picker supports drag-and-drop or file browsing, a live banner preview with the article title and subtitle, file-size and format checks, and duplicate detection by file name and size. Uploads have individual progress, cancel, and retry controls; a failed file does not stop the remaining queue. Retry or remove pending/failed uploads before publishing. Drag an attachment row with a mouse, or briefly hold its grip handle on a touch screen, to change the order of additional photos and files. Keyboard users can focus the handle, press Space to pick up a file, use the arrow keys to move it, and press Space to drop or Escape to cancel. The new order is persisted when the article is saved. Build the frontend and run `.venv/Scripts/python.exe -m scripts.test_attachment_reordering` to verify mouse, touch, keyboard, cancellation, and order persistence in an isolated browser test (requires Playwright and Microsoft Edge). Replacing a banner keeps the previous image until the replacement uploads successfully.
The publisher accepts a banner image (JPEG, PNG, WebP, or GIF, up to 8 MB) and up to ten additional photos or files (20 MB each). The banner appears in the journal thumbnail and behind the article title and subtitle with a readability overlay; additional photos and downloadable files appear below the body. Uploads require an admin session. New uploads use Dropbox by default. The Supabase article's `banner` and each item in `attachments` contain `storage: "dropbox"` and `dropbox_file_id: "id:..."`, alongside their names, sizes, media types, and stable website URLs.
The `article_media` table stores the upload-ID-to-Dropbox-ID mapping, including uploads not yet attached to a published article. The public `/api/uploads/{upload_id}` route retrieves bytes from Dropbox by ID, so article rows never contain expiring temporary links or access tokens. Original local uploads continue to work until migrated. Removing an attachment or deleting an article does not delete its stored file.
The `article_media` table stores the upload-ID-to-Dropbox-ID mapping, including uploads not yet attached to a published article. The public `/api/uploads/{upload_id}` route retrieves bytes from Dropbox by ID, so article rows never contain expiring temporary links or access tokens. Original local uploads continue to work until migrated. Saving an attachment removal or banner replacement deletes the unused file from Dropbox and removes its upload record and any local migration backup. Deleting an article also cleans up its uploaded files. Files referenced by another article are retained.
Removal intent is stored in the private `article_media_deletions` table before the article write. Cleanup runs after a successful save/delete and retries pending work at backend startup and every 60 seconds; Dropbox outages do not lose deletion requests. Cleanup checks published references before deleting anything. In local mode, the queue lives in `backend/data/media-deletions.json`. The form tracks uploads removed before publishing as well; removals take effect when the form is successfully submitted. Apply the current schema to existing Supabase projects before running this version. Article writes and cleanup are serialized within the backend process; run a single backend worker for this workflow.
For an existing Supabase project, run `bash scripts/setup_supabase.sh --schema-only` to add the nullable `banner` and default-empty `attachments` columns before running the updated backend. Existing articles remain compatible.
@@ -102,6 +104,14 @@ To move existing published media to Dropbox, preview with `.venv/Scripts/python.
For offline development only, set `JOURNAL_MEDIA_STORAGE=local`. Local files and legacy uploads use `JOURNAL_UPLOAD_DIR` (default `backend/data/uploads`). Dropbox uploads with Supabase configured do not require local file storage. If running without Supabase, upload metadata remains in that local directory and needs persistent storage.
## Edit experience and skills
Sign in with the existing admin password, then open **Experience** or **Skills** (also linked from the admin account menu). Experience supports adding, editing, and deleting roles, companies, locations, dates, summaries, highlights, technologies, and project links. Entries are displayed newest first by start date. Skills supports category CRUD, adding/removing individual skill labels, card colors, and editing the introduction and “How I work” principles. Press Enter in a label field to add it. Saves update the public website without code edits or Git pushes. Delete actions require confirmation; canceled dialogs leave published content unchanged.
Experience and skills live in the private Supabase `portfolio_content` table. Apply `backend/supabase/schema.sql` to existing projects, then run `.venv/Scripts/python.exe -m backend.seed_portfolio` once to copy the existing JSON content. Rerunning the seeder preserves content already saved in Supabase. All mutation endpoints require the same admin authentication as articles; reads remain public. Writes use version checks to avoid losing concurrent document updates. Configured Supabase failures are reported rather than falling back to outdated JSON. Local development without Supabase writes to the experience/skills JSON files.
Validation: `.venv/Scripts/python.exe -m unittest backend.test_portfolio_content`. After building the frontend, run `.venv/Scripts/python.exe -m scripts.test_portfolio_editors` for isolated browser coverage (requires Playwright and Microsoft Edge), including CRUD, public visibility, mobile forms, and keeping drafts through session recovery.
## Supabase article storage
The runtime backend needs two values from **Supabase Dashboard → Settings → API Keys**:
+15 -1
View File
@@ -4,7 +4,7 @@ import re
from functools import lru_cache
import dropbox
from dropbox.exceptions import AuthError, DropboxException
from dropbox.exceptions import ApiError, AuthError, DropboxException
from fastapi import HTTPException
from requests.exceptions import RequestException
@@ -58,3 +58,17 @@ def download(file_id: str, limit: int) -> bytes:
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 delete(file_id: str) -> None:
try:
get_dropbox().files_delete_v2(file_id)
except AuthError as exc:
raise HTTPException(503, "Dropbox needs to be reconnected by the site owner.") from exc
except ApiError as exc:
# A retry may follow a successful Dropbox delete and failed database cleanup.
if exc.error.is_path_lookup() and exc.error.get_path_lookup().is_not_found():
return
raise HTTPException(502, "Dropbox could not delete this file. Deletion will be retried.") from exc
except (DropboxException, RequestException) as exc:
raise HTTPException(502, "Dropbox could not delete this file. Deletion will be retried.") from exc
+34 -12
View File
@@ -27,7 +27,7 @@ from pydantic import BaseModel, EmailStr, Field
from starlette.concurrency import run_in_threadpool
from httpx import TransportError
from supabase import Client, ClientOptions, PostgrestAPIError, create_client
from backend import dropbox_storage
from backend import dropbox_storage, media_cleanup, portfolio_content
BASE_DIR = Path(__file__).resolve().parent
@@ -56,6 +56,7 @@ app = FastAPI(
title="Alex Herlan Portfolio API",
description="Supabase-ready content and contact delivery for Alex Herlan's portfolio.",
version="1.1.0",
lifespan=media_cleanup.lifespan,
)
origins = [
@@ -94,6 +95,7 @@ class NewPostPayload(BaseModel):
content: dict[str, Any]
banner: ArticleMedia | None = None
attachments: list[ArticleMedia] = Field(default_factory=list, max_length=10)
discarded_uploads: list[str] = Field(default_factory=list, max_length=100, exclude=True)
@lru_cache(maxsize=8)
@@ -260,14 +262,7 @@ def profile() -> dict[str, Any]:
return read_data("profile.json")
@app.get("/api/experience")
def experience() -> list[dict[str, Any]]:
return read_data("experience.json")
@app.get("/api/skills")
def skills() -> dict[str, Any]:
return read_data("skills.json")
portfolio_content.register_routes(app, require_admin)
@app.post("/api/auth/login")
@@ -425,7 +420,15 @@ def validated_article(payload: NewPostPayload, slug: str) -> dict[str, Any]:
if media is None:
return None
stored = uploaded_media(media["url"].rsplit("/", 1)[-1])
if ArticleMedia.model_validate(stored).model_dump() != media:
canonical = ArticleMedia.model_validate(stored).model_dump()
# An editor can remain open while its local files are migrated to Dropbox.
# The upload URL and file details are unchanged; resolve the new storage
# fields from the server without trusting a client-supplied Dropbox ID.
if (media["storage"] is None and media["dropbox_file_id"] is None
and canonical["storage"] == "dropbox" and canonical["dropbox_file_id"]):
media = {**media, "storage": canonical["storage"],
"dropbox_file_id": canonical["dropbox_file_id"]}
if canonical != media:
raise HTTPException(status_code=422, detail="An attachment is invalid. Please upload it again.")
return stored
article["banner"] = canonical_media(article["banner"])
@@ -451,11 +454,13 @@ def save_local_posts(records: list[dict[str, Any]]) -> None:
@app.post("/api/posts", status_code=status.HTTP_201_CREATED)
@media_cleanup.serialized
def create_post(
payload: NewPostPayload, _: None = Depends(require_admin)
) -> dict[str, Any]:
slug = post_slug(payload.title)
article = validated_article(payload, slug)
media_cleanup.enqueue_urls(payload.discarded_uploads)
client = get_supabase()
if client is not None:
@@ -482,6 +487,7 @@ def create_post(
) from exc
if not created.data:
raise HTTPException(status_code=502, detail="Supabase did not return the new article.")
media_cleanup.cleanup_pending()
return created.data[0]
with POSTS_LOCK:
@@ -494,10 +500,12 @@ def create_post(
current_posts.append(article)
save_local_posts(current_posts)
media_cleanup.cleanup_pending()
return article
@app.put("/api/posts/{slug}")
@media_cleanup.serialized
def update_post(
slug: str, payload: NewPostPayload, _: None = Depends(require_admin)
) -> dict[str, Any]:
@@ -505,6 +513,10 @@ def update_post(
raise HTTPException(status_code=404, detail="Post not found")
# Keep published URLs stable when an admin changes the title.
article = validated_article(payload, slug)
previous = post_by_slug(slug)
media_cleanup.enqueue_urls(payload.discarded_uploads)
retained = {m["url"] for m in media_cleanup.media_items(article)}
media_cleanup.enqueue([m for m in media_cleanup.media_items(previous) if m["url"] not in retained])
client = get_supabase()
if client is not None:
try:
@@ -513,6 +525,7 @@ def update_post(
raise HTTPException(status_code=502, detail="The article could not be updated. Please try again.") from exc
if not response.data:
raise HTTPException(status_code=404, detail="Post not found")
media_cleanup.cleanup_pending()
return response.data[0]
with POSTS_LOCK:
@@ -521,14 +534,21 @@ def update_post(
if existing["slug"] == slug:
records[index] = {**existing, **article}
save_local_posts(records)
return records[index]
raise HTTPException(status_code=404, detail="Post not found")
saved = records[index]
break
else:
raise HTTPException(status_code=404, detail="Post not found")
media_cleanup.cleanup_pending()
return saved
@app.delete("/api/posts/{slug}")
@media_cleanup.serialized
def delete_post(slug: str, _: None = Depends(require_admin)) -> dict[str, bool]:
if not re.fullmatch(r"[a-z0-9-]+", slug):
raise HTTPException(status_code=404, detail="Post not found")
previous = post_by_slug(slug)
media_cleanup.enqueue(media_cleanup.media_items(previous))
client = get_supabase()
if client is not None:
try:
@@ -537,6 +557,7 @@ def delete_post(slug: str, _: None = Depends(require_admin)) -> dict[str, bool]:
raise HTTPException(status_code=502, detail="The article could not be deleted. Please try again.") from exc
if not response.data:
raise HTTPException(status_code=404, detail="Post not found")
media_cleanup.cleanup_pending()
return {"deleted": True}
with POSTS_LOCK:
@@ -545,6 +566,7 @@ def delete_post(slug: str, _: None = Depends(require_admin)) -> dict[str, bool]:
if len(remaining) == len(records):
raise HTTPException(status_code=404, detail="Post not found")
save_local_posts(remaining)
media_cleanup.cleanup_pending()
return {"deleted": True}
+144
View File
@@ -0,0 +1,144 @@
"""Durable cleanup of explicitly removed journal media after article writes."""
import asyncio
from contextlib import asynccontextmanager, suppress
from functools import wraps
import json
import inspect
import re
from threading import RLock
from fastapi import HTTPException
from starlette.concurrency import run_in_threadpool
LOCK = RLock()
TABLE = "article_media_deletions"
def serialized(function):
@wraps(function)
def wrapped(*args, **kwargs):
with LOCK:
return function(*args, **kwargs)
wrapped.__signature__ = inspect.signature(function, eval_str=True)
return wrapped
def media_items(article):
return [m for m in [article.get("banner"), *(article.get("attachments") or [])] if m]
def local_queue_path():
from backend import main
return main.DATA_DIR / "media-deletions.json"
def pending():
from backend import main
client = main.get_supabase()
if client is not None:
return main.read_article_query(client.table(TABLE).select("upload_id,metadata").order("created_at")).data
path = local_queue_path()
return json.loads(path.read_text(encoding="utf-8")) if path.exists() else []
def save_local_queue(records):
path = local_queue_path()
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(".tmp")
temporary.write_text(json.dumps(records), encoding="utf-8")
temporary.replace(path)
def enqueue(media):
"""Persist intent before changing the article; referenced files stay protected."""
from backend import main
if not media:
return
records = {}
for item in media:
upload_id = item["url"].rsplit("/", 1)[-1]
stored = main.uploaded_media(upload_id)
records[upload_id] = {"upload_id": upload_id, "metadata": stored}
client = main.get_supabase()
try:
if client is not None:
result = client.table(TABLE).upsert(list(records.values())).execute()
if not result.data:
raise RuntimeError("Removal queue was not saved")
else:
save_local_queue(list({**{r["upload_id"]: r for r in pending()}, **records}.values()))
except Exception as exc:
raise HTTPException(502, "The file removal could not be scheduled. Please save again.") from exc
def enqueue_urls(urls):
from backend import main
media = []
for url in set(urls):
if not re.fullmatch(r"/api/uploads/[a-f0-9]{32}", url):
raise HTTPException(422, "Invalid file removal reference.")
try:
media.append(main.uploaded_media(url.rsplit("/", 1)[-1]))
except HTTPException as exc:
if exc.status_code != 404:
raise
enqueue(media)
def forget(upload_id):
from backend import main
client = main.get_supabase()
if client is not None:
client.table(TABLE).delete().eq("upload_id", upload_id).execute()
else:
save_local_queue([r for r in pending() if r["upload_id"] != upload_id])
@serialized
def cleanup_pending():
from backend import main, dropbox_storage
try:
records = pending()
if not records:
return
referenced = [m for p in main.article_records(include_content=False) for m in media_items(p)]
active_urls = {m["url"] for m in referenced}
active_ids = {m.get("dropbox_file_id") for m in referenced if m.get("dropbox_file_id")}
client = main.get_supabase()
for record in records:
upload_id, media = record["upload_id"], record["metadata"]
if not re.fullmatch(r"[a-f0-9]{32}", upload_id):
continue
if media["url"] in active_urls or media.get("dropbox_file_id") in active_ids:
continue
try:
if media.get("storage") == "dropbox":
dropbox_storage.delete(media["dropbox_file_id"])
# Remove registry and migration backups only after Dropbox succeeds.
if client is not None:
client.table("article_media").delete().eq("upload_id", upload_id).execute()
for suffix in ("", ".json"):
path = main.UPLOAD_DIR / (upload_id + suffix)
if path.resolve().parent != main.UPLOAD_DIR.resolve():
raise ValueError("Invalid upload cleanup path")
path.unlink(missing_ok=True)
forget(upload_id)
except Exception as exc:
main.logger.warning("Journal file cleanup pending: type=%s", type(exc).__name__)
except Exception as exc:
main.logger.warning("Journal cleanup retry pending: type=%s", type(exc).__name__)
@asynccontextmanager
async def lifespan(app):
async def retry():
while True:
await run_in_threadpool(cleanup_pending)
await asyncio.sleep(60)
task = asyncio.create_task(retry())
try:
yield
finally:
task.cancel()
with suppress(asyncio.CancelledError):
await task
+165
View File
@@ -0,0 +1,165 @@
"""Editable experience and skills, backed by Supabase with local development storage."""
import json
from threading import RLock
from typing import Annotated, Literal
from uuid import uuid4
from fastapi import Depends, HTTPException
from pydantic import BaseModel, ConfigDict, Field, HttpUrl, StringConstraints, model_validator
LOCK = RLock()
TABLE = "portfolio_content"
ShortText = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=100)]
LongText = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=1000)]
Month = Annotated[str, StringConstraints(pattern=r"^[1-9][0-9]{3}-(0[1-9]|1[0-2])$")]
class ContentModel(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
class Project(ContentModel):
name: ShortText
period: str = Field(default="", max_length=100)
href: HttpUrl
class ExperienceInput(ContentModel):
company: ShortText
role: str = Field(min_length=1, max_length=200)
location: str = Field(min_length=1, max_length=200)
start: Month
end: Month | None = None
summary: str = Field(min_length=1, max_length=4000)
highlights: list[LongText] = Field(default_factory=list, max_length=30)
technologies: list[ShortText] = Field(default_factory=list, max_length=60)
projects: list[Project] = Field(default_factory=list, max_length=20)
@model_validator(mode="after")
def ordered_dates(self):
if self.end and self.end < self.start:
raise ValueError("End date must be on or after the start date.")
return self
class CategoryInput(ContentModel):
name: ShortText
description: str = Field(min_length=1, max_length=1000)
accent: Literal["blue", "lavender", "peach", "yellow", "mint"] = "mint"
items: list[ShortText] = Field(default_factory=list, max_length=100)
class SkillsOverview(ContentModel):
intro: str = Field(min_length=1, max_length=2000)
principles: list[LongText] = Field(default_factory=list, max_length=20)
def load_document(key):
from backend import main
client = main.get_supabase()
if client is None:
# Read the file on each edit; don't mutate objects held in the public cache.
path = main.DATA_DIR / f"{key}.json"
return {"payload": json.loads(path.read_text(encoding="utf-8")), "version": 0}
response = main.read_article_query(client.table(TABLE).select("payload,version").eq("key", key).limit(1))
if not response.data:
raise HTTPException(503, "Portfolio content has not been initialized on the server.")
return response.data[0]
def save_document(key, payload, version):
from backend import main
client = main.get_supabase()
if client is None:
path = main.DATA_DIR / f"{key}.json"
temporary = path.with_suffix(".tmp")
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
temporary.replace(path)
main.read_data.cache_clear()
return
try:
response = client.table(TABLE).update({"payload": payload, "version": version + 1}).eq("key", key).eq("version", version).execute()
except Exception as exc:
raise HTTPException(502, "Your changes could not be saved. Please try again.") from exc
if not response.data:
raise HTTPException(409, "This content changed while saving. Please try saving again.")
def experience():
return sorted(load_document("experience")["payload"], key=lambda item: item["start"], reverse=True)
def skills():
content = load_document("skills")["payload"]
# Stable fallback IDs support existing local JSON before running the seeder.
for index, category in enumerate(content["categories"]):
category.setdefault("id", f"category-{index + 1}")
return content
def mutate_item(key, collection, item_id=None, payload=None):
with LOCK:
document = load_document(key)
content = document["payload"]
items = content if collection is None else content[collection]
if collection:
for index, item in enumerate(items):
item.setdefault("id", f"category-{index + 1}")
if item_id is None:
record = {**payload, "id": uuid4().hex}
items.append(record)
else:
index = next((i for i, item in enumerate(items) if item["id"] == item_id), None)
if index is None:
raise HTTPException(404, "This entry no longer exists.")
if payload is None:
items.pop(index)
record = {"deleted": True}
else:
record = {**payload, "id": item_id}
items[index] = record
save_document(key, content, document["version"])
return record
def register_routes(app, require_admin):
@app.get("/api/experience")
def get_experience():
return experience()
@app.post("/api/experience", status_code=201)
def create_experience(payload: ExperienceInput, _: None = Depends(require_admin)):
return mutate_item("experience", None, payload=payload.model_dump(mode="json"))
@app.put("/api/experience/{item_id}")
def update_experience(item_id: str, payload: ExperienceInput, _: None = Depends(require_admin)):
return mutate_item("experience", None, item_id, payload.model_dump(mode="json"))
@app.delete("/api/experience/{item_id}")
def delete_experience(item_id: str, _: None = Depends(require_admin)):
return mutate_item("experience", None, item_id)
@app.get("/api/skills")
def get_skills():
return skills()
@app.patch("/api/skills")
def update_overview(payload: SkillsOverview, _: None = Depends(require_admin)):
with LOCK:
document = load_document("skills")
content = document["payload"]
content.update(payload.model_dump(mode="json"))
save_document("skills", content, document["version"])
return content
@app.post("/api/skills/categories", status_code=201)
def create_category(payload: CategoryInput, _: None = Depends(require_admin)):
return mutate_item("skills", "categories", payload=payload.model_dump(mode="json"))
@app.put("/api/skills/categories/{item_id}")
def update_category(item_id: str, payload: CategoryInput, _: None = Depends(require_admin)):
return mutate_item("skills", "categories", item_id, payload.model_dump(mode="json"))
@app.delete("/api/skills/categories/{item_id}")
def delete_category(item_id: str, _: None = Depends(require_admin)):
return mutate_item("skills", "categories", item_id)
+25
View File
@@ -0,0 +1,25 @@
"""One-time seed: python -m backend.seed_portfolio. Existing content is preserved."""
import json
from dotenv import load_dotenv
from backend import main, portfolio_content
def seed():
client = main.get_supabase()
if client is None:
raise SystemExit("Configure Supabase before seeding portfolio content.")
for key in ("experience", "skills"):
payload = json.loads((main.DATA_DIR / f"{key}.json").read_text(encoding="utf-8"))
if key == "skills":
for index, category in enumerate(payload["categories"]):
category.setdefault("id", f"category-{index + 1}")
# ON CONFLICT DO NOTHING makes rerunning setup safe for admin edits.
client.table(portfolio_content.TABLE).upsert(
{"key": key, "payload": payload, "version": 1}, on_conflict="key", ignore_duplicates=True,
).execute()
print(f"{key}: initialized if missing; existing edits preserved.")
if __name__ == "__main__":
load_dotenv(main.BASE_DIR / ".env")
seed()
+21
View File
@@ -36,3 +36,24 @@ create table if not exists public.article_media (
alter table public.article_media enable row level security;
revoke all on table public.article_media from anon, authenticated;
grant select, insert, update, delete on table public.article_media to service_role;
-- Persist removal intent before article changes so Dropbox outages can be retried.
create table if not exists public.article_media_deletions (
upload_id text primary key check (upload_id ~ '^[a-f0-9]{32}$'),
metadata jsonb not null,
created_at timestamptz not null default now()
);
alter table public.article_media_deletions enable row level security;
revoke all on table public.article_media_deletions from anon, authenticated;
grant select, insert, update, delete on table public.article_media_deletions to service_role;
-- Editable portfolio sections. Version checks prevent concurrent document writes
-- from silently replacing each other's changes.
create table if not exists public.portfolio_content (
key text primary key check (key in ('experience', 'skills')),
payload jsonb not null,
version bigint not null default 1
);
alter table public.portfolio_content enable row level security;
revoke all on table public.portfolio_content from anon, authenticated;
grant select, insert, update, delete on table public.portfolio_content to service_role;
+116 -1
View File
@@ -10,7 +10,8 @@ from fastapi.testclient import TestClient
from PIL import Image
from httpx import ReadTimeout, RemoteProtocolError
from supabase import PostgrestAPIError
from dropbox.exceptions import AuthError
from dropbox.exceptions import ApiError, AuthError
from dropbox.files import DeleteError, LookupError
from backend import main
@@ -126,6 +127,8 @@ class ArticleMediaTests(unittest.TestCase):
table = client.table.return_value
payload = self.article_payload()
table.update.return_value.eq.return_value.execute.return_value.data = [{**payload, "slug": "original-url"}]
table.select.return_value.eq.return_value.limit.return_value.retry.return_value.execute.return_value.data = [{**payload, "slug": "original-url"}]
table.select.return_value.order.return_value.retry.return_value.execute.return_value.data = []
table.delete.return_value.eq.return_value.execute.return_value.data = [{"slug": "original-url"}]
with patch.object(main, "get_supabase", return_value=client):
updated = self.client.put("/api/posts/original-url", json=payload, headers=self.headers)
@@ -253,6 +256,118 @@ class ArticleMediaTests(unittest.TestCase):
self.assertEqual(migrated["dropbox_file_id"], "id:migrated_file")
self.assertEqual((main.UPLOAD_DIR / media["url"].rsplit("/", 1)[-1]).read_bytes(), b"original notes")
def test_edit_open_before_migration_keeps_old_media_and_new_uploads(self):
from backend.migrate_media_dropbox import migrate
image = io.BytesIO()
Image.new("RGB", (20, 20), "green").save(image, format="PNG")
banner = self.upload(image.getvalue(), "banner").json()
attachment = self.upload(b"original notes", name="notes.txt").json()
draft = {**self.article_payload(), "banner": banner, "attachments": [attachment]}
created = self.client.post("/api/posts", headers=self.headers, json=draft)
self.assertEqual(created.status_code, 201)
path = "/api/posts/an-editable-article"
dropbox = MagicMock()
dropbox.files_upload.side_effect = [
MagicMock(id="id:migrated_banner"), MagicMock(id="id:migrated_attachment"),
MagicMock(id="id:new_attachment"),
]
with patch.object(main.dropbox_storage, "get_dropbox", return_value=dropbox):
migrate(apply=True)
with patch.dict(os.environ, {"JOURNAL_MEDIA_STORAGE": "dropbox"}):
uploaded = self.upload(b"new notes", name="new.txt")
self.assertEqual(uploaded.status_code, 201)
draft["attachments"].append(uploaded.json())
# The browser still has the pre-migration metadata for the old files.
draft["title"] = "An edited article after migration"
saved = self.client.put(path, headers=self.headers, json=draft)
self.assertEqual(saved.status_code, 200, saved.text)
result = self.client.get(path).json()
self.assertEqual(result["title"], draft["title"])
self.assertEqual(result["banner"]["dropbox_file_id"], "id:migrated_banner")
self.assertEqual([m["dropbox_file_id"] for m in result["attachments"]],
["id:migrated_attachment", "id:new_attachment"])
self.assertEqual(result["banner"]["url"], banner["url"])
self.assertEqual(result["attachments"][0]["url"], attachment["url"])
# Compatibility must not accept altered file details or a substituted ID.
for changes in ({"size": 999}, {"name": "different.txt"},
{"media_type": "image/png"}, {"dropbox_file_id": "id:other"},
{"storage": "dropbox"}):
with self.subTest(changes=changes):
invalid = {**draft, "attachments": [{**attachment, **changes}]}
rejected = self.client.put(path, headers=self.headers, json=invalid)
self.assertEqual(rejected.status_code, 422)
def test_removal_deletes_dropbox_files_after_save_and_preserves_shared_media(self):
dropbox = MagicMock()
dropbox.files_upload.side_effect = [MagicMock(id=f"id:file_{i}") for i in range(4)]
image = io.BytesIO()
Image.new("RGB", (8, 8), "blue").save(image, format="PNG")
with patch.dict(os.environ, {"JOURNAL_MEDIA_STORAGE": "dropbox"}), patch.object(main.dropbox_storage, "get_dropbox", return_value=dropbox):
banner = self.upload(image.getvalue(), "banner").json()
attachment = self.upload(b"shared notes").json()
replacement = self.upload(image.getvalue(), "banner").json()
discarded = self.upload(b"removed before publishing").json()
payload = {**self.article_payload(), "banner": banner, "attachments": [attachment]}
self.assertEqual(self.client.post("/api/posts", headers=self.headers, json=payload).status_code, 201)
second = {**self.article_payload(), "title": "Another article with shared notes", "attachments": [attachment]}
self.assertEqual(self.client.post("/api/posts", headers=self.headers, json=second).status_code, 201)
edited = {**payload, "banner": replacement, "attachments": [], "discarded_uploads": [discarded["url"]]}
self.assertEqual(self.client.put("/api/posts/an-editable-article", headers=self.headers, json=edited).status_code, 200)
deleted = [call.args[0] for call in dropbox.files_delete_v2.call_args_list]
self.assertCountEqual(deleted, [banner["dropbox_file_id"], discarded["dropbox_file_id"]])
self.assertEqual(self.client.get(banner["url"]).status_code, 404)
self.assertEqual(main.uploaded_media(attachment["url"].rsplit("/", 1)[-1]), attachment)
self.assertEqual(self.client.delete("/api/posts/another-article-with-shared-notes", headers=self.headers).status_code, 200)
self.assertEqual(self.client.delete("/api/posts/an-editable-article", headers=self.headers).status_code, 200)
self.assertCountEqual([call.args[0] for call in dropbox.files_delete_v2.call_args_list],
[m["dropbox_file_id"] for m in [banner, attachment, replacement, discarded]])
self.assertEqual(main.media_cleanup.pending(), [])
def test_failed_file_deletion_is_durable_and_retryable_after_article_deleted(self):
dropbox = MagicMock()
dropbox.files_upload.return_value.id = "id:retry_delete"
dropbox.files_delete_v2.side_effect = AuthError("request", None)
with patch.dict(os.environ, {"JOURNAL_MEDIA_STORAGE": "dropbox"}), patch.object(main.dropbox_storage, "get_dropbox", return_value=dropbox):
media = self.upload(b"retry notes").json()
payload = {**self.article_payload(), "attachments": [media]}
self.client.post("/api/posts", headers=self.headers, json=payload)
response = self.client.delete("/api/posts/an-editable-article", headers=self.headers)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(main.media_cleanup.pending()), 1)
self.assertEqual(self.client.get("/api/auth/session", headers=self.headers).status_code, 200)
self.assertEqual(main.uploaded_media(media["url"].rsplit("/", 1)[-1]), media)
# Dropbox already removed the file, e.g. before a database outage.
dropbox.files_delete_v2.side_effect = ApiError("request", DeleteError.path_lookup(LookupError.not_found), None, None)
main.media_cleanup.cleanup_pending()
self.assertEqual(main.media_cleanup.pending(), [])
self.assertEqual(self.client.get(media["url"]).status_code, 404)
def test_failed_article_save_does_not_delete_referenced_file(self):
media = self.upload(b"must survive failed save").json()
payload = {**self.article_payload(), "attachments": [media]}
self.client.post("/api/posts", headers=self.headers, json=payload)
with patch.object(main, "save_local_posts", side_effect=OSError("disk unavailable")):
with self.assertRaises(OSError):
self.client.put("/api/posts/an-editable-article", headers=self.headers, json=self.article_payload())
main.read_data.cache_clear()
main.media_cleanup.cleanup_pending()
self.assertEqual(self.client.get(media["url"]).content, b"must survive failed save")
self.assertEqual(len(main.media_cleanup.pending()), 1)
self.assertEqual(self.client.put("/api/posts/an-editable-article", headers=self.headers, json=self.article_payload()).status_code, 200)
self.assertEqual(self.client.get(media["url"]).status_code, 404)
self.assertEqual(main.media_cleanup.pending(), [])
def test_removing_new_upload_before_publish_cleans_it_after_success(self):
media = self.upload(b"discarded draft upload").json()
payload = {**self.article_payload(), "discarded_uploads": [media["url"]]}
invalid = {**payload, "content": {"type": "doc", "content": []}}
self.assertEqual(self.client.post("/api/posts", headers=self.headers, json=invalid).status_code, 422)
self.assertEqual(self.client.get(media["url"]).status_code, 200)
result = self.client.post("/api/posts", headers=self.headers, json=payload)
self.assertEqual(result.status_code, 201)
self.assertNotIn("discarded_uploads", result.json())
self.assertEqual(self.client.get(media["url"]).status_code, 404)
if __name__ == "__main__":
unittest.main()
+119
View File
@@ -0,0 +1,119 @@
import json
import os
from pathlib import Path
from tempfile import TemporaryDirectory
import unittest
from unittest.mock import MagicMock, patch
from fastapi.testclient import TestClient
from backend import main, portfolio_content
class PortfolioContentTests(unittest.TestCase):
def setUp(self):
directory = TemporaryDirectory()
self.addCleanup(directory.cleanup)
self.root = Path(directory.name)
(self.root / "experience.json").write_text("[]", encoding="utf-8")
(self.root / "skills.json").write_text(json.dumps({"intro": "Original intro", "principles": ["Keep it simple"], "categories": []}), encoding="utf-8")
for mocked in (patch.object(main, "DATA_DIR", self.root), patch.object(main, "get_supabase", return_value=None),
patch.dict(os.environ, {"JOURNAL_ADMIN_PASSWORD": "test-password", "JOURNAL_TOKEN_SECRET": "test-secret"})):
mocked.start(); self.addCleanup(mocked.stop)
self.client = TestClient(main.app)
token = self.client.post("/api/auth/login", json={"password": "test-password"}).json()["access_token"]
self.headers = {"Authorization": "Bearer " + token}
self.experience = {"company": "Example", "role": "Engineer", "location": "Remote", "start": "2024-06", "end": None,
"summary": "Build and maintain useful applications.", "highlights": ["Delivered a product"], "technologies": ["Python"],
"projects": [{"name": "Project", "href": "https://example.com", "period": "2024"}]}
self.category = {"name": "Engineering", "description": "Tools I use", "accent": "blue", "items": ["Python", "React"]}
def test_experience_crud_is_publicly_readable_and_persistent(self):
created = self.client.post("/api/experience", json=self.experience, headers=self.headers)
self.assertEqual(created.status_code, 201, created.text)
item = created.json()
older = self.client.post("/api/experience", json={**self.experience, "start": "2020-01", "end": "2023-12"}, headers=self.headers).json()
self.assertEqual([entry["id"] for entry in self.client.get("/api/experience").json()], [item["id"], older["id"]])
changed = {**self.experience, "role": "Lead engineer", "technologies": ["Go"], "projects": [], "highlights": []}
response = self.client.put("/api/experience/" + item["id"], json=changed, headers=self.headers)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["id"], item["id"])
saved = json.loads((self.root / "experience.json").read_text())
self.assertEqual(saved[0]["role"], "Lead engineer")
for entry in [item, older]:
self.assertEqual(self.client.delete("/api/experience/" + entry["id"], headers=self.headers).status_code, 200)
self.assertEqual(self.client.get("/api/experience").json(), [])
def test_skills_categories_labels_and_overview_crud(self):
response = self.client.post("/api/skills/categories", json=self.category, headers=self.headers)
self.assertEqual(response.status_code, 201, response.text)
item = response.json()
changed = {**self.category, "name": "Updated category", "items": ["TypeScript"]}
response = self.client.put("/api/skills/categories/" + item["id"], json=changed, headers=self.headers)
self.assertEqual(response.status_code, 200)
overview = {"intro": "A new introduction", "principles": ["One principle", "Another principle"]}
self.assertEqual(self.client.patch("/api/skills", json=overview, headers=self.headers).status_code, 200)
content = self.client.get("/api/skills").json()
self.assertEqual(content["intro"], overview["intro"])
self.assertEqual(content["categories"][0]["items"], ["TypeScript"])
self.assertEqual(content["categories"][0]["id"], item["id"])
self.assertEqual(self.client.delete("/api/skills/categories/" + item["id"], headers=self.headers).status_code, 200)
self.client.patch("/api/skills", json={**overview, "principles": []}, headers=self.headers)
content = self.client.get("/api/skills").json()
self.assertEqual(content["categories"], [])
self.assertEqual(content["principles"], [])
def test_all_mutations_require_admin(self):
for method, path, body in [
("post", "/api/experience", self.experience), ("put", "/api/experience/missing", self.experience),
("delete", "/api/experience/missing", None), ("post", "/api/skills/categories", self.category),
("put", "/api/skills/categories/missing", self.category), ("delete", "/api/skills/categories/missing", None),
("patch", "/api/skills", {"intro": "Intro", "principles": []}),
]:
for headers in ({}, {"Authorization": "Bearer 1.invalid"}):
response = self.client.request(method, path, json=body, headers=headers)
self.assertEqual(response.status_code, 401, (method, path))
self.assertEqual(self.client.get("/api/experience").status_code, 200)
self.assertEqual(self.client.get("/api/skills").status_code, 200)
def test_invalid_dates_links_fields_and_missing_records(self):
for changes in ({"start": "2024-13"}, {"end": "2020-01"}, {"role": " "},
{"projects": [{"name": "Unsafe", "href": "javascript:alert(1)"}]}, {"id": "client-selected"}):
self.assertEqual(self.client.post("/api/experience", json={**self.experience, **changes}, headers=self.headers).status_code, 422)
self.assertEqual(self.client.post("/api/skills/categories", json={**self.category, "accent": "invalid"}, headers=self.headers).status_code, 422)
for path, body in [("/api/experience/missing", self.experience), ("/api/skills/categories/missing", self.category)]:
self.assertEqual(self.client.put(path, json=body, headers=self.headers).status_code, 404)
self.assertEqual(self.client.delete(path, headers=self.headers).status_code, 404)
def test_supabase_writes_use_versions_and_fail_without_silent_fallback(self):
client = MagicMock()
table = client.table.return_value
read = table.select.return_value.eq.return_value.limit.return_value.retry.return_value.execute
read.return_value.data = [{"payload": [], "version": 7}]
write = table.update.return_value.eq.return_value.eq.return_value.execute
write.return_value.data = [{"key": "experience"}]
with patch.object(main, "get_supabase", return_value=client):
response = self.client.post("/api/experience", json=self.experience, headers=self.headers)
self.assertEqual(response.status_code, 201)
written = table.update.call_args.args[0]
self.assertEqual(written["version"], 8)
self.assertEqual(written["payload"][0]["role"], "Engineer")
table.update.return_value.eq.assert_called_with("key", "experience")
table.update.return_value.eq.return_value.eq.assert_called_with("version", 7)
write.return_value.data = []
self.assertEqual(self.client.post("/api/experience", json=self.experience, headers=self.headers).status_code, 409)
write.side_effect = RuntimeError("offline")
self.assertEqual(self.client.post("/api/experience", json=self.experience, headers=self.headers).status_code, 502)
read.return_value.data = []
self.assertEqual(self.client.get("/api/experience").status_code, 503)
self.assertEqual(json.loads((self.root / "experience.json").read_text()), [])
def test_local_category_ids_remain_stable_when_deleting_previous_category(self):
content = {"intro": "Intro", "principles": [], "categories": [{**self.category, "name": name} for name in ["First", "Second"]]}
(self.root / "skills.json").write_text(json.dumps(content))
categories = self.client.get("/api/skills").json()["categories"]
self.client.delete("/api/skills/categories/" + categories[0]["id"], headers=self.headers)
self.assertEqual(self.client.get("/api/skills").json()["categories"][0]["id"], categories[1]["id"])
if __name__ == "__main__":
unittest.main()
+62
View File
@@ -8,6 +8,9 @@
"name": "alex-herlan-portfolio",
"version": "1.0.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tiptap/extension-placeholder": "^3.31.3",
"@tiptap/extension-text-style": "^3.31.3",
"@tiptap/pm": "^3.31.3",
@@ -23,6 +26,59 @@
"vite": "^8.3.0"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@floating-ui/core": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
@@ -1554,6 +1610,12 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/use-sync-external-store": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz",
+3
View File
@@ -9,6 +9,9 @@
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tiptap/extension-placeholder": "^3.31.3",
"@tiptap/extension-text-style": "^3.31.3",
"@tiptap/pm": "^3.31.3",
+14 -2
View File
@@ -24,6 +24,7 @@ async function request(path, { timeoutMs = 0, ...options } = {}) {
try {
const body = await response.json();
if (typeof body.detail === "string") detail = body.detail;
else if (Array.isArray(body.detail)) detail = body.detail.map((item) => item.msg?.replace(/^Value error, /, "")).filter(Boolean).join(" ") || detail;
} catch { /* Keep the fallback for proxy errors. */ }
const error = new Error(detail);
error.status = response.status;
@@ -64,8 +65,19 @@ async function readRequest(path, options = {}) {
}
export const getProfile = (signal) => request("/api/profile", { signal });
export const getExperience = (signal) => request("/api/experience", { signal });
export const getSkills = (signal) => request("/api/skills", { signal });
export const getExperience = (signal) => readRequest("/api/experience", { signal });
export const getSkills = (signal) => readRequest("/api/skills", { signal });
const saveContent = (path, method, payload, token) => request(path, {
method, headers: { Authorization: `Bearer ${token}` },
...(payload === undefined ? {} : { body: JSON.stringify(payload) }),
});
export const createExperience = (payload, token) => saveContent("/api/experience", "POST", payload, token);
export const updateExperience = (id, payload, token) => saveContent(`/api/experience/${encodeURIComponent(id)}`, "PUT", payload, token);
export const deleteExperience = (id, token) => saveContent(`/api/experience/${encodeURIComponent(id)}`, "DELETE", undefined, token);
export const createSkillCategory = (payload, token) => saveContent("/api/skills/categories", "POST", payload, token);
export const updateSkillCategory = (id, payload, token) => saveContent(`/api/skills/categories/${encodeURIComponent(id)}`, "PUT", payload, token);
export const deleteSkillCategory = (id, token) => saveContent(`/api/skills/categories/${encodeURIComponent(id)}`, "DELETE", undefined, token);
export const updateSkillsOverview = (payload, token) => saveContent("/api/skills", "PATCH", payload, token);
export const getPosts = (signal) => readRequest("/api/posts", { signal });
export const getPost = (slug, signal) => readRequest(`/api/posts/${slug}`, { signal });
export const mediaUrl = (media) => `${API_BASE}${media.url}`;
+4 -2
View File
@@ -108,12 +108,14 @@ export default function AdminProvider({ children }) {
{open && <Modal title={isAdmin ? "Admin account" : "Admin sign in"} onClose={close} busy={busy}>
{isAdmin ? <div className="admin-account">
<span className="publisher-badge"><span /> Admin</span>
<p>You can create, edit, and delete journal articles.</p>
<p>Manage your articles, experience, and skills.</p>
<Link className="button button--primary" to="/blog/manage" onClick={close}><Icon name="plus" size={18} /> New article</Link>
<Link className="button" to="/blog" onClick={close}>Manage articles</Link>
<Link className="button" to="/experience" onClick={close}>Manage experience</Link>
<Link className="button" to="/skills" onClick={close}>Manage skills</Link>
<button className="text-button" type="button" onClick={signOut}><Icon name="logout" size={18} /> Sign out</button>
</div> : <form className="admin-signin" onSubmit={signIn}>
<p>Enter the admin password to manage journal articles.</p>
<p>Enter the admin password to manage your website content.</p>
<label className="publisher-field">
<span>Admin password</span>
<input autoFocus autoComplete="current-password" type="password" required maxLength={200}
@@ -33,7 +33,7 @@ export default function ArticleAdminActions({ post, onDeleted }) {
<Icon name="trash" size={16} /> Delete
</button>
{confirming && <Modal title="Delete article?" onClose={() => setConfirming(false)} busy={busy}>
<p>{post.title} will be removed from the journal. This cannot be undone.</p>
<p>{post.title} and its uploaded files will be deleted. Files used by another article will be kept. This cannot be undone.</p>
{error && <p className="form-status form-status--error" role="alert">{error}</p>}
<div className="dialog-actions">
<button autoFocus className="button" type="button" disabled={busy} onClick={() => setConfirming(false)}>Cancel</button>
+2 -11
View File
@@ -1,6 +1,7 @@
import { useEffect, useId, useRef, useState } from "react";
import { mediaUrl } from "../api";
import Icon from "./Icon";
import AttachmentList from "./AttachmentList";
const imageTypes = ["image/jpeg", "image/png", "image/webp", "image/gif"];
const isImage = (file) => imageTypes.includes(file.type) || (!file.type && /\.(jpe?g|png|webp|gif)$/i.test(file.name));
@@ -147,17 +148,7 @@ export default function ArticleUploads({ banner, attachments, busy, title, subti
<div className="upload-section">
<div className="upload-section__heading"><div><h3>Additional photos & files</h3><p>Shown below your article, in this order.</p></div><span className="upload-label">{attachmentCount}/10</span></div>
<Dropzone purpose="attachment" disabled={busy || attachmentCount >= 10} onFiles={addFiles} />
{attachments.length > 0 && <ul className="upload-list">
{attachments.map((file, index) => <li key={file.url}>
<span className="upload-file-icon">{file.media_type.startsWith("image/") ? <img src={mediaUrl(file)} alt="" /> : <Icon name="file" size={24} />}</span>
<div className="upload-file-info"><strong>{file.name}</strong><small>{fileSize(file.size)} · <span className="upload-ready">Ready</span></small></div>
<div className="upload-file-actions">
<button type="button" className="upload-icon-button" disabled={index === 0} aria-label={`Move ${file.name} up`} title="Move up" onClick={() => onMoveAttachment(file.url, -1)}><Icon name="up" size={17} /></button>
<button type="button" className="upload-icon-button" disabled={index === attachments.length - 1} aria-label={`Move ${file.name} down`} title="Move down" onClick={() => onMoveAttachment(file.url, 1)}><Icon name="down" size={17} /></button>
<button type="button" className="upload-icon-button" aria-label={`Remove ${file.name}`} title="Remove file" onClick={() => onRemoveAttachment(file.url)}><Icon name="close" size={17} /></button>
</div>
</li>)}
</ul>}
{attachments.length > 0 && <AttachmentList attachments={attachments} busy={busy} onMove={onMoveAttachment} onRemove={onRemoveAttachment} formatSize={fileSize} />}
</div>
{messages.length > 0 && <div className="upload-feedback" role="alert"><strong>Some files could not be added</strong><ul>{messages.map((message, index) => <li key={index}>{message}</li>)}</ul><button type="button" className="text-button" onClick={() => setMessages([])}>Dismiss</button></div>}
@@ -0,0 +1,68 @@
import { useState } from "react";
import { DndContext, KeyboardSensor, MouseSensor, TouchSensor, closestCenter, useSensor, useSensors } from "@dnd-kit/core";
import { SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { mediaUrl } from "../api";
import Icon from "./Icon";
const verticalDrag = ({ transform }) => ({ ...transform, x: 0 });
function SortableAttachment({ file, busy, canSort, sorting, onRemove, formatSize }) {
const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } = useSortable({
id: file.url, disabled: !canSort, transition: { duration: 180, easing: "ease" },
});
return <li ref={setNodeRef} className={`upload-sortable ${isDragging ? "is-sorting" : ""} ${!canSort ? "is-disabled" : ""}`}
style={{ transform: CSS.Transform.toString(transform), transition }}
onDragStart={(event) => event.preventDefault()}
onMouseDown={(event) => {
// Keep touch scrolling available on the row, with dragging on the handle.
if (!event.target.closest("button")) listeners?.onMouseDown?.(event);
}}>
<button ref={setActivatorNodeRef} type="button" className="upload-drag-handle"
{...attributes} {...listeners} disabled={!canSort} aria-label={`Reorder ${file.name}`}
title="Drag to reorder. Or press Space, use arrow keys, then Space to drop.">
<Icon name="grip" size={18} />
</button>
<span className="upload-file-icon">{file.media_type.startsWith("image/") ? <img src={mediaUrl(file)} alt="" draggable={false} /> : <Icon name="file" size={24} />}</span>
<div className="upload-file-info"><strong>{file.name}</strong><small>{formatSize(file.size)} · <span className="upload-ready">Ready</span></small></div>
<button type="button" className="upload-icon-button" disabled={busy || sorting}
aria-label={`Remove ${file.name}`} title="Remove file" onClick={() => onRemove(file.url)}><Icon name="close" size={17} /></button>
</li>;
}
export default function AttachmentList({ attachments, busy, onMove, onRemove, formatSize }) {
const [activeId, setActiveId] = useState(null);
const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 6 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, scrollBehavior: "auto" }),
);
const describe = (id) => attachments.find((file) => file.url === id)?.name ?? "File";
const position = (id) => attachments.findIndex((file) => file.url === id) + 1;
return <>
{attachments.length > 1 && <p className="upload-tip upload-sort-hint">Drag to reorder. Keyboard: <kbd>Space</kbd> to pick up, <kbd></kbd>/<kbd></kbd> to move, <kbd>Space</kbd> to drop.</p>}
<DndContext sensors={sensors} collisionDetection={closestCenter} modifiers={[verticalDrag]}
accessibility={{
screenReaderInstructions: { draggable: "Press Space to pick up a file, arrow keys to move it, and Space to drop. Press Escape to cancel." },
announcements: {
onDragStart: ({ active }) => `Picked up ${describe(active.id)}, position ${position(active.id)} of ${attachments.length}.`,
onDragOver: ({ active, over }) => over ? `${describe(active.id)}, position ${position(over.id)} of ${attachments.length}.` : undefined,
onDragEnd: ({ active, over }) => over ? `Dropped ${describe(active.id)} at position ${position(over.id)} of ${attachments.length}.` : "Reordering canceled.",
onDragCancel: () => "Reordering canceled. File order unchanged.",
},
}}
onDragStart={({ active }) => setActiveId(active.id)}
onDragCancel={() => setActiveId(null)}
onDragEnd={({ active, over }) => {
setActiveId(null);
if (!busy && over && active.id !== over.id) onMove(active.id, over.id);
}}>
<SortableContext items={attachments.map((file) => file.url)} strategy={verticalListSortingStrategy}>
<ul className="upload-list upload-sortable-list" aria-label="Attachment order">
{attachments.map((file) => <SortableAttachment key={file.url} file={file} formatSize={formatSize}
busy={busy} canSort={!busy && attachments.length > 1} sorting={activeId !== null} onRemove={onRemove} />)}
</ul>
</SortableContext>
</DndContext>
</>;
}
+1
View File
@@ -1,4 +1,5 @@
const paths = {
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" /></>,
file: <><path d="M14 2H5v20h14V7l-5-5Zm0 0v6h5M8 13h8M8 17h5" /></>,
@@ -0,0 +1,122 @@
import { useState } from "react";
import { createExperience, updateExperience, createSkillCategory, updateSkillCategory, updateSkillsOverview } from "../api";
import { useAdmin } from "./AdminSession";
import Icon from "./Icon";
import Modal from "./Modal";
const lines = (text) => text.split("\n").map((line) => line.trim()).filter(Boolean);
function EditorDialog({ title, children, onClose, onSave, onSaved, submitLabel = "Save changes", destructive = false }) {
const { token, isAdmin, openSignIn } = useAdmin();
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function submit(event) {
event.preventDefault();
if (busy || !isAdmin) return;
setBusy(true); setError("");
try { const result = await onSave(token); onSaved(result); }
catch (problem) { setError(problem.message); }
finally { setBusy(false); }
}
return <Modal title={title} busy={busy} onClose={onClose}>
<form className="portfolio-editor" onSubmit={submit}>
<fieldset disabled={busy || !isAdmin}>{children}</fieldset>
{error && <p className="form-status form-status--error" role="alert">{error}</p>}
{!isAdmin && <p className="form-status" role="status">Sign in again to save. Your edits are still here.</p>}
<div className="dialog-actions">
<button type="button" className="button" disabled={busy} onClick={onClose}>Cancel</button>
{isAdmin ? <button type="submit" className={`button ${destructive ? "button--danger" : "button--primary"}`} disabled={busy}>{busy ? "Saving…" : submitLabel}</button>
: <button type="button" className="button button--primary" onClick={openSignIn}>Sign in</button>}
</div>
</form>
</Modal>;
}
export function ContentActions({ label, onEdit, onDelete }) {
return <div className="portfolio-item-actions">
<button type="button" className="text-button" aria-label={`Edit ${label}`} onClick={onEdit}><Icon name="edit" size={15} /> Edit</button>
<button type="button" className="text-button text-button--danger" aria-label={`Delete ${label}`} onClick={onDelete}><Icon name="trash" size={15} /> Delete</button>
</div>;
}
export function DeleteContentDialog({ label, onDelete, onClose, onDeleted }) {
return <EditorDialog title="Delete this entry?" destructive submitLabel="Delete entry" onClose={onClose} onSave={onDelete} onSaved={onDeleted}>
<p>{label} will be removed from the website. This cannot be undone.</p>
</EditorDialog>;
}
function TagEditor({ label, items, onChange, limit }) {
const [text, setText] = useState("");
const [message, setMessage] = useState("");
function add() {
const value = text.trim();
if (!value) return;
if (items.some((item) => item.toLowerCase() === value.toLowerCase())) { setMessage("Already added."); return; }
if (items.length >= limit) { setMessage(`You can add up to ${limit} labels.`); return; }
onChange([...items, value]); setText(""); setMessage("");
}
return <div className="portfolio-tags-editor">
<label className="publisher-field"><span>{label}</span>
<input value={text} maxLength={100} placeholder="Type a label and press Enter" onChange={(event) => { setText(event.target.value); setMessage(""); }}
onKeyDown={(event) => { if (event.key === "Enter" && !event.nativeEvent.isComposing) { event.preventDefault(); add(); } }} />
</label>
<div className="portfolio-tag-list">{items.map((item) => <span key={item}>{item}<button type="button" aria-label={`Remove ${item}`} onClick={() => onChange(items.filter((value) => value !== item))}><Icon name="close" size={13} /></button></span>)}</div>
<div className="portfolio-tag-help"><small>Press Enter to add. Use × to remove.</small><button type="button" className="text-button" onClick={add}>Add label</button></div>
{message && <small role="status">{message}</small>}
</div>;
}
export function ExperienceEditor({ item, onClose, onSaved }) {
const [value, setValue] = useState(() => ({ company: item?.company ?? "", role: item?.role ?? "", location: item?.location ?? "", start: item?.start ?? "", end: item?.end ?? "", summary: item?.summary ?? "", technologies: item?.technologies ?? [], projects: item?.projects ?? [] }));
const [current, setCurrent] = useState(!item?.end);
const [highlights, setHighlights] = useState((item?.highlights ?? []).join("\n"));
const field = (name) => ({ value: value[name], onChange: (event) => setValue((before) => ({ ...before, [name]: event.target.value })) });
const projectField = (index, name, text) => setValue((before) => ({ ...before, projects: before.projects.map((project, i) => i === index ? { ...project, [name]: text } : project) }));
function save(token) {
if (!current && value.end < value.start) throw new Error("End date must be on or after the start date.");
const payload = { ...value, end: current ? null : value.end, highlights: lines(highlights) };
return item ? updateExperience(item.id, payload, token) : createExperience(payload, token);
}
return <EditorDialog title={item ? "Edit experience" : "Add experience"} onClose={onClose} onSave={save} onSaved={onSaved} submitLabel={item ? "Save changes" : "Add experience"}>
<label className="publisher-field"><span>Role</span><input autoFocus required maxLength={200} {...field("role")} /></label>
<div className="portfolio-form-grid">
<label className="publisher-field"><span>Company</span><input required maxLength={100} {...field("company")} /></label>
<label className="publisher-field"><span>Location</span><input required maxLength={200} {...field("location")} /></label>
<label className="publisher-field"><span>Start date</span><input type="month" required {...field("start")} /></label>
<label className="publisher-field"><span>End date</span><input type="month" min={value.start || undefined} required={!current} disabled={current} {...field("end")} /></label>
</div>
<label className="portfolio-checkbox"><input type="checkbox" checked={current} onChange={(event) => setCurrent(event.target.checked)} /> I currently work here</label>
<label className="publisher-field"><span>Summary</span><textarea aria-label="Summary" required rows={4} maxLength={4000} {...field("summary")} /></label>
<label className="publisher-field"><span>Highlights one per line</span><textarea aria-label="Highlights" rows={5} value={highlights} onChange={(event) => setHighlights(event.target.value)} /></label>
<TagEditor label="Technologies" items={value.technologies} limit={60} onChange={(technologies) => setValue((before) => ({ ...before, technologies }))} />
<div className="portfolio-projects">
<div className="portfolio-section-heading"><strong>Project links</strong><button type="button" className="text-button" disabled={value.projects.length >= 20} onClick={() => setValue((before) => ({ ...before, projects: [...before.projects, { name: "", href: "", period: "" }] }))}><Icon name="plus" size={15} /> Add project</button></div>
{value.projects.map((project, index) => <div className="portfolio-project" key={index}>
<label className="publisher-field"><span>Project name</span><input required maxLength={100} value={project.name} onChange={(event) => projectField(index, "name", event.target.value)} /></label>
<label className="publisher-field"><span>Project URL</span><input required type="url" placeholder="https://" value={project.href} onChange={(event) => projectField(index, "href", event.target.value)} /></label>
<label className="publisher-field"><span>Project period (optional)</span><input maxLength={100} value={project.period} onChange={(event) => projectField(index, "period", event.target.value)} /></label>
<button type="button" className="text-button text-button--danger" onClick={() => setValue((before) => ({ ...before, projects: before.projects.filter((_, i) => i !== index) }))}>Remove project</button>
</div>)}
</div>
</EditorDialog>;
}
export function SkillCategoryEditor({ item, onClose, onSaved }) {
const [value, setValue] = useState({ name: item?.name ?? "", description: item?.description ?? "", accent: item?.accent ?? "mint", items: item?.items ?? [] });
const field = (name) => ({ value: value[name], onChange: (event) => setValue((before) => ({ ...before, [name]: event.target.value })) });
return <EditorDialog title={item ? "Edit skill category" : "Add skill category"} onClose={onClose} onSave={(token) => item ? updateSkillCategory(item.id, value, token) : createSkillCategory(value, token)} onSaved={onSaved} submitLabel={item ? "Save changes" : "Add category"}>
<label className="publisher-field"><span>Category name</span><input autoFocus required maxLength={100} {...field("name")} /></label>
<label className="publisher-field"><span>Description</span><textarea aria-label="Description" required rows={3} maxLength={1000} {...field("description")} /></label>
<label className="publisher-field"><span>Card color</span><select {...field("accent")}>{["mint", "blue", "lavender", "peach", "yellow"].map((color) => <option key={color} value={color}>{color[0].toUpperCase() + color.slice(1)}</option>)}</select></label>
<TagEditor label="Skills" items={value.items} limit={100} onChange={(items) => setValue((before) => ({ ...before, items }))} />
</EditorDialog>;
}
export function SkillsOverviewEditor({ skills, onClose, onSaved }) {
const [intro, setIntro] = useState(skills.intro);
const [principles, setPrinciples] = useState(skills.principles.join("\n"));
return <EditorDialog title="Edit skills overview" onClose={onClose} onSave={(token) => updateSkillsOverview({ intro, principles: lines(principles) }, token)} onSaved={onSaved}>
<label className="publisher-field"><span>Introduction</span><textarea aria-label="Introduction" autoFocus required rows={4} maxLength={2000} value={intro} onChange={(event) => setIntro(event.target.value)} /></label>
<label className="publisher-field"><span>How I work one principle per line</span><textarea aria-label="How I work" rows={6} value={principles} onChange={(event) => setPrinciples(event.target.value)} /></label>
</EditorDialog>;
}
+26 -22
View File
@@ -1,5 +1,8 @@
import { useEffect, useMemo, useState } from "react";
import { getExperience } from "../api";
import { useAdmin } from "../components/AdminSession";
import usePortfolioResource from "../usePortfolioResource";
import { ContentActions, DeleteContentDialog, ExperienceEditor } from "../components/PortfolioEditors";
import { useMemo, useState } from "react";
import { deleteExperience, getExperience } from "../api";
import Icon from "../components/Icon";
import PageHeader from "../components/PageHeader";
import { ErrorState, LoadingState } from "../components/Status";
@@ -33,7 +36,7 @@ function durationLabel(start, end) {
return parts.join(" ") || "1 mo";
}
function TimelineItem({ item, index, isExpanded, onToggle }) {
function TimelineItem({ item, index, isExpanded, onToggle, actions }) {
const isCurrent = !item.end;
const year = item.start.slice(0, 4);
@@ -46,6 +49,7 @@ function TimelineItem({ item, index, isExpanded, onToggle }) {
<span />
</div>
<div className="timeline-card">
{actions}
<button
className="timeline-card__header"
onClick={onToggle}
@@ -110,24 +114,19 @@ function TimelineItem({ item, index, isExpanded, onToggle }) {
}
export default function ExperiencePage() {
const [experience, setExperience] = useState([]);
const { isAdmin } = useAdmin();
const { data, loading, error, reload, setData } = usePortfolioResource(getExperience);
const experience = data ?? [];
const [expandedId, setExpandedId] = useState("independent");
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
const controller = new AbortController();
getExperience(controller.signal)
.then(setExperience)
.catch((requestError) => {
if (requestError.name !== "AbortError") setError(requestError.message);
})
.finally(() => setLoading(false));
return () => controller.abort();
}, []);
const [editor, setEditor] = useState(null);
function saved(record) {
setData((current) => [...current.filter((item) => item.id !== record.id), record].sort((a, b) => b.start.localeCompare(a.start)));
setExpandedId(record.id);
setEditor(null);
}
const visibleYears = useMemo(() => {
if (!experience.length) return "2013 — now";
if (!experience.length) return "Your timeline";
return `${experience.at(-1).start.slice(0, 4)} — now`;
}, [experience]);
@@ -139,19 +138,22 @@ export default function ExperiencePage() {
description="Product engineering, critical operations, and technical leadership—connected by a habit of making complicated systems easier to use."
aside={
<div className="heading-stat">
<strong>{experience.length || "6"}</strong>
<strong>{experience.length}</strong>
<span>career chapters<br />{visibleYears}</span>
</div>
}
/>
{loading && <LoadingState label="Mapping the timeline" />}
{error && <ErrorState message={error} />}
{isAdmin && <div className="portfolio-admin-toolbar"><p>Keep your career timeline up to date.</p><button type="button" className="button button--primary" disabled={!data} onClick={() => setEditor({ kind: "edit", item: null })}><Icon name="plus" size={17} /> Add experience</button></div>}
{loading && !data && <LoadingState label="Mapping the timeline" />}
{error && <ErrorState message={error} onRetry={reload} retrying={loading} />}
{!loading && !error && (
{data && (
<div className="timeline">
{!experience.length && <p className="state-card">No experience entries yet.</p>}
{experience.map((item, index) => (
<TimelineItem
actions={isAdmin && <ContentActions label={`${item.role} at ${item.company}`} onEdit={() => setEditor({ kind: "edit", item })} onDelete={() => setEditor({ kind: "delete", item })} />}
index={index}
isExpanded={expandedId === item.id}
item={item}
@@ -161,6 +163,8 @@ export default function ExperiencePage() {
))}
</div>
)}
{editor?.kind === "edit" && <ExperienceEditor item={editor.item} onClose={() => setEditor(null)} onSaved={saved} />}
{editor?.kind === "delete" && <DeleteContentDialog label={`${editor.item.role} at ${editor.item.company}`} onClose={() => setEditor(null)} onDelete={(token) => deleteExperience(editor.item.id, token)} onDeleted={() => { setData((current) => current.filter((item) => item.id !== editor.item.id)); setEditor(null); }} />}
</section>
);
}
+19 -7
View File
@@ -38,6 +38,7 @@ function ArticleForm({ slug }) {
const [articleText, setArticleText] = useState("");
const [banner, setBanner] = useState(null);
const [attachments, setAttachments] = useState([]);
const discardedUploads = useRef(new Set());
const [uploading, setUploading] = useState(false);
const [customTopic, setCustomTopic] = useState("");
const [topicMessage, setTopicMessage] = useState("");
@@ -121,6 +122,7 @@ function ArticleForm({ slug }) {
content: article,
banner,
attachments,
discarded_uploads: [...discardedUploads.current],
};
setStatus({ type: "sending", message: slug ? "Saving changes..." : "Publishing..." });
@@ -136,17 +138,20 @@ function ArticleForm({ slug }) {
async function uploadFile(file, purpose, options) {
const uploaded = await uploadMedia(file, purpose, token, options);
if (options.signal.aborted) return;
if (purpose === "banner") setBanner(uploaded);
if (purpose === "banner") setBanner((previous) => {
if (previous) discardedUploads.current.add(previous.url);
return uploaded;
});
else setAttachments((current) => [...current, uploaded]);
}
function moveAttachment(url, direction) {
function moveAttachment(url, targetUrl) {
setAttachments((current) => {
const index = current.findIndex((file) => file.url === url);
const next = index + direction;
if (index < 0 || next < 0 || next >= current.length) return current;
const next = current.findIndex((file) => file.url === targetUrl);
if (index < 0 || next < 0 || index === next) return current;
const ordered = [...current];
[ordered[index], ordered[next]] = [ordered[next], ordered[index]];
ordered.splice(next, 0, ordered.splice(index, 1)[0]);
return ordered;
});
}
@@ -273,8 +278,15 @@ function ArticleForm({ slug }) {
<p className="section-help">Use + Section to add a heading and a new section. Each section gets a divider and an automatic uppercase drop cap on its opening paragraph.</p>
<ArticleUploads banner={banner} attachments={attachments} busy={status.type === "sending"}
title={post.title} subtitle={post.excerpt} onPendingChange={setUploading} onMoveAttachment={moveAttachment}
onUpload={uploadFile} onRemoveBanner={() => setBanner(null)}
onRemoveAttachment={(url) => setAttachments((current) => current.filter((file) => file.url !== url))} />
onUpload={uploadFile} onRemoveBanner={() => {
if (banner) discardedUploads.current.add(banner.url);
setBanner(null);
}}
onRemoveAttachment={(url) => {
discardedUploads.current.add(url);
setAttachments((current) => current.filter((file) => file.url !== url));
}} />
<p className="section-help">Removed photos and files are deleted from storage when you {slug ? "save changes" : "publish"}.</p>
<div className="publisher-submit">
<Link className="text-button" to={slug ? `/blog/${slug}` : "/blog"}>Cancel</Link>
+24 -21
View File
@@ -1,24 +1,21 @@
import { useEffect, useState } from "react";
import { getSkills } from "../api";
import { useAdmin } from "../components/AdminSession";
import usePortfolioResource from "../usePortfolioResource";
import { ContentActions, DeleteContentDialog, SkillCategoryEditor, SkillsOverviewEditor } from "../components/PortfolioEditors";
import { useState } from "react";
import { deleteSkillCategory, getSkills } from "../api";
import Icon from "../components/Icon";
import PageHeader from "../components/PageHeader";
import { ErrorState, LoadingState } from "../components/Status";
export default function SkillsPage() {
const [skills, setSkills] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
const controller = new AbortController();
getSkills(controller.signal)
.then(setSkills)
.catch((requestError) => {
if (requestError.name !== "AbortError") setError(requestError.message);
})
.finally(() => setLoading(false));
return () => controller.abort();
}, []);
const { isAdmin } = useAdmin();
const { data: skills, loading, error, reload, setData } = usePortfolioResource(getSkills);
const [editor, setEditor] = useState(null);
function savedCategory(record) {
setData((current) => ({ ...current, categories: current.categories.some((item) => item.id === record.id)
? current.categories.map((item) => item.id === record.id ? record : item) : [...current.categories, record] }));
setEditor(null);
}
return (
<section className="content-page container">
@@ -33,12 +30,13 @@ export default function SkillsPage() {
}
/>
{loading && <LoadingState label="Unpacking the toolkit" />}
{error && <ErrorState message={error} />}
{isAdmin && <div className="portfolio-admin-toolbar"><p>Shape your toolkit as your skills evolve.</p><div><button className="button" type="button" disabled={!skills} onClick={() => setEditor({ kind: "overview" })}>Edit overview</button><button className="button button--primary" type="button" disabled={!skills} onClick={() => setEditor({ kind: "category", item: null })}><Icon name="plus" size={17} /> Add category</button></div></div>}
{loading && !skills && <LoadingState label="Unpacking the toolkit" />}
{error && <ErrorState message={error} onRetry={reload} retrying={loading} />}
{skills && (
<>
<div className="principles-panel reveal">
{skills.principles.length > 0 && <div className="principles-panel reveal">
<p className="eyebrow">How I work</p>
<div className="principles-list">
{skills.principles.map((principle, index) => (
@@ -48,13 +46,14 @@ export default function SkillsPage() {
</div>
))}
</div>
</div>
</div>}
<div className="skills-grid">
{!skills.categories.length && <p className="state-card">No skill categories yet.</p>}
{skills.categories.map((category, index) => (
<article
className={`skill-card skill-card--${category.accent} reveal`}
key={category.name}
key={category.id}
style={{ "--delay": `${index * 55}ms` }}
>
<div className="skill-card__top">
@@ -68,11 +67,15 @@ export default function SkillsPage() {
<span key={item}>{item}</span>
))}
</div>
{isAdmin && <ContentActions label={category.name} onEdit={() => setEditor({ kind: "category", item: category })} onDelete={() => setEditor({ kind: "delete", item: category })} />}
</article>
))}
</div>
</>
)}
{editor?.kind === "category" && <SkillCategoryEditor item={editor.item} onClose={() => setEditor(null)} onSaved={savedCategory} />}
{editor?.kind === "overview" && <SkillsOverviewEditor skills={skills} onClose={() => setEditor(null)} onSaved={(record) => { setData((current) => ({ ...current, intro: record.intro, principles: record.principles })); setEditor(null); }} />}
{editor?.kind === "delete" && <DeleteContentDialog label={editor.item.name} onClose={() => setEditor(null)} onDelete={(token) => deleteSkillCategory(editor.item.id, token)} onDeleted={() => { setData((current) => ({ ...current, categories: current.categories.filter((item) => item.id !== editor.item.id) })); setEditor(null); }} />}
</section>
);
}
+40
View File
@@ -2855,6 +2855,18 @@ button {
.upload-icon-button { display: grid; place-items: center; width: 34px; height: 36px; padding: 0; border: 0; border-radius: 8px; background: transparent; color: var(--ink-soft); cursor: pointer; }
.upload-icon-button:hover { background: var(--mint); color: var(--sage-deep); }
.upload-icon-button:disabled { opacity: 0.25; }
.upload-sortable-list { position: relative; isolation: isolate; }
.upload-list .upload-sortable { position: relative; cursor: grab; user-select: none; padding-left: 4px; gap: 8px; }
.upload-list .upload-sortable.is-disabled { cursor: default; }
.upload-list .upload-sortable.is-sorting { z-index: 2; cursor: grabbing; border-color: var(--sage-deep); background: #f2f7f3; box-shadow: 0 8px 22px rgba(35, 65, 53, .14); }
.upload-drag-handle { display: grid; place-items: center; flex: 0 0 32px; width: 32px; height: 44px; padding: 0; border: 0; border-radius: 8px; background: transparent; color: var(--ink-soft); cursor: grab; touch-action: none; }
.upload-drag-handle:hover, .upload-drag-handle:focus-visible { color: var(--sage-deep); background: var(--mint); }
.upload-drag-handle:active { cursor: grabbing; }
.upload-drag-handle:disabled { opacity: .25; cursor: default; }
.upload-sortable > .upload-icon-button { flex-shrink: 0; }
.upload-sort-hint kbd { font: inherit; color: var(--sage-deep); }
@media (pointer: coarse) { .upload-drag-handle { flex-basis: 44px; width: 44px; } }
@media (prefers-reduced-motion: reduce) { .upload-list .upload-sortable { transition: none !important; } }
.upload-queue { display: grid; gap: 12px; }
.upload-file-info progress { width: 100%; height: 6px; margin-top: 4px; accent-color: var(--sage-deep); }
.upload-file-info p { margin: 0; color: #9a3434; font-size: 11px; line-height: 1.6; }
@@ -2871,6 +2883,8 @@ button {
.upload-file-actions { margin-left: auto; }
.upload-file-info { flex-basis: calc(100% - 54px); }
.upload-icon-button { width: 40px; height: 40px; }
.upload-list .upload-sortable { flex-wrap: nowrap; }
.upload-sortable .upload-file-info { flex-basis: auto; }
.upload-saved-banner { align-items: flex-start; }
}
.article-attachments { margin-top: 55px; padding-top: 48px; border-top: 1px solid var(--line); }
@@ -2919,6 +2933,32 @@ button {
.admin-signin p, .admin-account p { margin: 0; }
.admin-signin .publisher-field { margin: 0; }
.dialog-actions { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 12px; margin-top: 24px; }
.admin-dialog:has(.portfolio-editor) { width: min(680px, calc(100vw - 32px)); }
.portfolio-editor > fieldset { display: grid; gap: 20px; min-width: 0; margin: 0; padding: 0; border: 0; }
.portfolio-editor .publisher-field { min-width: 0; }
.portfolio-editor textarea { resize: vertical; }
.portfolio-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.portfolio-checkbox { display: flex; align-items: center; gap: 10px; font-size: 14px; }
.portfolio-checkbox input { accent-color: var(--sage-deep); }
.portfolio-projects, .portfolio-tags-editor { display: grid; gap: 12px; }
.portfolio-project { display: grid; gap: 12px; border: 1px solid var(--line); border-radius: 14px; padding: 16px; }
.portfolio-section-heading, .portfolio-tag-help { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.portfolio-tag-list { display: flex; flex-wrap: wrap; gap: 7px; }
.portfolio-tag-list > span { display: inline-flex; align-items: center; gap: 6px; padding: 5px 8px 5px 12px; border-radius: 20px; background: var(--mint); color: var(--sage-deep); font-size: 12px; overflow-wrap: anywhere; }
.portfolio-tag-list button { display: grid; place-items: center; flex-shrink: 0; width: 26px; height: 26px; background: transparent; color: inherit; border: 0; border-radius: 50%; cursor: pointer; }
.portfolio-tag-list button:hover { background: rgba(35, 65, 53, .08); }
.portfolio-tag-help small { color: var(--ink-soft); }
.portfolio-admin-toolbar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px; margin-bottom: 30px; padding: 20px; border: 1px solid var(--line); border-radius: 16px; background: var(--surface); }
.portfolio-admin-toolbar p { margin: 0; color: var(--ink-soft); font-size: 14px; }
.portfolio-admin-toolbar > div { display: flex; flex-wrap: wrap; gap: 10px; }
.portfolio-item-actions { display: flex; flex-wrap: wrap; gap: 20px; margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--line); }
.timeline-card > .portfolio-item-actions { margin: 0; padding: 14px 24px; border-top: 0; border-bottom: 1px solid var(--line); }
.portfolio-item-actions .text-button { font-size: 12px; }
@media (max-width: 520px) {
.portfolio-form-grid { grid-template-columns: 1fr; }
.portfolio-section-heading, .portfolio-tag-help { align-items: flex-start; flex-wrap: wrap; }
.portfolio-admin-toolbar .button { flex: 1; }
}
.article-admin-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 20px; }
.post-card:has(.article-admin-actions) { display: flex; flex-direction: column; }
.post-card:has(.article-admin-actions) > a { height: auto; flex: 1; }
+27
View File
@@ -0,0 +1,27 @@
import { useCallback, useEffect, useState } from "react";
import { useAdmin } from "./components/AdminSession";
export default function usePortfolioResource(fetchContent) {
const { sessionRevision } = useAdmin();
const [attempt, setAttempt] = useState(0);
const [state, setState] = useState({ data: null, loading: true, error: "" });
const reload = useCallback(() => setAttempt((value) => value + 1), []);
useEffect(() => {
const controller = new AbortController();
setState((current) => ({ ...current, loading: true, error: "" }));
fetchContent(controller.signal).then((data) => {
if (!controller.signal.aborted) setState({ data, loading: false, error: "" });
}).catch((error) => {
if (!controller.signal.aborted) setState((current) => ({ ...current, loading: false, error: error.message }));
});
return () => controller.abort();
}, [fetchContent, sessionRevision, attempt]);
useEffect(() => {
if (!state.error) return;
window.addEventListener("online", reload);
window.addEventListener("focus", reload);
return () => { window.removeEventListener("online", reload); window.removeEventListener("focus", reload); };
}, [state.error, reload]);
const setData = useCallback((update) => setState((current) => ({ ...current, data: typeof update === "function" ? update(current.data) : update, error: "" })), []);
return { ...state, reload, setData };
}
+135
View File
@@ -0,0 +1,135 @@
"""Browser regression: python -m scripts.test_attachment_reordering (build frontend first)."""
import io
import json
import os
from pathlib import Path
import shutil
import tempfile
import threading
import time
from uuid import uuid4
from PIL import Image
from playwright.sync_api import expect, sync_playwright
import uvicorn
from backend import main
def run():
# A separate server and temporary local storage never change live articles or Dropbox.
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
for source in main.DATA_DIR.glob("*.json"):
if source.name != "media-deletions.json":
shutil.copyfile(source, root / source.name)
main.DATA_DIR, main.POSTS_PATH, main.UPLOAD_DIR = root, root / "posts.json", root / "uploads"
main.get_supabase = lambda: None
main.read_data.cache_clear()
os.environ.update(JOURNAL_ADMIN_PASSWORD="browser-test", JOURNAL_TOKEN_SECRET="browser-test-secret", JOURNAL_MEDIA_STORAGE="local")
photo = io.BytesIO()
Image.new("RGB", (120, 100), "#537466").save(photo, format="PNG")
media = []
for name in ("First.png", "Second.png", "Third.png"):
uid = uuid4().hex
media.append(main.store_upload(photo.getvalue(), uid, {
"url": "/api/uploads/" + uid, "name": name, "size": len(photo.getvalue()), "media_type": "image/png",
}))
article = {"slug": "reorder-check", "title": "Attachment ordering check", "excerpt": "Checking that the order is saved and displayed consistently.",
"published_at": "2026-09-13", "read_time": 2, "tags": ["AI"], "accent": "mint", "banner": None, "attachments": media,
"content": {"type": "doc", "content": [{"type": "paragraph", "content": [{"type": "text", "text": "This article contains enough text to check reordering attachments and saving the resulting order."}]}]}}
main.POSTS_PATH.write_text(json.dumps([article]), encoding="utf-8")
server = uvicorn.Server(uvicorn.Config(main.app, host="127.0.0.1", port=8011, log_level="error"))
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
for _ in range(100):
if server.started:
break
time.sleep(.05)
assert server.started
try:
with sync_playwright() as playwright:
browser = playwright.chromium.launch(channel="msedge", headless=True)
errors = []
def open_editor(page):
page.on("pageerror", lambda error: errors.append(str(error)))
token = main.issue_admin_token()[0]
page.add_init_script("sessionStorage.setItem('alex-journal-admin', " + json.dumps(token) + ");")
page.goto("http://127.0.0.1:8011/blog/reorder-check/edit")
expect(page.get_by_role("button", name="Save changes", exact=True)).to_be_enabled()
page.get_by_role("list", name="Attachment order").scroll_into_view_if_needed()
def names(page, expected):
expect(page.locator(".upload-sortable-list .upload-file-info strong")).to_have_text(expected)
def drag(page, source, target):
start, end = source.bounding_box(), target.bounding_box()
page.mouse.move(start["x"] + start["width"] / 2, start["y"] + start["height"] / 2)
page.mouse.down()
page.mouse.move(end["x"] + end["width"] / 2, end["y"] + end["height"] / 2, steps=15)
page.mouse.up()
page = browser.new_page(viewport={"width": 1280, "height": 1000})
open_editor(page)
expect(page.get_by_role("button", name="Move First.png up", exact=True)).to_have_count(0)
drag(page, page.get_by_role("button", name="Reorder First.png"), page.get_by_role("button", name="Reorder Third.png"))
names(page, ["Second.png", "Third.png", "First.png"])
page.wait_for_timeout(250) # Let the previous drop transition finish.
handle = page.get_by_role("button", name="Reorder First.png")
handle.focus()
page.keyboard.press("Space")
expect(handle).to_have_attribute("aria-pressed", "true")
page.wait_for_timeout(50) # Keyboard sensor attaches its key listener after activation.
page.keyboard.press("ArrowUp")
page.wait_for_timeout(250)
page.keyboard.press("Space")
names(page, ["Second.png", "First.png", "Third.png"])
page.wait_for_timeout(250)
page.get_by_role("button", name="Reorder Second.png").focus()
page.keyboard.press("Space")
page.wait_for_timeout(50)
page.keyboard.press("ArrowDown")
page.keyboard.press("Escape")
names(page, ["Second.png", "First.png", "Third.png"])
drag(page, page.locator(".upload-file-info strong").filter(has_text="Second.png"), page.get_by_role("button", name="Reorder Third.png"))
names(page, ["First.png", "Third.png", "Second.png"])
page.get_by_role("button", name="Save changes", exact=True).click()
expect(page).to_have_url("http://127.0.0.1:8011/blog/reorder-check")
expect(page.locator(".article-attachments figcaption")).to_have_text(["First.png", "Third.png", "Second.png"])
assert [m["name"] for m in json.loads(main.POSTS_PATH.read_text())[0]["attachments"]] == ["First.png", "Third.png", "Second.png"]
print("Mouse handle/row drag, keyboard sorting, Escape cancellation, and saved article order passed.", flush=True)
context = browser.new_context(viewport={"width": 390, "height": 844}, is_mobile=True, has_touch=True, reduced_motion="reduce")
mobile = context.new_page()
open_editor(mobile)
first = mobile.get_by_role("button", name="Reorder First.png").bounding_box()
last = mobile.get_by_role("button", name="Reorder Second.png").bounding_box()
x, start, end = first["x"] + first["width"] / 2, first["y"] + first["height"] / 2, last["y"] + last["height"] / 2
session = context.new_cdp_session(mobile)
session.send("Input.dispatchTouchEvent", {"type": "touchStart", "touchPoints": [{"x": x, "y": start}]})
mobile.wait_for_timeout(200)
for step in range(1, 16):
session.send("Input.dispatchTouchEvent", {"type": "touchMove", "touchPoints": [{"x": x, "y": start + (end-start)*step/15}]})
mobile.wait_for_timeout(16)
session.send("Input.dispatchTouchEvent", {"type": "touchEnd", "touchPoints": []})
names(mobile, ["Third.png", "Second.png", "First.png"])
assert mobile.evaluate("document.documentElement.scrollWidth <= innerWidth")
mobile.wait_for_timeout(100) # The touch sensor briefly suppresses ghost clicks after dropping.
mobile.screenshot(path=".venv/reorder-mobile.png")
mobile.get_by_role("button", name="Remove Third.png", exact=True).tap()
names(mobile, ["Second.png", "First.png"])
mobile.get_by_role("button", name="Remove Second.png", exact=True).tap()
names(mobile, ["First.png"])
expect(mobile.get_by_role("button", name="Reorder First.png")).to_be_disabled()
expect(mobile.get_by_role("button", name="Remove First.png", exact=True)).to_be_enabled()
assert not errors, errors
print("Touch drag, mobile layout, reduced motion, and single-file removal passed.", flush=True)
browser.close()
finally:
server.should_exit = True
thread.join(timeout=5)
if __name__ == "__main__":
run()
+138
View File
@@ -0,0 +1,138 @@
"""Browser checks against isolated local data: python -m scripts.test_portfolio_editors."""
import json
import os
from pathlib import Path
import shutil
import tempfile
import threading
import time
import uvicorn
from playwright.sync_api import expect, sync_playwright
from backend import main
def run():
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
for source in main.DATA_DIR.glob("*.json"):
if source.name != "media-deletions.json":
shutil.copyfile(source, root / source.name)
main.DATA_DIR = root
main.POSTS_PATH = root / "posts.json"
main.get_supabase = lambda: None
main.read_data.cache_clear()
os.environ.update(JOURNAL_ADMIN_PASSWORD="browser-test", JOURNAL_TOKEN_SECRET="browser-test-secret")
server = uvicorn.Server(uvicorn.Config(main.app, host="127.0.0.1", port=8011, log_level="error"))
thread = threading.Thread(target=server.run, daemon=True); thread.start()
for _ in range(100):
if server.started: break
time.sleep(.05)
assert server.started
try:
with sync_playwright() as playwright:
browser = playwright.chromium.launch(channel="msedge", headless=True)
page = browser.new_page(viewport={"width": 1280, "height": 1000})
errors = []
page.on("pageerror", lambda error: errors.append(str(error)))
page.goto("http://127.0.0.1:8011/experience")
expect(page.locator(".timeline-card").first).to_be_visible()
expect(page.get_by_role("button", name="Add experience", exact=True)).to_have_count(0)
def sign_in():
dialog = page.get_by_role("dialog", name="Admin sign in", exact=True)
dialog.get_by_label("Admin password").fill("browser-test")
dialog.get_by_role("button", name="Sign in", exact=True).click()
expect(dialog).to_have_count(0)
page.get_by_role("button", name="Admin sign in", exact=True).click(); sign_in()
page.get_by_role("button", name="Add experience", exact=True).click()
dialog = page.get_by_role("dialog", name="Add experience", exact=True)
for label, value in [("Role", "Browser engineer"), ("Company", "Example team"), ("Location", "Remote"), ("Start date", "2026-09"), ("Summary", "Deliver useful software and maintain reliable systems.")]:
dialog.get_by_label(label, exact=True).fill(value)
dialog.get_by_label("Highlights", exact=False).fill("First achievement\nSecond achievement")
dialog.get_by_label("Technologies", exact=True).fill("Python")
dialog.get_by_label("Technologies", exact=True).press("Enter")
expect(dialog.get_by_role("button", name="Remove Python", exact=True)).to_be_visible()
dialog.get_by_role("button", name="Add project", exact=True).click()
dialog.get_by_label("Project name", exact=True).fill("Demo")
dialog.get_by_label("Project URL", exact=True).fill("https://example.com")
dialog.get_by_role("button", name="Add experience", exact=True).click()
expect(page.get_by_role("heading", name="Browser engineer", exact=True)).to_be_visible()
page.get_by_role("button", name="Edit Browser engineer at Example team", exact=True).click()
dialog = page.get_by_role("dialog", name="Edit experience", exact=True)
dialog.get_by_label("Role", exact=True).fill("Lead browser engineer")
dialog.get_by_label("I currently work here").uncheck()
dialog.get_by_label("End date", exact=True).fill("2027-01")
dialog.get_by_role("button", name="Save changes", exact=True).click()
expect(dialog).to_have_count(0)
page.reload()
expect(page.get_by_role("heading", name="Lead browser engineer", exact=True)).to_be_visible()
page.get_by_role("button", name="Delete Lead browser engineer at Example team", exact=True).click()
page.get_by_role("dialog").get_by_role("button", name="Cancel", exact=True).click()
expect(page.get_by_role("heading", name="Lead browser engineer", exact=True)).to_be_visible()
page.get_by_role("button", name="Delete Lead browser engineer at Example team", exact=True).click()
page.get_by_role("dialog").get_by_role("button", name="Delete entry", exact=True).click()
expect(page.get_by_role("heading", name="Lead browser engineer", exact=True)).to_have_count(0)
print("Experience create, edit, reload persistence, project links, labels, and confirmed deletion passed.", flush=True)
page.goto("http://127.0.0.1:8011/skills")
page.get_by_role("button", name="Add category", exact=True).click()
dialog = page.get_by_role("dialog", name="Add skill category", exact=True)
dialog.get_by_label("Category name", exact=True).fill("Browser tools")
dialog.get_by_label("Description", exact=True).fill("Tools used in a browser check.")
dialog.get_by_label("Skills", exact=True).fill("Python")
dialog.get_by_label("Skills", exact=True).press("Enter")
dialog.get_by_role("button", name="Add category", exact=True).click()
expect(page.get_by_role("heading", name="Browser tools", exact=True)).to_be_visible()
page.get_by_role("button", name="Edit Browser tools", exact=True).click()
dialog = page.get_by_role("dialog", name="Edit skill category", exact=True)
dialog.get_by_role("button", name="Remove Python", exact=True).click()
dialog.get_by_label("Skills", exact=True).fill("TypeScript")
dialog.get_by_label("Skills", exact=True).press("Enter")
dialog.get_by_label("Category name", exact=True).fill("Updated browser tools")
failed = [False]
def expire_once(route):
if route.request.method == "PUT" and not failed[0]:
failed[0] = True
route.fulfill(status=401, content_type="application/json", body=json.dumps({"detail": "Your session ended."}))
else: route.continue_()
page.route("**/api/skills/categories/*", expire_once)
dialog.get_by_role("button", name="Save changes", exact=True).click()
expect(dialog).to_contain_text("Your edits are still here")
dialog.get_by_role("button", name="Sign in", exact=True).click(); sign_in()
expect(dialog.get_by_label("Category name", exact=True)).to_have_value("Updated browser tools")
dialog.get_by_role("button", name="Save changes", exact=True).click()
expect(dialog).to_have_count(0)
page.reload()
card = page.locator(".skill-card").filter(has=page.get_by_role("heading", name="Updated browser tools", exact=True))
expect(card.locator(".skill-tags")).to_have_text("TypeScript")
page.get_by_role("button", name="Edit overview", exact=True).click()
dialog = page.get_by_role("dialog", name="Edit skills overview", exact=True)
dialog.get_by_label("Introduction", exact=True).fill("A toolkit that evolves with my work.")
dialog.get_by_label("How I work", exact=False).fill("Keep learning\nShip thoughtfully")
dialog.get_by_role("button", name="Save changes", exact=True).click()
expect(dialog).to_have_count(0)
page.reload()
expect(page.locator(".principles-list")).to_contain_text("Ship thoughtfully")
page.get_by_role("button", name="Delete Updated browser tools", exact=True).click()
page.get_by_role("dialog").get_by_role("button", name="Delete entry", exact=True).click()
expect(page.get_by_role("heading", name="Updated browser tools", exact=True)).to_have_count(0)
page.set_viewport_size({"width": 390, "height": 844})
page.get_by_role("button", name="Add category", exact=True).click()
page.get_by_role("dialog").screenshot(path=".venv/skills-editor-mobile.png")
assert page.evaluate("document.documentElement.scrollWidth <= innerWidth")
page.get_by_role("dialog").get_by_role("button", name="Cancel", exact=True).click()
page.get_by_role("button", name="Admin account", exact=True).click()
page.get_by_role("button", name="Sign out", exact=True).click()
expect(page.get_by_role("button", name="Add category", exact=True)).to_have_count(0)
expect(page.locator(".portfolio-item-actions")).to_have_count(0)
assert not errors, errors
print("Skill category/label CRUD, overview editing, session recovery, mobile layout, and public-only controls passed.", flush=True)
browser.close()
finally:
server.should_exit = True; thread.join(timeout=5)
if __name__ == "__main__":
run()