diff --git a/README.md b/README.md index cab9f1a..0629968 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,8 @@ npm run dev Open `http://localhost:5173`. Vite proxies `/api` requests to FastAPI on port 8000. +The interface loads Roboto Flex from Google Fonts for its compact UI copy, while editorial headings keep the résumé-inspired serif treatment. + ## Contact-form delivery The form uses `POST /api/contact` and sends email through SMTP. Copy `backend/.env.example` to `backend/.env`, fill in the SMTP values, and start the API with the environment file: @@ -52,6 +54,19 @@ uvicorn backend.main:app --reload --port 8000 --env-file backend\.env If SMTP is unavailable, the API returns a clear delivery error and the page keeps Alex's direct email, LinkedIn, and GitHub links visible. +## Journal publisher + +Open `http://localhost:5173/blog/manage` and sign in with the journal admin password. Publishing writes the new article directly to `backend/data/posts.json`; no database is involved. + +Set unique production values in `backend/.env`: + +```dotenv +JOURNAL_ADMIN_PASSWORD=replace-with-a-strong-password +JOURNAL_TOKEN_SECRET=replace-with-a-long-random-secret +``` + +The login endpoint returns a signed session that expires after four hours. The password remains server-side and the browser stores only the temporary token. + ## Content API - `GET /api/profile` @@ -59,6 +74,9 @@ If SMTP is unavailable, the API returns a clear delivery error and the page keep - `GET /api/skills` - `GET /api/posts` - `GET /api/posts/{slug}` +- `POST /api/auth/login` +- `GET /api/auth/session` +- `POST /api/posts` (authenticated) - `GET /api/resume` - `POST /api/contact` @@ -74,4 +92,3 @@ uvicorn backend.main:app --host 0.0.0.0 --port 8000 ``` When `frontend/dist` exists, FastAPI serves the built single-page application and supports direct links such as `/experience` and `/blog/{slug}`. - diff --git a/backend/.env.example b/backend/.env.example index 0c11eaf..5941222 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -13,3 +13,6 @@ SMTP_USE_SSL=false # Comma-separated origins allowed to call the API during development. FRONTEND_ORIGINS=http://localhost:5173 +# Lightweight journal publisher authentication. Use long, unique values in production. +JOURNAL_ADMIN_PASSWORD=replace-with-a-strong-password +JOURNAL_TOKEN_SECRET=replace-with-a-long-random-secret diff --git a/backend/data/profile.json b/backend/data/profile.json index fb97581..f082b25 100644 --- a/backend/data/profile.json +++ b/backend/data/profile.json @@ -28,6 +28,25 @@ "kind": "github" } ], + "interests": [ + { + "label": "Go to Spotify", + "note": "Often playing", + "href": "https://open.spotify.com/user/orbitrix", + "kind": "spotify" + }, + { + "label": "Open Instagram", + "note": "Life outside code", + "href": "https://www.instagram.com/alexanderherlan2/", + "kind": "instagram" + }, + { + "label": "Find me on Steam", + "note": "Occasionally gaming", + "href": "https://steamcommunity.com/profiles/76561197961799136", + "kind": "steam" + } + ], "focus": ["Product engineering", "Applied AI", "Cloud systems"] } - diff --git a/backend/main.py b/backend/main.py index 8b7e4e5..1c9c7d6 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,16 +1,21 @@ from __future__ import annotations import json +import hashlib +import hmac import os import re import smtplib import ssl +import threading +import time +from datetime import date from email.message import EmailMessage from functools import lru_cache from pathlib import Path -from typing import Any +from typing import Any, Literal -from fastapi import FastAPI, HTTPException, Query +from fastapi import Depends, FastAPI, Header, HTTPException, Query, status from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse from pydantic import BaseModel, EmailStr, Field @@ -22,6 +27,9 @@ PROJECT_DIR = BASE_DIR.parent DATA_DIR = BASE_DIR / "data" FRONTEND_DIST = PROJECT_DIR / "frontend" / "dist" RESUME_PATH = PROJECT_DIR / "Alexander Herlan Resume 2024.pdf" +POSTS_PATH = DATA_DIR / "posts.json" +POSTS_LOCK = threading.Lock() +ADMIN_TOKEN_TTL = 60 * 60 * 4 app = FastAPI( @@ -52,6 +60,25 @@ class ContactPayload(BaseModel): company: str = Field(default="", max_length=200) +class LoginPayload(BaseModel): + password: str = Field(min_length=1, max_length=200) + + +class ArticleSection(BaseModel): + heading: str = Field(min_length=3, max_length=160) + paragraphs: list[str] = Field(min_length=1, max_length=12) + + +class NewPostPayload(BaseModel): + title: str = Field(min_length=5, max_length=160) + excerpt: str = Field(min_length=20, max_length=360) + published_at: date + read_time: int = Field(ge=1, le=60) + tags: list[str] = Field(min_length=1, max_length=6) + accent: Literal["blue", "lavender", "peach", "yellow", "mint"] = "mint" + content: list[ArticleSection] = Field(min_length=1, max_length=8) + + @lru_cache(maxsize=8) def read_data(filename: str) -> Any: path = DATA_DIR / filename @@ -60,6 +87,64 @@ def read_data(filename: str) -> Any: return json.loads(path.read_text(encoding="utf-8")) +def journal_credentials() -> tuple[str, str]: + password = os.getenv("JOURNAL_ADMIN_PASSWORD", "") + token_secret = os.getenv("JOURNAL_TOKEN_SECRET", "") + if not password or not token_secret: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Journal publishing is not configured.", + ) + return password, token_secret + + +def issue_admin_token() -> tuple[str, int]: + _, token_secret = journal_credentials() + expires_at = int(time.time()) + ADMIN_TOKEN_TTL + payload = str(expires_at) + signature = hmac.new( + token_secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256 + ).hexdigest() + return f"{payload}.{signature}", expires_at + + +def require_admin(authorization: str | None = Header(default=None)) -> None: + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Admin sign-in required.", + ) + + token = authorization.removeprefix("Bearer ").strip() + try: + expires, supplied_signature = token.split(".", maxsplit=1) + expires_at = int(expires) + except (TypeError, ValueError): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid admin session.", + ) + + _, token_secret = journal_credentials() + expected_signature = hmac.new( + token_secret.encode("utf-8"), expires.encode("utf-8"), hashlib.sha256 + ).hexdigest() + if expires_at <= int(time.time()) or not hmac.compare_digest( + supplied_signature, expected_signature + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Your admin session has expired. Please sign in again.", + ) + + +def post_slug(title: str) -> str: + slug = re.sub(r"[^a-z0-9]+", "-", title.casefold()).strip("-") + if not slug: + raise HTTPException(status_code=422, detail="The title needs letters or numbers.") + return slug + + @app.get("/api/health") def health() -> dict[str, str]: return {"status": "ok"} @@ -80,6 +165,28 @@ def skills() -> dict[str, Any]: return read_data("skills.json") +@app.post("/api/auth/login") +def admin_login(payload: LoginPayload) -> dict[str, Any]: + password, _ = journal_credentials() + if not hmac.compare_digest(payload.password, password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="That password is not correct.", + ) + token, expires_at = issue_admin_token() + return { + "access_token": token, + "token_type": "bearer", + "expires_at": expires_at, + "expires_in": ADMIN_TOKEN_TTL, + } + + +@app.get("/api/auth/session") +def admin_session(_: None = Depends(require_admin)) -> dict[str, bool]: + return {"authenticated": True} + + @app.get("/api/posts") def posts( q: str | None = Query(default=None, max_length=100), @@ -105,6 +212,40 @@ def posts( return sorted(filtered, key=lambda item: item["published_at"], reverse=True) +@app.post("/api/posts", status_code=status.HTTP_201_CREATED) +def create_post( + payload: NewPostPayload, _: None = Depends(require_admin) +) -> dict[str, Any]: + slug = post_slug(payload.title) + clean_tags = list(dict.fromkeys(tag.strip() for tag in payload.tags if tag.strip())) + if not clean_tags: + raise HTTPException(status_code=422, detail="Add at least one topic tag.") + if any(len(tag) > 40 for tag in clean_tags): + raise HTTPException(status_code=422, detail="Topic tags must be 40 characters or fewer.") + + article = payload.model_dump(mode="json") + article["slug"] = slug + article["tags"] = clean_tags + + with POSTS_LOCK: + current_posts = json.loads(POSTS_PATH.read_text(encoding="utf-8")) + if any(post["slug"] == slug for post in current_posts): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="A journal post with this title already exists.", + ) + current_posts.append(article) + temporary_path = POSTS_PATH.with_suffix(".json.tmp") + temporary_path.write_text( + json.dumps(current_posts, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + temporary_path.replace(POSTS_PATH) + read_data.cache_clear() + + return article + + @app.get("/api/posts/{slug}") def post_by_slug(slug: str) -> dict[str, Any]: if not re.fullmatch(r"[a-z0-9-]+", slug): @@ -193,4 +334,3 @@ if FRONTEND_DIST.is_dir(): if requested_file.is_file(): return FileResponse(requested_file) return FileResponse(FRONTEND_DIST / "index.html") - diff --git a/frontend/index.html b/frontend/index.html index ff0a08b..60bba13 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -8,6 +8,9 @@ content="Alex Herlan is a senior software engineer building full-stack products, applied AI, and dependable cloud systems." /> + + + Alex Herlan — Software Engineer @@ -16,4 +19,3 @@ - diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a9e1351..c3b77b1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,10 @@ "name": "alex-herlan-portfolio", "version": "1.0.0", "dependencies": { + "@tiptap/extension-text-style": "^3.31.3", + "@tiptap/pm": "^3.31.3", + "@tiptap/react": "^3.31.3", + "@tiptap/starter-kit": "^3.31.3", "react": "^19.3.0", "react-dom": "^19.3.0", "react-router-dom": "^7.18.3" @@ -18,6 +22,34 @@ "vite": "^8.3.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT", + "optional": true + }, "node_modules/@oxc-project/types": { "version": "0.149.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz", @@ -308,6 +340,475 @@ "dev": true, "license": "MIT" }, + "node_modules/@tiptap/core": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.31.3.tgz", + "integrity": "sha512-Cz50pvciQrxdSxgTkHOVz0uD0Yl/8Xt0QatGD6ILm47jW8EzyHR9RkUGs/D5IqzXKuVPntfw1ttaT926vXfiRg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.31.3.tgz", + "integrity": "sha512-fyY2XMbyDDDfOTQ1Qdrnqa1qwC9DWE4n7AfE0EKQI0G8MfLV8RaDlLDcOZDJ9JbMPY7/Gx7EjyK4NKxSW9n2hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.31.3.tgz", + "integrity": "sha512-dIuYhKk8TitKU/FeDpoTeWZhU42YgDN5npgWNjAmMmRktPdoxnH3/wGSiwXlqZgJWjehN7kPWDePWpJeAImGpQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-bubble-menu": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.31.3.tgz", + "integrity": "sha512-EV6ZnwKc++2OM/OcD54n8s1B7C9LP7GKAtdEPwu0t3BYJf3sG8Ueoinf7SgGPO9oMPg96GneOkDNm1urMV167g==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.31.3.tgz", + "integrity": "sha512-qEyyoPapPef4LO8XKaN83bxtaNzkJ4kFn/IxLnEKd4BJ3Mvi4MH2yJYlyDqwFnMoev4pMA1zBHRAt/C0THRmZw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.31.3" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.31.3.tgz", + "integrity": "sha512-SzxOqchrD2AcN3uT67PjKmRFEMOU3vNNiwNaamZJUbZrI6Hmy+bvdHJrc3jrIddyyCrAtsnAfRI0cmorW1jGfg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.31.3.tgz", + "integrity": "sha512-nvknt4FhyJQjYcvxptmeUlFsIAc8ibua3E5BN4Pim374/9RWepH4cdE9X0/qUTtguHElLx0iKtSIY3rW2qGTFA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.31.3.tgz", + "integrity": "sha512-EexgmqnyDNyGlISxo7SMrp5MygpJYmqD+0cY5jB6L1U6L4CpKKRWUt8OO1sWzEHDE1+TTvwt+WIFoIWAziOtEA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.31.3.tgz", + "integrity": "sha512-NWomSfu5CSC7VacnMSDzKT8qm66SzMfZwVPEtwY5bPpRTJgTiT1rNK0neDrrzfMN27MfylGyKWWf7Q5Qf8w/fg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.31.3" + } + }, + "node_modules/@tiptap/extension-floating-menu": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.31.3.tgz", + "integrity": "sha512-rd4VJ9PGSP9Eop8ZTEwaLZcMzMXLuJKe3hUNf58rq+zANpwM+9fI+vB7g9MAc3eXwUNDxNDVACFIL2oeBqpQ3A==", + "license": "MIT", + "optional": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@floating-ui/dom": "^1.0.0", + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.31.3.tgz", + "integrity": "sha512-EBXKb1FrVStsNYCcRGtd9jmzveCvR+eqgg1rVqoONrqFK6U7bga6LN+1dMKroP1kliDVgveYkP3vRYxqw+rFqg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.31.3" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.31.3.tgz", + "integrity": "sha512-QAdCvNO4+yW9ATwsrej11NTkDYFqPLIEQr3ARNrKOK1qaiS7A0fia2SEukb/hrkP3A6mbozhoQt2r2RGUf/DpQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.31.3.tgz", + "integrity": "sha512-rk5VHMAeQcg06SLauN6EGdD2jc0O2qY8QkZYPd0LxNvLblw2BxBx+lxUQSYwLAT9Ie5914gKIK2YbRyO2Ts3ig==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.31.3.tgz", + "integrity": "sha512-YnHGy2KShRwvCseAmmxl9VP7R0qaj8QMp3DA6DJWZqp7r5gLGvDkAqhedxqqefqsE4Y43hjkBdjtB9Ce78LkIw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.31.3.tgz", + "integrity": "sha512-ibGvdvAPyfxBMUVNRI43eb9h2/Jka1MRG5GtnGqbcCX/2+Y/y0EOfFrPQPkuYphiWQYyu9+FzPKMB/pjuaLuKQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-link": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.31.3.tgz", + "integrity": "sha512-986wOQzTL9Zr5lf84LCLpm+YOms8A0K39/8DVoqRfebqcOe0/eq4bnztmAlfabOd+kJY92g3AgZERFUx/w+dcw==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.3.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-list": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.31.3.tgz", + "integrity": "sha512-LoveGnC0FVdCV4jUNBaG1ZA+KWE07+adzV3kGy6uUYFcJEjbVUHTnPDrBOob3IwOSO3sCwIkvQb6EVYeXn/4yg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.31.3.tgz", + "integrity": "sha512-4QlKOriJJMvg95QJTEsy9BUYPBQ6UvJyb8WURRwdUtQUkkqb8h32lg/eyQUv2FzW9IT1AdUyNZfVaxH64kRfQA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.31.3" + } + }, + "node_modules/@tiptap/extension-list-keymap": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.31.3.tgz", + "integrity": "sha512-If8UOEdDZbPJU6iYTvLtH6DOp2KBy6BKxg9UELL1AevVetGHEF/7lW8hP50Gn1tHMVpPRqmhSzVrJRpEJJgb/Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.31.3" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.31.3.tgz", + "integrity": "sha512-mp3g11NgA/PYu8rj7J7Ez3l4qBy6WfTSmHIG4PZvEGG5w2oUAIkgb9DU7nPPzjmeme27oazFYZw+AtZA0+u4tw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.31.3" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.31.3.tgz", + "integrity": "sha512-+iPku7wJfy5hbNDNLX8dveFtYVsZMmh7vztjuq8hT3mipSC4IByDehDbU8fzUCjfXiEzmI7mQn7c8LGmHULuzA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.31.3.tgz", + "integrity": "sha512-G29bhKttYwcKHT+BI6emWVFol3RO/gUXxQVcmr/iT8LXXy7j8J6HFUnsKM+Kg5YlP1rxMRgsa65dbrQVahZi0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.31.3.tgz", + "integrity": "sha512-gdsWtF+taeaCu6V+5Ct10fGo0ACUy1GnYtbb+mcathBt8OqbT+Ws60p/yEmKesBDz2Hn+B5IcWy6+2BBZl5ZTg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-text-style": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.31.3.tgz", + "integrity": "sha512-wgjWWrjZwRZHiaDTQPX2am1y/4ePgRgGWF/2MOfSb7g4d5229p4aVtbT1JT7wu9z8OC482pEqcTlhdvgftvW7A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-underline": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.31.3.tgz", + "integrity": "sha512-HghdJaOwRqYzsAxqSyNyb+IWyOMcdCl8IoiBETA9BZCJAqdXzFLcuWp7CqCqJPam9dgkWosSoHqTCAO4nTBfpw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extensions": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.31.3.tgz", + "integrity": "sha512-8sJNPGGUe8f3aDojcOW5cfVL7I5NrBbE0UWxG08qoi9Tea6qWbvQJsCR9tsrOapr/DaLr3kpbGZ9s1gEEfcNcA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/pm": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.31.3.tgz", + "integrity": "sha512-sZime0SWsz/k62W2WvHx5Ig7G2h7kVhrrmnqy+wEgIHfDwEfOlelRjaWCiBCFlF7dxGUntJusCh9FxlLhni0Ag==", + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.4.1", + "prosemirror-commands": "^1.7.1", + "prosemirror-dropcursor": "^1.8.2", + "prosemirror-gapcursor": "^1.4.1", + "prosemirror-history": "^1.5.0", + "prosemirror-inputrules": "^1.5.1", + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.11", + "prosemirror-schema-list": "^1.5.1", + "prosemirror-state": "^1.4.4", + "prosemirror-tables": "^1.8.5", + "prosemirror-transform": "^1.12.0", + "prosemirror-view": "^1.42.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/react": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.31.3.tgz", + "integrity": "sha512-QiwQqvaLFLm5EMFu5tg7nAgXJxCUiUTLD8EsK+TqVV5P4bqOoMOCM39khbhXTJyahCuYpiFWx5YOSDtC/JiPtg==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "fast-equals": "^5.3.3", + "use-sync-external-store": "^1.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "optionalDependencies": { + "@tiptap/extension-bubble-menu": "^3.31.3", + "@tiptap/extension-floating-menu": "^3.31.3" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.31.3.tgz", + "integrity": "sha512-WKof9RewdmGHvWJ1wn0/HVNG2mV+HOgVRyJkKekuM9fgr6BZAAH/xZsWE1eon+94JnQ+KtK2ThydXQM/qc6b2A==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/extension-blockquote": "3.31.3", + "@tiptap/extension-bold": "3.31.3", + "@tiptap/extension-bullet-list": "3.31.3", + "@tiptap/extension-code": "3.31.3", + "@tiptap/extension-code-block": "3.31.3", + "@tiptap/extension-document": "3.31.3", + "@tiptap/extension-dropcursor": "3.31.3", + "@tiptap/extension-gapcursor": "3.31.3", + "@tiptap/extension-hard-break": "3.31.3", + "@tiptap/extension-heading": "3.31.3", + "@tiptap/extension-horizontal-rule": "3.31.3", + "@tiptap/extension-italic": "3.31.3", + "@tiptap/extension-link": "3.31.3", + "@tiptap/extension-list": "3.31.3", + "@tiptap/extension-list-item": "3.31.3", + "@tiptap/extension-list-keymap": "3.31.3", + "@tiptap/extension-ordered-list": "3.31.3", + "@tiptap/extension-paragraph": "3.31.3", + "@tiptap/extension-strike": "3.31.3", + "@tiptap/extension-text": "3.31.3", + "@tiptap/extension-underline": "3.31.3", + "@tiptap/extensions": "3.31.3", + "@tiptap/pm": "3.31.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@vitejs/plugin-react": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", @@ -351,6 +852,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT", + "peer": true + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -361,6 +869,15 @@ "node": ">=8" } }, + "node_modules/fast-equals": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.2.tgz", + "integrity": "sha512-Ywe6jodPTWOTL9/k0bV7gdfP8twKL5Y8I8CZ933fAY5gBekICZSUQTbyH6ut2NZCNyB05mSUwAuEqdEIaOOlDQ==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -666,6 +1183,12 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/linkifyjs": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz", + "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.19", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", @@ -685,6 +1208,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -734,6 +1263,145 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prosemirror-changeset": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.2.tgz", + "integrity": "sha512-ViYrjMSg3YFiXwIhKaluu+/mi3Yrxt6AR8ri14ulTaGcZtXO1CThl7A2gv79qx5fQnOw8woKwyBU2u+9PVCm3w==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz", + "integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.3.tgz", + "integrity": "sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.11", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.11.tgz", + "integrity": "sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.1.tgz", + "integrity": "sha512-t4F5615FycnCqsX7ShTUs8+jfnwcf46kuFRvSl/3qFx2QTZ4DjgowesLHQXcFP7G//TjesqZG3WtgL7vdyVJyA==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.42.3", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.3.tgz", + "integrity": "sha512-oTN7EtH+CpwxU9NrwEYWd0UZ4JUx7l048l5A2Xppm4p/60isZYLnth9QVQmC3VRIvdrIWCxwZSd+Uz791G31/w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.8", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, "node_modules/react": { "version": "19.3.0", "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", @@ -827,6 +1495,12 @@ "@rolldown/binding-win32-x64-msvc": "1.2.8" } }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", @@ -866,6 +1540,15 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "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", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", @@ -943,6 +1626,12 @@ "optional": true } } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" } } } diff --git a/frontend/package.json b/frontend/package.json index 5154161..61b6ea7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,10 @@ "preview": "vite preview" }, "dependencies": { + "@tiptap/extension-text-style": "^3.31.3", + "@tiptap/pm": "^3.31.3", + "@tiptap/react": "^3.31.3", + "@tiptap/starter-kit": "^3.31.3", "react": "^19.3.0", "react-dom": "^19.3.0", "react-router-dom": "^7.18.3" diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 3a5950c..8a81ad1 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -7,6 +7,7 @@ import BlogPage from "./pages/BlogPage"; import BlogPostPage from "./pages/BlogPostPage"; import ContactPage from "./pages/ContactPage"; import ExperiencePage from "./pages/ExperiencePage"; +import JournalAdminPage from "./pages/JournalAdminPage"; import NotFoundPage from "./pages/NotFoundPage"; import SkillsPage from "./pages/SkillsPage"; @@ -45,6 +46,7 @@ export default function App() { } /> } /> } /> + } /> } /> ); } - diff --git a/frontend/src/api.js b/frontend/src/api.js index 54503b0..abd6e5a 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -17,7 +17,9 @@ async function request(path, options = {}) { } catch { // Keep the friendly fallback when a proxy or server returns non-JSON. } - throw new Error(detail); + const error = new Error(detail); + error.status = response.status; + throw error; } return response.json(); @@ -28,9 +30,24 @@ export const getExperience = (signal) => request("/api/experience", { signal }); export const getSkills = (signal) => request("/api/skills", { signal }); export const getPosts = (signal) => request("/api/posts", { signal }); export const getPost = (slug, signal) => request(`/api/posts/${slug}`, { signal }); +export const loginAdmin = (password) => + request("/api/auth/login", { + method: "POST", + body: JSON.stringify({ password }), + }); +export const getAdminSession = (token, signal) => + request("/api/auth/session", { + signal, + headers: { Authorization: `Bearer ${token}` }, + }); +export const createPost = (payload, token) => + request("/api/posts", { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: JSON.stringify(payload), + }); export const sendContactMessage = (payload) => request("/api/contact", { method: "POST", body: JSON.stringify(payload), }); - diff --git a/frontend/src/components/Icon.jsx b/frontend/src/components/Icon.jsx index 8263fc5..1dc31b1 100644 --- a/frontend/src/components/Icon.jsx +++ b/frontend/src/components/Icon.jsx @@ -27,10 +27,38 @@ const paths = { ), + instagram: ( + <> + + + + + ), + lock: ( + <> + + + + ), + logout: , menu: , + plus: , search: , send: , spark: , + spotify: ( + <> + + + + ), + steam: ( + <> + + + + + ), }; export default function Icon({ name, size = 20, className = "" }) { @@ -51,4 +79,3 @@ export default function Icon({ name, size = 20, className = "" }) { ); } - diff --git a/frontend/src/pages/BlogPage.jsx b/frontend/src/pages/BlogPage.jsx index fef6def..892a697 100644 --- a/frontend/src/pages/BlogPage.jsx +++ b/frontend/src/pages/BlogPage.jsx @@ -51,11 +51,15 @@ export default function BlogPage() { useEffect(() => { const controller = new AbortController(); getPosts(controller.signal) - .then(setPosts) + .then((result) => { + if (!controller.signal.aborted) setPosts(result); + }) .catch((requestError) => { if (requestError.name !== "AbortError") setError(requestError.message); }) - .finally(() => setLoading(false)); + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); return () => controller.abort(); }, []); @@ -80,7 +84,12 @@ export default function BlogPage() { eyebrow="Journal" title="Notes from the build." description="Practical observations on applied AI, resilient products, and the technology choices behind them." - aside={

