From 404e0dc16dc76f3736bf3e4f9396d648b334fd0a Mon Sep 17 00:00:00 2001 From: StormRunner06106 Date: Sun, 13 Sep 2026 10:03:21 -0700 Subject: [PATCH] =?UTF-8?q?Implemented.=20Sign=20in,=20then=20open=20Exper?= =?UTF-8?q?ience=20or=20Skills=20to:=20-=20Add,=20edit,=20and=20delete=20e?= =?UTF-8?q?xperience=20entries=20and=20project=20links.=20-=20Manage=20ski?= =?UTF-8?q?ll=20categories=20and=20individual=20skill=20labels.=20-=20Edit?= =?UTF-8?q?=20the=20skills=20introduction=20and=20=E2=80=9CHow=20I=20work?= =?UTF-8?q?=E2=80=9D=20text.=20Changes=20save=20directly=20to=20Supabase?= =?UTF-8?q?=E2=80=94no=20code=20edits=20or=20Git=20push=20needed.=20Existi?= =?UTF-8?q?ng=20content=20is=20preserved.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 14 +- backend/dropbox_storage.py | 16 +- backend/main.py | 46 +++-- backend/media_cleanup.py | 144 +++++++++++++++ backend/portfolio_content.py | 165 ++++++++++++++++++ backend/seed_portfolio.py | 25 +++ backend/supabase/schema.sql | 21 +++ backend/test_articles.py | 117 ++++++++++++- backend/test_portfolio_content.py | 119 +++++++++++++ frontend/package-lock.json | 62 +++++++ frontend/package.json | 3 + frontend/src/api.js | 16 +- frontend/src/components/AdminSession.jsx | 6 +- .../src/components/ArticleAdminActions.jsx | 2 +- frontend/src/components/ArticleUploads.jsx | 13 +- frontend/src/components/AttachmentList.jsx | 68 ++++++++ frontend/src/components/Icon.jsx | 1 + frontend/src/components/PortfolioEditors.jsx | 122 +++++++++++++ frontend/src/pages/ExperiencePage.jsx | 48 ++--- frontend/src/pages/JournalAdminPage.jsx | 26 ++- frontend/src/pages/SkillsPage.jsx | 45 ++--- frontend/src/styles.css | 40 +++++ frontend/src/usePortfolioResource.js | 27 +++ scripts/test_attachment_reordering.py | 135 ++++++++++++++ scripts/test_portfolio_editors.py | 138 +++++++++++++++ 25 files changed, 1337 insertions(+), 82 deletions(-) create mode 100644 backend/media_cleanup.py create mode 100644 backend/portfolio_content.py create mode 100644 backend/seed_portfolio.py create mode 100644 backend/test_portfolio_content.py create mode 100644 frontend/src/components/AttachmentList.jsx create mode 100644 frontend/src/components/PortfolioEditors.jsx create mode 100644 frontend/src/usePortfolioResource.js create mode 100644 scripts/test_attachment_reordering.py create mode 100644 scripts/test_portfolio_editors.py diff --git a/README.md b/README.md index cbdf3c8..dbe8940 100644 --- a/README.md +++ b/README.md @@ -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**: diff --git a/backend/dropbox_storage.py b/backend/dropbox_storage.py index e1b37a6..f874091 100644 --- a/backend/dropbox_storage.py +++ b/backend/dropbox_storage.py @@ -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 diff --git a/backend/main.py b/backend/main.py index 08524b1..450d264 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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} diff --git a/backend/media_cleanup.py b/backend/media_cleanup.py new file mode 100644 index 0000000..fe28aba --- /dev/null +++ b/backend/media_cleanup.py @@ -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 diff --git a/backend/portfolio_content.py b/backend/portfolio_content.py new file mode 100644 index 0000000..18f848b --- /dev/null +++ b/backend/portfolio_content.py @@ -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) diff --git a/backend/seed_portfolio.py b/backend/seed_portfolio.py new file mode 100644 index 0000000..46c0958 --- /dev/null +++ b/backend/seed_portfolio.py @@ -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() diff --git a/backend/supabase/schema.sql b/backend/supabase/schema.sql index ba3b9c7..bba93c7 100644 --- a/backend/supabase/schema.sql +++ b/backend/supabase/schema.sql @@ -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; diff --git a/backend/test_articles.py b/backend/test_articles.py index c79459e..70b5e76 100644 --- a/backend/test_articles.py +++ b/backend/test_articles.py @@ -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() diff --git a/backend/test_portfolio_content.py b/backend/test_portfolio_content.py new file mode 100644 index 0000000..49b2c6d --- /dev/null +++ b/backend/test_portfolio_content.py @@ -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() diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0e1c66f..e020e3a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index f660ce8..0c1faab 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/api.js b/frontend/src/api.js index 27f6e87..ad5a739 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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}`; diff --git a/frontend/src/components/AdminSession.jsx b/frontend/src/components/AdminSession.jsx index 77758a2..5cf5de2 100644 --- a/frontend/src/components/AdminSession.jsx +++ b/frontend/src/components/AdminSession.jsx @@ -108,12 +108,14 @@ export default function AdminProvider({ children }) { {open && {isAdmin ?
Admin -

You can create, edit, and delete journal articles.

+

Manage your articles, experience, and skills.

New article Manage articles + Manage experience + Manage skills
:
-

Enter the admin password to manage journal articles.

+

Enter the admin password to manage your website content.