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.

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

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.

+
+
+ {interests.map((interest) => ( + + + {interest.note}{interest.label} + + + ))} +
+
); } 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() { - +
+ Topics {post.tags.length}/6 selected +
+ {suggestedTopics.map((topic) => ( + + ))} +
+
+ setCustomTopic(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + addCustomTopic(); + } + }} + placeholder="Add another topic" + value={customTopic} + /> + +
+
- - -