{posts.length || 10}field notes

} + aside={( +
+

{posts.length || 10}field notes

+ Write +
+ )} />
@@ -129,4 +138,3 @@ export default function BlogPage() { ); } - diff --git a/frontend/src/pages/BlogPostPage.jsx b/frontend/src/pages/BlogPostPage.jsx index 32a546e..f32a601 100644 --- a/frontend/src/pages/BlogPostPage.jsx +++ b/frontend/src/pages/BlogPostPage.jsx @@ -21,12 +21,17 @@ export default function BlogPostPage() { useEffect(() => { const controller = new AbortController(); setLoading(true); + setError(""); getPost(slug, controller.signal) - .then(setPost) + .then((result) => { + if (!controller.signal.aborted) setPost(result); + }) .catch((requestError) => { if (requestError.name !== "AbortError") setError(requestError.message); }) - .finally(() => setLoading(false)); + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); return () => controller.abort(); }, [slug]); @@ -38,6 +43,10 @@ export default function BlogPostPage() { return
; } + if (!post) { + return
; + } + return (
@@ -72,4 +81,3 @@ export default function BlogPostPage() {
); } - diff --git a/frontend/src/pages/ContactPage.jsx b/frontend/src/pages/ContactPage.jsx index 4cd0e32..c3315df 100644 --- a/frontend/src/pages/ContactPage.jsx +++ b/frontend/src/pages/ContactPage.jsx @@ -32,6 +32,7 @@ export default function ContactPage({ profile, error }) { } const socials = profile?.socials ?? []; + const interests = profile?.interests ?? []; return (
@@ -120,6 +121,27 @@ export default function ContactPage({ profile, error }) {

I’m especially interested in full-stack product work, applied AI, and systems that make demanding workflows feel calmer.

+
+

Beyond the build

+

I love music and nearly always have something playing on Spotify.

+

Off the clock, I share a little life on Instagram and occasionally disappear into a game on Steam.

+
+ {interests.map((interest) => ( + + + {interest.note}{interest.label} + + + ))} +
+
+

Find me here

{socials.map((social) => ( @@ -139,4 +161,3 @@ export default function ContactPage({ profile, error }) { ); } - diff --git a/frontend/src/pages/JournalAdminPage.jsx b/frontend/src/pages/JournalAdminPage.jsx new file mode 100644 index 0000000..3dbdbf9 --- /dev/null +++ b/frontend/src/pages/JournalAdminPage.jsx @@ -0,0 +1,210 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { createPost, getAdminSession, loginAdmin } from "../api"; +import Icon from "../components/Icon"; +import PageHeader from "../components/PageHeader"; +import { LoadingState } from "../components/Status"; + +const TOKEN_KEY = "alex-journal-admin"; +const today = new Date().toISOString().slice(0, 10); + +const initialPost = { + title: "", + excerpt: "", + published_at: today, + read_time: 5, + tags: "AI, Engineering", + accent: "mint", + section_heading: "The idea", + body: "", +}; + +export default function JournalAdminPage() { + const navigate = useNavigate(); + const [token, setToken] = useState(() => sessionStorage.getItem(TOKEN_KEY) ?? ""); + const [checking, setChecking] = useState(Boolean(token)); + const [password, setPassword] = useState(""); + const [post, setPost] = useState(initialPost); + const [status, setStatus] = useState({ type: "idle", message: "" }); + + useEffect(() => { + if (!token) { + setChecking(false); + return undefined; + } + + const controller = new AbortController(); + getAdminSession(token, controller.signal) + .catch((error) => { + if (error.name !== "AbortError") { + sessionStorage.removeItem(TOKEN_KEY); + setToken(""); + setStatus({ type: "error", message: "Your session ended. Sign in again." }); + } + }) + .finally(() => { + if (!controller.signal.aborted) setChecking(false); + }); + return () => controller.abort(); + }, [token]); + + async function signIn(event) { + event.preventDefault(); + setStatus({ type: "sending", message: "Signing in…" }); + try { + const result = await loginAdmin(password); + sessionStorage.setItem(TOKEN_KEY, result.access_token); + setToken(result.access_token); + setPassword(""); + setStatus({ type: "success", message: "Signed in. Your session lasts four hours." }); + } catch (error) { + setStatus({ type: "error", message: error.message }); + } + } + + function signOut() { + sessionStorage.removeItem(TOKEN_KEY); + setToken(""); + setStatus({ type: "idle", message: "" }); + } + + function updatePost(event) { + setPost((current) => ({ ...current, [event.target.name]: event.target.value })); + } + + async function publish(event) { + event.preventDefault(); + const paragraphs = post.body + .split(/\n\s*\n/) + .map((paragraph) => paragraph.trim()) + .filter(Boolean); + const payload = { + title: post.title, + excerpt: post.excerpt, + published_at: post.published_at, + read_time: Number(post.read_time), + tags: post.tags.split(",").map((tag) => tag.trim()).filter(Boolean), + accent: post.accent, + content: [{ heading: post.section_heading, paragraphs }], + }; + + setStatus({ type: "sending", message: "Publishing…" }); + try { + const created = await createPost(payload, token); + setStatus({ type: "success", message: "Published. Opening the article…" }); + navigate(`/blog/${created.slug}`); + } catch (error) { + if (error.status === 401) { + sessionStorage.removeItem(TOKEN_KEY); + setToken(""); + } + setStatus({ type: "error", message: error.message }); + } + } + + return ( +
+ Authenticated : null} + /> + + {checking && } + + {!checking && !token && ( +
+ +
+

Private access

+

Sign in to write

+

The publisher uses one server-side password. It is never stored in the browser.

+
+ + + {status.message &&

{status.message}

} +
+ )} + + {!checking && token && ( +
+
+
+

New article

+

Paragraphs are separated by a blank line.

+
+ +
+ + + +