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
+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)