diff --git a/.gitignore b/.gitignore
index 79d51c2..75c36d8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,10 +11,10 @@ venv/
# Local configuration
.env
backend/.env
+backend/data/uploads/
# Editors and operating systems
.DS_Store
Thumbs.db
.idea/
.vscode/
-
diff --git a/README.md b/README.md
index 0629968..d75fc3c 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,14 @@
# Alex Herlan Portfolio
-A custom React and FastAPI portfolio built from Alex Herlan's résumé. The interface uses hand-written CSS—no Tailwind or component library—and all profile, career, skills, and journal content is loaded from JSON through the API.
+A custom React and FastAPI portfolio built from Alex Herlan's résumé. The interface uses hand-written CSS—no Tailwind or component library. Profile, career, and skill content comes from JSON; journal articles use Supabase through FastAPI with a local JSON fallback.
## Project structure
```text
backend/
- data/ JSON content store
+ data/ Local content and article fallback
+ supabase/schema.sql Supabase articles table and security setup
+ seed_supabase.py One-time migration for the ten starter articles
main.py FastAPI routes, SMTP delivery, and production serving
requirements.txt
frontend/
@@ -29,7 +31,7 @@ pip install -r backend\requirements.txt
Start FastAPI:
```powershell
-uvicorn backend.main:app --reload --port 8000
+uvicorn backend.main:app --reload --port 8000 --env-file backend\.env
```
In a second terminal, start React:
@@ -56,7 +58,7 @@ If SMTP is unavailable, the API returns a clear delivery error and the page keep
## 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.
+Open `http://localhost:5173/blog/manage` and sign in with the journal admin password. The writing room uses Tiptap for headings, bold and italic text, lists, undo/redo, and inline font sizes. Publishing writes to Supabase when it is configured, or to `backend/data/posts.json` during local fallback mode.
Set unique production values in `backend/.env`:
@@ -67,6 +69,28 @@ 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.
+## Supabase article storage
+
+The runtime backend needs two values from **Supabase Dashboard → Settings → API Keys**:
+
+```dotenv
+SUPABASE_URL=https://your-project-ref.supabase.co
+SUPABASE_SECRET_KEY=sb_secret_replace_me
+SUPABASE_ARTICLES_TABLE=articles
+```
+
+Use the new `sb_secret_...` key when available. It bypasses Row Level Security and must stay only in `backend/.env`; never add it to React or commit it. The older `service_role` key is accepted as a compatibility fallback through `SUPABASE_SERVICE_ROLE_KEY`.
+
+To connect a project without creating anything manually in the Supabase console:
+
+1. Add the URL, secret key, project ref, and scoped access token to `backend/.env`.
+2. Run `bash scripts/setup_supabase.sh` from the repository root.
+3. Restart FastAPI.
+
+The Bash command applies `backend/supabase/schema.sql` through the Management API and imports all ten starter articles. Use `bash scripts/setup_supabase.sh --schema-only` when you want the table without seed data.
+
+The access token must be project-scoped with **Database: Read-write** permission. Supabase recommends scoped access tokens for agents and automation because their reach can be limited to one project. Do not paste database passwords, access tokens, or secret keys into chat.
+
## Content API
- `GET /api/profile`
@@ -80,7 +104,7 @@ The login endpoint returns a signed session that expires after four hours. The p
- `GET /api/resume`
- `POST /api/contact`
-Edit the files in `backend/data` to update portfolio content. No database is required.
+Edit the files in `backend/data` to update profile, career, and skill content. The health endpoint reports `articles: supabase` when the remote journal store is connected and `articles: json-fallback` otherwise.
## Production build
diff --git a/backend/.env.example b/backend/.env.example
index 5941222..112f8bb 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -16,3 +16,12 @@ 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
+
+# Server-only Supabase article storage. Prefer the new sb_secret_ key.
+SUPABASE_URL=https://your-project-ref.supabase.co
+SUPABASE_SECRET_KEY=sb_secret_replace_me
+SUPABASE_ARTICLES_TABLE=articles
+
+# Optional: only needed to apply schema changes through the Management API.
+SUPABASE_PROJECT_REF=your-project-ref
+SUPABASE_ACCESS_TOKEN=sbp_your_scoped_personal_access_token
diff --git a/backend/main.py b/backend/main.py
index 1c9c7d6..ea88a67 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -1,25 +1,29 @@
from __future__ import annotations
-import json
import hashlib
import hmac
+import json
import os
import re
import smtplib
import ssl
import threading
import time
+import io
+from uuid import uuid4
from datetime import date
from email.message import EmailMessage
from functools import lru_cache
from pathlib import Path
from typing import Any, Literal
-from fastapi import Depends, FastAPI, Header, HTTPException, Query, status
+from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, status
+from PIL import Image, UnidentifiedImageError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from pydantic import BaseModel, EmailStr, Field
from starlette.concurrency import run_in_threadpool
+from supabase import Client, create_client
BASE_DIR = Path(__file__).resolve().parent
@@ -30,12 +34,21 @@ 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
+UPLOAD_DIR = Path(os.getenv("JOURNAL_UPLOAD_DIR", str(DATA_DIR / "uploads"))).resolve()
+MAX_UPLOAD_BYTES = 20 * 1024 * 1024
+
+
+class ArticleMedia(BaseModel):
+ url: str = Field(pattern=r"^/api/uploads/[a-f0-9]{32}$")
+ name: str = Field(min_length=1, max_length=200)
+ media_type: str = Field(max_length=100)
+ size: int = Field(gt=0, le=MAX_UPLOAD_BYTES)
app = FastAPI(
title="Alex Herlan Portfolio API",
- description="JSON-backed content and contact delivery for Alex Herlan's portfolio.",
- version="1.0.0",
+ description="Supabase-ready content and contact delivery for Alex Herlan's portfolio.",
+ version="1.1.0",
)
origins = [
@@ -64,11 +77,6 @@ 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)
@@ -76,7 +84,9 @@ class NewPostPayload(BaseModel):
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)
+ content: dict[str, Any]
+ banner: ArticleMedia | None = None
+ attachments: list[ArticleMedia] = Field(default_factory=list, max_length=10)
@lru_cache(maxsize=8)
@@ -87,6 +97,77 @@ def read_data(filename: str) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
+@lru_cache(maxsize=1)
+def get_supabase() -> Client | None:
+ url = os.getenv("SUPABASE_URL", "").strip()
+ key = (
+ os.getenv("SUPABASE_SECRET_KEY", "").strip()
+ or os.getenv("SUPABASE_SERVICE_ROLE_KEY", "").strip()
+ )
+ if not url and not key:
+ return None
+ if not url or not key:
+ raise HTTPException(
+ status_code=500,
+ detail="Supabase configuration is incomplete.",
+ )
+ return create_client(url, key)
+
+
+def articles_table() -> str:
+ table = os.getenv("SUPABASE_ARTICLES_TABLE", "articles").strip()
+ if not re.fullmatch(r"[a-z][a-z0-9_]*", table):
+ raise HTTPException(status_code=500, detail="Invalid Supabase table name.")
+ return table
+
+
+def article_records(include_content: bool = True) -> list[dict[str, Any]]:
+ client = get_supabase()
+ if client is None:
+ records = read_data("posts.json")
+ if include_content:
+ return records
+ return [
+ {key: value for key, value in post.items() if key != "content"}
+ for post in records
+ ]
+
+ columns = "slug,title,excerpt,published_at,read_time,tags,accent,banner,attachments"
+ if include_content:
+ columns += ",content"
+ try:
+ response = (
+ client.table(articles_table())
+ .select(columns)
+ .order("published_at", desc=True)
+ .execute()
+ )
+ except Exception as exc:
+ raise HTTPException(
+ status_code=status.HTTP_502_BAD_GATEWAY,
+ detail="The journal store is temporarily unavailable.",
+ ) from exc
+ return response.data or []
+
+
+def tiptap_plain_text(document: dict[str, Any]) -> str:
+ fragments: list[str] = []
+
+ def walk(node: Any) -> None:
+ if isinstance(node, dict):
+ text = node.get("text")
+ if isinstance(text, str):
+ fragments.append(text)
+ for child in node.get("content", []):
+ walk(child)
+ elif isinstance(node, list):
+ for child in node:
+ walk(child)
+
+ walk(document)
+ return " ".join(fragments).strip()
+
+
def journal_credentials() -> tuple[str, str]:
password = os.getenv("JOURNAL_ADMIN_PASSWORD", "")
token_secret = os.getenv("JOURNAL_TOKEN_SECRET", "")
@@ -147,7 +228,10 @@ def post_slug(title: str) -> str:
@app.get("/api/health")
def health() -> dict[str, str]:
- return {"status": "ok"}
+ return {
+ "status": "ok",
+ "articles": "supabase" if get_supabase() is not None else "json-fallback",
+ }
@app.get("/api/profile")
@@ -192,7 +276,7 @@ def posts(
q: str | None = Query(default=None, max_length=100),
tag: str | None = Query(default=None, max_length=50),
) -> list[dict[str, Any]]:
- all_posts = read_data("posts.json")
+ all_posts = article_records(include_content=False)
query = q.casefold().strip() if q else None
requested_tag = tag.casefold().strip() if tag else None
@@ -212,6 +296,59 @@ def posts(
return sorted(filtered, key=lambda item: item["published_at"], reverse=True)
+def uploaded_media(upload_id: str) -> dict[str, Any]:
+ if not re.fullmatch(r"[a-f0-9]{32}", upload_id):
+ raise HTTPException(status_code=404, detail="File not found.")
+ metadata = UPLOAD_DIR / f"{upload_id}.json"
+ if not metadata.is_file() or not (UPLOAD_DIR / upload_id).is_file():
+ raise HTTPException(status_code=404, detail="File not found.")
+ return json.loads(metadata.read_text(encoding="utf-8"))
+
+
+@app.post("/api/uploads", status_code=201)
+async def upload_media(
+ request: Request,
+ name: str = Query(min_length=1, max_length=200),
+ purpose: Literal["banner", "attachment"] = "attachment",
+ _: None = Depends(require_admin),
+) -> dict[str, Any]:
+ limit = 8 * 1024 * 1024 if purpose == "banner" else MAX_UPLOAD_BYTES
+ data = bytearray()
+ async for chunk in request.stream():
+ data.extend(chunk)
+ if len(data) > limit:
+ raise HTTPException(status_code=413, detail=f"Choose a file smaller than {limit // (1024 * 1024)} MB.")
+ if not data:
+ raise HTTPException(status_code=422, detail="The selected file is empty.")
+ media_type = "application/octet-stream"
+ try:
+ with Image.open(io.BytesIO(data)) as photo:
+ photo.verify()
+ media_type = {"JPEG": "image/jpeg", "PNG": "image/png", "WEBP": "image/webp", "GIF": "image/gif"}.get(photo.format, media_type)
+ except (UnidentifiedImageError, OSError, ValueError, Image.DecompressionBombError):
+ pass
+ if purpose == "banner" and not media_type.startswith("image/"):
+ raise HTTPException(status_code=422, detail="Choose a valid JPEG, PNG, WebP, or GIF banner.")
+ upload_id = uuid4().hex
+ metadata = {"url": f"/api/uploads/{upload_id}", "name": name.replace("\\", "/").split("/")[-1] or "attachment", "media_type": media_type, "size": len(data)}
+ UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
+ (UPLOAD_DIR / upload_id).write_bytes(data)
+ (UPLOAD_DIR / f"{upload_id}.json").write_text(json.dumps(metadata), encoding="utf-8")
+ return metadata
+
+
+@app.get("/api/uploads/{upload_id}")
+def download_media(upload_id: str) -> FileResponse:
+ media = uploaded_media(upload_id)
+ return FileResponse(
+ UPLOAD_DIR / upload_id,
+ media_type=media["media_type"],
+ filename=media["name"],
+ content_disposition_type="inline" if media["media_type"].startswith("image/") else "attachment",
+ headers={"X-Content-Type-Options": "nosniff"},
+ )
+
+
@app.post("/api/posts", status_code=status.HTTP_201_CREATED)
def create_post(
payload: NewPostPayload, _: None = Depends(require_admin)
@@ -226,6 +363,43 @@ def create_post(
article = payload.model_dump(mode="json")
article["slug"] = slug
article["tags"] = clean_tags
+ for media in [article["banner"], *article["attachments"]]:
+ if media is not None and uploaded_media(media["url"].rsplit("/", 1)[-1]) != media:
+ raise HTTPException(status_code=422, detail="An attachment is invalid. Please upload it again.")
+ if article["banner"] and not article["banner"]["media_type"].startswith("image/"):
+ raise HTTPException(status_code=422, detail="The banner must be an image.")
+
+ if article["content"].get("type") != "doc" or len(tiptap_plain_text(article["content"])) < 40:
+ raise HTTPException(status_code=422, detail="The article body is too short.")
+ if len(json.dumps(article["content"], ensure_ascii=False)) > 250_000:
+ raise HTTPException(status_code=422, detail="The formatted article is too large.")
+
+ client = get_supabase()
+ if client is not None:
+ try:
+ existing = (
+ client.table(articles_table())
+ .select("slug")
+ .eq("slug", slug)
+ .limit(1)
+ .execute()
+ )
+ if existing.data:
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail="A journal post with this title already exists.",
+ )
+ created = client.table(articles_table()).insert(article).execute()
+ except HTTPException:
+ raise
+ except Exception as exc:
+ raise HTTPException(
+ status_code=status.HTTP_502_BAD_GATEWAY,
+ detail="The article could not be saved to Supabase.",
+ ) from exc
+ if not created.data:
+ raise HTTPException(status_code=502, detail="Supabase did not return the new article.")
+ return created.data[0]
with POSTS_LOCK:
current_posts = json.loads(POSTS_PATH.read_text(encoding="utf-8"))
@@ -250,7 +424,27 @@ def create_post(
def post_by_slug(slug: str) -> dict[str, Any]:
if not re.fullmatch(r"[a-z0-9-]+", slug):
raise HTTPException(status_code=404, detail="Post not found")
- for post in read_data("posts.json"):
+
+ client = get_supabase()
+ if client is not None:
+ try:
+ response = (
+ client.table(articles_table())
+ .select("slug,title,excerpt,published_at,read_time,tags,accent,content,banner,attachments")
+ .eq("slug", slug)
+ .limit(1)
+ .execute()
+ )
+ except Exception as exc:
+ raise HTTPException(
+ status_code=status.HTTP_502_BAD_GATEWAY,
+ detail="The journal store is temporarily unavailable.",
+ ) from exc
+ if response.data:
+ return response.data[0]
+ raise HTTPException(status_code=404, detail="Post not found")
+
+ for post in article_records():
if post["slug"] == slug:
return post
raise HTTPException(status_code=404, detail="Post not found")
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 3c71d24..a064abb 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -1,4 +1,5 @@
fastapi
+Pillow
uvicorn[standard]
email-validator
-
+supabase==2.31.0
diff --git a/backend/seed_supabase.py b/backend/seed_supabase.py
new file mode 100644
index 0000000..ace9745
--- /dev/null
+++ b/backend/seed_supabase.py
@@ -0,0 +1,31 @@
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+
+from dotenv import load_dotenv
+from supabase import create_client
+
+
+BASE_DIR = Path(__file__).resolve().parent
+
+
+def main() -> None:
+ load_dotenv(BASE_DIR / ".env")
+ url = os.getenv("SUPABASE_URL", "").strip()
+ key = (
+ os.getenv("SUPABASE_SECRET_KEY", "").strip()
+ or os.getenv("SUPABASE_SERVICE_ROLE_KEY", "").strip()
+ )
+ table = os.getenv("SUPABASE_ARTICLES_TABLE", "articles").strip()
+ if not url or not key:
+ raise SystemExit("Add SUPABASE_URL and SUPABASE_SECRET_KEY to backend/.env first.")
+
+ posts = json.loads((BASE_DIR / "data" / "posts.json").read_text(encoding="utf-8"))
+ response = create_client(url, key).table(table).upsert(posts, on_conflict="slug").execute()
+ print(f"Seeded {len(response.data or [])} articles into {table}.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/backend/supabase/schema.sql b/backend/supabase/schema.sql
new file mode 100644
index 0000000..eef017e
--- /dev/null
+++ b/backend/supabase/schema.sql
@@ -0,0 +1,27 @@
+create table if not exists public.articles (
+ id uuid primary key default gen_random_uuid(),
+ slug text not null unique,
+ title text not null,
+ excerpt text not null,
+ published_at date not null,
+ read_time smallint not null check (read_time between 1 and 60),
+ tags text[] not null check (cardinality(tags) between 1 and 6),
+ accent text not null default 'mint' check (
+ accent in ('blue', 'lavender', 'peach', 'yellow', 'mint')
+ ),
+ content jsonb not null,
+ created_at timestamptz not null default now()
+);
+
+create index if not exists articles_published_at_idx
+ on public.articles (published_at desc);
+
+alter table public.articles add column if not exists banner jsonb;
+alter table public.articles add column if not exists attachments jsonb not null default '[]'::jsonb;
+
+alter table public.articles enable row level security;
+
+-- Articles are exposed only through FastAPI. The server-side secret key maps to
+-- service_role and bypasses RLS; browser roles receive no table privileges.
+revoke all on table public.articles from anon, authenticated;
+grant select, insert, update, delete on table public.articles to service_role;
diff --git a/frontend/index.html b/frontend/index.html
index 60bba13..a49f35a 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -10,7 +10,7 @@
-
+
Alex Herlan — Software Engineer
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index c3b77b1..0e1c66f 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -8,6 +8,7 @@
"name": "alex-herlan-portfolio",
"version": "1.0.0",
"dependencies": {
+ "@tiptap/extension-placeholder": "^3.31.3",
"@tiptap/extension-text-style": "^3.31.3",
"@tiptap/pm": "^3.31.3",
"@tiptap/react": "^3.31.3",
@@ -629,6 +630,19 @@
"@tiptap/core": "3.31.3"
}
},
+ "node_modules/@tiptap/extension-placeholder": {
+ "version": "3.31.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.31.3.tgz",
+ "integrity": "sha512-9jYtR8ELEw7GVaruyrm4oFkPcjig9Q+crc+dpmarhBNXUmxagCdlhVzNwCJ2WJRzvBAtx59sEYqNTU38Wx8S3A==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extensions": "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",
diff --git a/frontend/package.json b/frontend/package.json
index 61b6ea7..f660ce8 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
+ "@tiptap/extension-placeholder": "^3.31.3",
"@tiptap/extension-text-style": "^3.31.3",
"@tiptap/pm": "^3.31.3",
"@tiptap/react": "^3.31.3",
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 8a81ad1..69dadc5 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,15 +1,17 @@
-import { useEffect, useState } from "react";
+import { lazy, Suspense, useEffect, useState } from "react";
import { Route, Routes, useLocation } from "react-router-dom";
import { getProfile } from "./api";
import Layout from "./components/Layout";
import AboutPage from "./pages/AboutPage";
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";
+import { LoadingState } from "./components/Status";
+
+const BlogPostPage = lazy(() => import("./pages/BlogPostPage"));
+const JournalAdminPage = lazy(() => import("./pages/JournalAdminPage"));
function ScrollToTop() {
const { pathname } = useLocation();
@@ -38,22 +40,24 @@ export default function App() {
return (
-
- }
- />
- } />
- } />
- } />
- } />
- } />
- }
- />
- } />
-
+ }>
+
+ }
+ />
+ } />
+ } />
+ } />
+ } />
+ } />
+ }
+ />
+ } />
+
+
);
}
diff --git a/frontend/src/api.js b/frontend/src/api.js
index abd6e5a..8b92874 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -4,7 +4,7 @@ async function request(path, options = {}) {
const response = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
- "Content-Type": "application/json",
+ "Content-Type": options.body instanceof File ? "application/octet-stream" : "application/json",
...options.headers,
},
});
@@ -13,7 +13,7 @@ async function request(path, options = {}) {
let detail = "Something went wrong. Please try again.";
try {
const body = await response.json();
- detail = body.detail ?? detail;
+ detail = typeof body.detail === "string" ? body.detail : detail;
} catch {
// Keep the friendly fallback when a proxy or server returns non-JSON.
}
@@ -30,6 +30,12 @@ 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 mediaUrl = (media) => `${API_BASE}${media.url}`;
+export const uploadMedia = (file, purpose, token) => request(`/api/uploads?name=${encodeURIComponent(file.name)}&purpose=${purpose}`, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${token}` },
+ body: file,
+});
export const loginAdmin = (password) =>
request("/api/auth/login", {
method: "POST",
diff --git a/frontend/src/components/RichTextEditor.jsx b/frontend/src/components/RichTextEditor.jsx
new file mode 100644
index 0000000..5ab3f3d
--- /dev/null
+++ b/frontend/src/components/RichTextEditor.jsx
@@ -0,0 +1,118 @@
+import { EditorContent, useEditor, useEditorState } from "@tiptap/react";
+import StarterKit from "@tiptap/starter-kit";
+import { TextStyleKit } from "@tiptap/extension-text-style";
+import Placeholder from "@tiptap/extension-placeholder";
+import { useEffect } from "react";
+
+const extensions = [
+ StarterKit.configure({ heading: { levels: [2, 3] } }),
+ TextStyleKit.configure({
+ backgroundColor: false,
+ color: false,
+ fontFamily: false,
+ lineHeight: false,
+ }),
+ Placeholder.configure({
+ placeholder: "Start with the idea you want the reader to keep…",
+ }),
+];
+
+function ToolbarButton({ active = false, children, label, onClick }) {
+ return (
+
+ );
+}
+
+export default function RichTextEditor({ onChange }) {
+ const editor = useEditor({
+ extensions,
+ content: "",
+ editorProps: { attributes: { "aria-label": "Article body" } },
+ onUpdate: ({ editor: currentEditor }) => {
+ onChange(currentEditor.getJSON(), currentEditor.getText().trim());
+ },
+ });
+
+ const state = useEditorState({
+ editor,
+ selector: ({ editor: currentEditor }) => ({
+ bold: currentEditor?.isActive("bold") ?? false,
+ italic: currentEditor?.isActive("italic") ?? false,
+ underline: currentEditor?.isActive("underline") ?? false,
+ heading2: currentEditor?.isActive("heading", { level: 2 }) ?? false,
+ heading3: currentEditor?.isActive("heading", { level: 3 }) ?? false,
+ bulletList: currentEditor?.isActive("bulletList") ?? false,
+ orderedList: currentEditor?.isActive("orderedList") ?? false,
+ fontSize: currentEditor?.getAttributes("textStyle").fontSize ?? "",
+ }),
+ });
+
+ if (!editor) return Preparing editor…
;
+
+ return (
+
+
+
+ editor.chain().focus().toggleBold().run()}>B
+ editor.chain().focus().toggleItalic().run()}>I
+ editor.chain().focus().toggleUnderline().run()}>U
+
+
+ editor.chain().focus().toggleHeading({ level: 2 }).run()}>H2
+ editor.chain().focus().toggleHeading({ level: 3 }).run()}>H3
+
+
+ editor.chain().focus().toggleBulletList().run()}>• List
+ editor.chain().focus().toggleOrderedList().run()}>1. List
+
+
+
+ editor.chain().focus().undo().run()}>↶
+ editor.chain().focus().redo().run()}>↷
+
+
+
+
+ );
+}
+
+export function RichTextArticle({ content }) {
+ const editor = useEditor({
+ extensions,
+ content,
+ editable: false,
+ });
+
+ useEffect(() => {
+ if (editor) editor.commands.setContent(content);
+ }, [content, editor]);
+
+ if (!editor) return null;
+ return ;
+}
diff --git a/frontend/src/pages/BlogPostPage.jsx b/frontend/src/pages/BlogPostPage.jsx
index f32a601..8ed4b57 100644
--- a/frontend/src/pages/BlogPostPage.jsx
+++ b/frontend/src/pages/BlogPostPage.jsx
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { getPost } from "../api";
import Icon from "../components/Icon";
+import { RichTextArticle } from "../components/RichTextEditor";
import { ErrorState, LoadingState } from "../components/Status";
function formatDate(date) {
@@ -58,20 +59,21 @@ export default function BlogPostPage() {
{post.title}
{post.excerpt}
-
-
AH
-
Alex Herlan{formatDate(post.published_at)} · {post.read_time} min read
+
+
+ {formatDate(post.published_at)}
+ {post.read_time} min read
- {post.content.map((section) => (
+ {Array.isArray(post.content) ? post.content.map((section) => (
{section.heading}
{section.paragraphs.map((paragraph) => {paragraph}
)}
- ))}
+ )) :
}
Thanks for reading.
diff --git a/frontend/src/pages/ContactPage.jsx b/frontend/src/pages/ContactPage.jsx
index c3315df..5e09edb 100644
--- a/frontend/src/pages/ContactPage.jsx
+++ b/frontend/src/pages/ContactPage.jsx
@@ -121,27 +121,6 @@ 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.
-
-
-
Find me here
{socials.map((social) => (
@@ -158,6 +137,29 @@ export default function ContactPage({ profile, error }) {
+
+
+
+
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.
+
+
+
);
}
diff --git a/frontend/src/pages/JournalAdminPage.jsx b/frontend/src/pages/JournalAdminPage.jsx
index 3dbdbf9..ed9fe80 100644
--- a/frontend/src/pages/JournalAdminPage.jsx
+++ b/frontend/src/pages/JournalAdminPage.jsx
@@ -3,20 +3,32 @@ import { useNavigate } from "react-router-dom";
import { createPost, getAdminSession, loginAdmin } from "../api";
import Icon from "../components/Icon";
import PageHeader from "../components/PageHeader";
+import RichTextEditor from "../components/RichTextEditor";
import { LoadingState } from "../components/Status";
const TOKEN_KEY = "alex-journal-admin";
const today = new Date().toISOString().slice(0, 10);
+const emptyDocument = { type: "doc", content: [{ type: "paragraph" }] };
+const suggestedTopics = [
+ "AI",
+ "React",
+ "FastAPI",
+ "Supabase",
+ "Python",
+ "Cloud",
+ "DevOps",
+ "Product",
+ "Reliability",
+ "Security",
+];
const initialPost = {
title: "",
excerpt: "",
published_at: today,
read_time: 5,
- tags: "AI, Engineering",
+ tags: ["AI"],
accent: "mint",
- section_heading: "The idea",
- body: "",
};
export default function JournalAdminPage() {
@@ -25,6 +37,9 @@ export default function JournalAdminPage() {
const [checking, setChecking] = useState(Boolean(token));
const [password, setPassword] = useState("");
const [post, setPost] = useState(initialPost);
+ const [article, setArticle] = useState(emptyDocument);
+ const [articleText, setArticleText] = useState("");
+ const [customTopic, setCustomTopic] = useState("");
const [status, setStatus] = useState({ type: "idle", message: "" });
useEffect(() => {
@@ -72,20 +87,45 @@ export default function JournalAdminPage() {
setPost((current) => ({ ...current, [event.target.name]: event.target.value }));
}
+ function toggleTopic(topic) {
+ setPost((current) => {
+ if (current.tags.includes(topic)) {
+ return { ...current, tags: current.tags.filter((item) => item !== topic) };
+ }
+ if (current.tags.length >= 6) {
+ setStatus({ type: "error", message: "Choose up to six topics." });
+ return current;
+ }
+ return { ...current, tags: [...current.tags, topic] };
+ });
+ }
+
+ function addCustomTopic() {
+ const topic = customTopic.trim();
+ if (!topic || post.tags.includes(topic)) return;
+ if (post.tags.length >= 6) {
+ setStatus({ type: "error", message: "Choose up to six topics." });
+ return;
+ }
+ setPost((current) => ({ ...current, tags: [...current.tags, topic] }));
+ setCustomTopic("");
+ }
+
async function publish(event) {
event.preventDefault();
- const paragraphs = post.body
- .split(/\n\s*\n/)
- .map((paragraph) => paragraph.trim())
- .filter(Boolean);
+ if (!post.tags.length) {
+ setStatus({ type: "error", message: "Choose at least one topic." });
+ return;
+ }
+ if (articleText.length < 40) {
+ setStatus({ type: "error", message: "Write at least 40 characters before publishing." });
+ return;
+ }
+
const payload = {
- title: post.title,
- excerpt: post.excerpt,
- published_at: post.published_at,
+ ...post,
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 }],
+ content: article,
};
setStatus({ type: "sending", message: "Publishing…" });
@@ -107,7 +147,7 @@ export default function JournalAdminPage() {
Authenticated : null}
/>
@@ -145,7 +185,7 @@ export default function JournalAdminPage() {
New article
-
Paragraphs are separated by a blank line.
+
Write, format, choose the topics, and publish from one clean workspace.
@@ -181,21 +221,43 @@ export default function JournalAdminPage() {
-
+
-
-
-