Supabase was integrated to store articles, article editor is standardized editor using react component

This commit is contained in:
StormRunner06106
2026-09-12 01:39:07 -07:00
parent 14d1dbbb07
commit 3ed741d6d2
18 changed files with 1031 additions and 131 deletions
+1 -1
View File
@@ -11,10 +11,10 @@ venv/
# Local configuration
.env
backend/.env
backend/data/uploads/
# Editors and operating systems
.DS_Store
Thumbs.db
.idea/
.vscode/
+29 -5
View File
@@ -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
+9
View File
@@ -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
+207 -13
View File
@@ -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")
+2 -1
View File
@@ -1,4 +1,5 @@
fastapi
Pillow
uvicorn[standard]
email-validator
supabase==2.31.0
+31
View File
@@ -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()
+27
View File
@@ -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;
+1 -1
View File
@@ -10,7 +10,7 @@
<meta name="theme-color" content="#f7f8f4" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Roboto+Flex:opsz,wght@8..144,100..1000&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><rect width=%22100%22 height=%22100%22 rx=%2230%22 fill=%22%232f5147%22/><text x=%2250%22 y=%2264%22 text-anchor=%22middle%22 font-size=%2244%22 fill=%22white%22 font-family=%22Arial%22>AH</text></svg>" />
<title>Alex Herlan — Software Engineer</title>
</head>
+14
View File
@@ -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",
+1
View File
@@ -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",
+23 -19
View File
@@ -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 (
<Layout profile={profile}>
<ScrollToTop />
<Routes>
<Route
path="/"
element={<AboutPage error={profileError} profile={profile} />}
/>
<Route path="/experience" element={<ExperiencePage />} />
<Route path="/skills" element={<SkillsPage />} />
<Route path="/blog" element={<BlogPage />} />
<Route path="/blog/manage" element={<JournalAdminPage />} />
<Route path="/blog/:slug" element={<BlogPostPage />} />
<Route
path="/contact"
element={<ContactPage error={profileError} profile={profile} />}
/>
<Route path="*" element={<NotFoundPage />} />
</Routes>
<Suspense fallback={<div className="container content-page"><LoadingState label="Opening page" /></div>}>
<Routes>
<Route
path="/"
element={<AboutPage error={profileError} profile={profile} />}
/>
<Route path="/experience" element={<ExperiencePage />} />
<Route path="/skills" element={<SkillsPage />} />
<Route path="/blog" element={<BlogPage />} />
<Route path="/blog/manage" element={<JournalAdminPage />} />
<Route path="/blog/:slug" element={<BlogPostPage />} />
<Route
path="/contact"
element={<ContactPage error={profileError} profile={profile} />}
/>
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Suspense>
</Layout>
);
}
+8 -2
View File
@@ -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",
+118
View File
@@ -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 (
<button
aria-label={label}
className={active ? "is-active" : ""}
onClick={onClick}
title={label}
type="button"
>
{children}
</button>
);
}
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 <div className="editor-loading">Preparing editor</div>;
return (
<div className="rich-editor">
<div className="rich-editor__toolbar" aria-label="Text formatting">
<div className="editor-tool-group">
<ToolbarButton active={state?.bold} label="Bold" onClick={() => editor.chain().focus().toggleBold().run()}><strong>B</strong></ToolbarButton>
<ToolbarButton active={state?.italic} label="Italic" onClick={() => editor.chain().focus().toggleItalic().run()}><em>I</em></ToolbarButton>
<ToolbarButton active={state?.underline} label="Underline" onClick={() => editor.chain().focus().toggleUnderline().run()}><u>U</u></ToolbarButton>
</div>
<div className="editor-tool-group">
<ToolbarButton active={state?.heading2} label="Heading" onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>H2</ToolbarButton>
<ToolbarButton active={state?.heading3} label="Subheading" onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}>H3</ToolbarButton>
</div>
<div className="editor-tool-group">
<ToolbarButton active={state?.bulletList} label="Bullet list" onClick={() => editor.chain().focus().toggleBulletList().run()}> List</ToolbarButton>
<ToolbarButton active={state?.orderedList} label="Numbered list" onClick={() => editor.chain().focus().toggleOrderedList().run()}>1. List</ToolbarButton>
</div>
<label className="editor-size">
<span className="sr-only">Font size</span>
<select
aria-label="Font size"
onChange={(event) => {
const size = event.target.value;
const chain = editor.chain().focus();
if (size) chain.setFontSize(size).run();
else chain.unsetFontSize().run();
}}
value={state?.fontSize ?? ""}
>
<option value="">Normal</option>
<option value="14px">Small</option>
<option value="18px">Medium</option>
<option value="22px">Large</option>
<option value="28px">Extra large</option>
</select>
</label>
<div className="editor-tool-group editor-tool-group--history">
<ToolbarButton label="Undo" onClick={() => editor.chain().focus().undo().run()}></ToolbarButton>
<ToolbarButton label="Redo" onClick={() => editor.chain().focus().redo().run()}></ToolbarButton>
</div>
</div>
<EditorContent editor={editor} />
</div>
);
}
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 <EditorContent className="rich-article" editor={editor} />;
}
+7 -5
View File
@@ -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() {
</div>
<h1>{post.title}</h1>
<p>{post.excerpt}</p>
<div className="article-byline">
<span className="mini-avatar">AH</span>
<span><strong>Alex Herlan</strong><small>{formatDate(post.published_at)} · {post.read_time} min read</small></span>
<div className="article-byline article-byline--simple">
<Icon name="calendar" size={16} />
<span>{formatDate(post.published_at)}</span>
<span>{post.read_time} min read</span>
</div>
</div>
</header>
<div className="article-container article-content">
{post.content.map((section) => (
{Array.isArray(post.content) ? post.content.map((section) => (
<section key={section.heading}>
<h2>{section.heading}</h2>
{section.paragraphs.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
</section>
))}
)) : <RichTextArticle content={post.content} />}
<div className="article-end">
<Icon name="spark" />
<p>Thanks for reading.</p>
+23 -21
View File
@@ -121,27 +121,6 @@ export default function ContactPage({ profile, error }) {
<p>Im especially interested in full-stack product work, applied AI, and systems that make demanding workflows feel calmer.</p>
</div>
<div className="culture-card">
<p className="eyebrow">Beyond the build</p>
<h2><strong>I love music</strong> and nearly always have something playing on Spotify.</h2>
<p>Off the clock, I share a little life on Instagram and occasionally disappear into a game on Steam.</p>
<div className="culture-links">
{interests.map((interest) => (
<a
className={`culture-link culture-link--${interest.kind}`}
href={interest.href}
key={interest.kind}
rel="noreferrer"
target="_blank"
>
<span className="culture-link__icon"><Icon name={interest.kind} /></span>
<span><small>{interest.note}</small><strong>{interest.label}</strong></span>
<Icon name="arrowUpRight" size={18} />
</a>
))}
</div>
</div>
<div className="social-list">
<p className="eyebrow">Find me here</p>
{socials.map((social) => (
@@ -158,6 +137,29 @@ export default function ContactPage({ profile, error }) {
</div>
</aside>
</div>
<div className="culture-card culture-card--wide reveal">
<div className="culture-intro">
<p className="eyebrow">Beyond the build</p>
<h2><strong>I love music</strong> and nearly always have something playing on Spotify.</h2>
<p>Off the clock, I share a little life on Instagram and occasionally disappear into a game on Steam.</p>
</div>
<div className="culture-links">
{interests.map((interest) => (
<a
className={`culture-link culture-link--${interest.kind}`}
href={interest.href}
key={interest.kind}
rel="noreferrer"
target="_blank"
>
<span className="culture-link__icon"><Icon name={interest.kind} /></span>
<span><small>{interest.note}</small><strong>{interest.label}</strong></span>
<Icon name="arrowUpRight" size={18} />
</a>
))}
</div>
</div>
</section>
);
}
+90 -28
View File
@@ -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() {
<PageHeader
eyebrow="Journal studio"
title="Publish a field note."
description="A small private writing room for adding new articles to the JSON journal."
description="A focused writing room with rich-text editing and Supabase-ready storage."
aside={token ? <span className="publisher-badge"><span /> Authenticated</span> : null}
/>
@@ -145,7 +185,7 @@ export default function JournalAdminPage() {
<div className="publisher-toolbar">
<div>
<p className="eyebrow">New article</p>
<p>Paragraphs are separated by a blank line.</p>
<p>Write, format, choose the topics, and publish from one clean workspace.</p>
</div>
<button className="text-button" onClick={signOut} type="button"><Icon name="logout" size={16} /> Sign out</button>
</div>
@@ -181,21 +221,43 @@ export default function JournalAdminPage() {
</label>
</div>
<label className="publisher-field publisher-field--wide">
<span>Topics</span>
<input name="tags" onChange={updatePost} placeholder="AI, React, Systems" required value={post.tags} />
<small>Separate up to six topics with commas.</small>
</label>
<fieldset className="topic-picker">
<legend>Topics <span>{post.tags.length}/6 selected</span></legend>
<div className="topic-options">
{suggestedTopics.map((topic) => (
<button
aria-pressed={post.tags.includes(topic)}
className={post.tags.includes(topic) ? "is-active" : ""}
key={topic}
onClick={() => toggleTopic(topic)}
type="button"
>
{post.tags.includes(topic) ? "✓ " : "+ "}{topic}
</button>
))}
</div>
<div className="custom-topic">
<input
maxLength="40"
onChange={(event) => setCustomTopic(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
addCustomTopic();
}
}}
placeholder="Add another topic"
value={customTopic}
/>
<button onClick={addCustomTopic} type="button">Add topic</button>
</div>
</fieldset>
<label className="publisher-field publisher-field--wide">
<span>Section heading</span>
<input minLength="3" name="section_heading" onChange={updatePost} required value={post.section_heading} />
</label>
<label className="publisher-field publisher-field--wide">
<div className="publisher-field publisher-field--wide">
<span>Article</span>
<textarea minLength="40" name="body" onChange={updatePost} placeholder="Write the article here…" required rows="15" value={post.body} />
</label>
<RichTextEditor onChange={(content, text) => { setArticle(content); setArticleText(text); }} />
<small>{articleText.length} characters · Use headings to break longer pieces into sections.</small>
</div>
<div className="publisher-submit">
<button className="button button--primary" disabled={status.type === "sending"} type="submit">
+306 -35
View File
@@ -25,7 +25,7 @@
--container: 1160px;
--gutter: clamp(24px, 8vw, 150px);
color: var(--ink);
font-family: "Roboto Flex", Roboto, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-family: Roboto, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
@@ -182,7 +182,7 @@ button {
padding: 10px 15px;
border-radius: 13px;
color: var(--ink-soft);
font-size: 13px;
font-size: 14px;
font-weight: 620;
transition: color 160ms ease, background 160ms ease, transform 160ms ease;
}
@@ -335,7 +335,7 @@ button {
max-width: 660px;
margin: 22px 0 14px;
color: #3e514a;
font-size: clamp(18px, 1.7vw, 24px);
font-size: clamp(20px, 1.8vw, 26px);
font-weight: 540;
letter-spacing: -0.025em;
line-height: 1.35;
@@ -345,7 +345,7 @@ button {
max-width: 680px;
margin: 0;
color: var(--ink-soft);
font-size: clamp(14px, 1.1vw, 16px);
font-size: clamp(16px, 1.18vw, 18px);
line-height: 1.75;
}
@@ -367,7 +367,7 @@ button {
position: relative;
padding-left: 13px;
color: var(--ink-faint);
font-size: 11px;
font-size: 12px;
font-weight: 680;
letter-spacing: 0.04em;
text-transform: uppercase;
@@ -500,7 +500,7 @@ button {
max-width: 660px;
margin: 19px 0 0;
color: var(--ink-soft);
font-size: 16px;
font-size: 18px;
line-height: 1.75;
}
@@ -713,7 +713,7 @@ button {
.company-line {
margin: 6px 0 0;
color: var(--ink-soft);
font-size: 13px;
font-size: 14px;
font-weight: 630;
}
@@ -764,7 +764,7 @@ button {
margin-top: 0;
padding-top: 1px;
color: var(--ink-soft);
font-size: 14px;
font-size: 16px;
line-height: 1.75;
}
@@ -781,7 +781,7 @@ button {
grid-template-columns: 22px 1fr;
gap: 10px;
color: var(--ink-soft);
font-size: 13px;
font-size: 14px;
line-height: 1.65;
}
@@ -904,7 +904,7 @@ button {
.principles-list p {
margin: 0;
font-size: 12px;
font-size: 14px;
font-weight: 620;
line-height: 1.55;
}
@@ -973,7 +973,7 @@ button {
min-height: 43px;
margin: 0;
color: var(--ink-soft);
font-size: 12px;
font-size: 14px;
line-height: 1.6;
}
@@ -999,7 +999,7 @@ button {
.issue-count span {
color: var(--ink-faint);
font-family: "Roboto Flex", Roboto, ui-sans-serif, sans-serif;
font-family: Roboto, ui-sans-serif, sans-serif;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.1em;
@@ -1174,7 +1174,7 @@ button {
align-items: center;
gap: 16px;
color: var(--ink-faint);
font-size: 9px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
@@ -1201,7 +1201,7 @@ button {
.post-card__body > p {
margin: 0;
color: var(--ink-soft);
font-size: 12px;
font-size: 14px;
line-height: 1.65;
}
@@ -1334,6 +1334,21 @@ button {
margin-top: 29px;
}
.article-byline--simple {
gap: 10px;
color: var(--ink-soft);
font-size: 11px;
font-weight: 720;
letter-spacing: 0.045em;
text-transform: uppercase;
}
.article-byline--simple span + span::before {
margin-right: 10px;
color: rgba(36, 52, 47, 0.35);
content: "·";
}
.mini-avatar {
display: grid;
width: 38px;
@@ -1387,7 +1402,7 @@ button {
margin: 0;
color: #465751;
font-family: Georgia, "Times New Roman", serif;
font-size: 18px;
font-size: 19px;
line-height: 1.85;
}
@@ -1492,7 +1507,7 @@ button {
outline: 0;
color: var(--ink);
background: #fbfcfa;
font-size: 13px;
font-size: 15px;
transition: border 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
@@ -1574,7 +1589,7 @@ button {
.contact-note p {
margin: 0;
color: var(--ink-soft);
font-size: 12px;
font-size: 14px;
line-height: 1.7;
}
@@ -1625,7 +1640,7 @@ button {
.social-list strong {
overflow: hidden;
font-size: 11px;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -1727,6 +1742,23 @@ button {
box-shadow: 0 12px 34px rgba(75, 67, 95, 0.06);
}
.culture-card--wide {
margin-top: 20px;
padding: clamp(28px, 4vw, 42px);
}
.culture-intro {
display: grid;
grid-template-columns: minmax(240px, 0.8fr) minmax(320px, 1.2fr);
align-items: end;
gap: 8px 48px;
margin-bottom: 28px;
}
.culture-intro .eyebrow {
grid-column: 1 / -1;
}
.culture-card .eyebrow {
margin-bottom: 13px;
color: var(--lavender-deep);
@@ -1735,7 +1767,7 @@ button {
.culture-card h2 {
margin: 0;
color: var(--ink);
font-family: "Roboto Flex", Roboto, ui-sans-serif, sans-serif;
font-family: Roboto, ui-sans-serif, sans-serif;
font-size: 18px;
font-weight: 520;
letter-spacing: -0.025em;
@@ -1746,21 +1778,22 @@ button {
font-weight: 850;
}
.culture-card > p:not(.eyebrow) {
margin: 11px 0 20px;
.culture-intro > p:not(.eyebrow) {
margin: 0 0 2px;
color: var(--ink-soft);
font-size: 12px;
line-height: 1.65;
font-size: 15px;
line-height: 1.7;
}
.culture-links {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 9px;
}
.culture-link {
display: grid;
min-height: 58px;
min-height: 72px;
grid-template-columns: 38px minmax(0, 1fr) auto;
align-items: center;
gap: 11px;
@@ -1804,7 +1837,7 @@ button {
}
.culture-link strong {
font-size: 12px;
font-size: 14px;
font-style: italic;
font-weight: 850;
}
@@ -1897,7 +1930,7 @@ button {
outline: 0;
color: var(--ink);
background: #fbfcfa;
font-size: 13px;
font-size: 15px;
transition: border 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
@@ -1970,6 +2003,226 @@ button {
padding-top: 4px;
}
.topic-picker {
display: grid;
grid-column: 1 / -1;
gap: 13px;
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.topic-picker legend {
width: 100%;
margin-bottom: 5px;
color: var(--ink-soft);
font-size: 11px;
font-weight: 760;
letter-spacing: 0.07em;
text-transform: uppercase;
}
.topic-picker legend span {
float: right;
color: var(--ink-faint);
font-size: 10px;
font-weight: 600;
letter-spacing: 0;
text-transform: none;
}
.topic-options {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.topic-options button {
min-height: 38px;
padding-inline: 13px;
border: 0;
border-radius: 12px;
color: var(--ink-soft);
background: #f0f3ef;
font-size: 12px;
font-weight: 680;
cursor: pointer;
}
.topic-options button.is-active {
color: white;
background: var(--sage-deep);
box-shadow: 0 8px 19px rgba(47, 81, 71, 0.15);
}
.custom-topic {
display: flex;
max-width: 430px;
gap: 8px;
}
.custom-topic input {
min-width: 0;
min-height: 44px;
flex: 1;
padding-inline: 14px;
border: 1px solid rgba(54, 78, 70, 0.13);
border-radius: 13px;
outline: 0;
color: var(--ink);
background: #fbfcfa;
font-size: 14px;
}
.custom-topic input:focus {
border-color: rgba(47, 81, 71, 0.43);
box-shadow: 0 0 0 4px rgba(47, 81, 71, 0.07);
}
.custom-topic button {
flex: 0 0 auto;
padding-inline: 16px;
border: 0;
border-radius: 13px;
color: var(--sage-deep);
background: var(--mint);
font-size: 12px;
font-weight: 760;
cursor: pointer;
}
.rich-editor {
overflow: hidden;
border: 1px solid rgba(54, 78, 70, 0.14);
border-radius: 19px;
background: #fcfdfb;
transition: border 160ms ease, box-shadow 160ms ease;
}
.rich-editor:focus-within {
border-color: rgba(47, 81, 71, 0.42);
box-shadow: 0 0 0 4px rgba(47, 81, 71, 0.07);
}
.rich-editor__toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 7px;
padding: 10px 12px;
border-bottom: 1px solid var(--line);
background: #f2f5f1;
}
.editor-tool-group {
display: flex;
gap: 3px;
padding-right: 7px;
border-right: 1px solid var(--line);
}
.editor-tool-group button {
min-width: 34px;
height: 34px;
padding-inline: 8px;
border: 0;
border-radius: 10px;
color: var(--ink-soft);
background: transparent;
font-size: 12px;
cursor: pointer;
}
.editor-tool-group button:hover,
.editor-tool-group button.is-active {
color: var(--ink);
background: white;
box-shadow: 0 5px 15px rgba(46, 65, 58, 0.08);
}
.editor-size select {
width: auto;
min-height: 34px;
padding-inline: 10px 28px;
border: 0;
border-radius: 10px;
background: white;
font-size: 12px;
}
.editor-tool-group--history {
margin-left: auto;
padding-right: 0;
border-right: 0;
}
.rich-editor .tiptap {
min-height: 430px;
padding: 28px;
outline: 0;
color: #3f514a;
font-size: 18px;
line-height: 1.8;
}
.rich-editor .tiptap p.is-editor-empty:first-child::before {
height: 0;
float: left;
color: #9ba5a1;
content: attr(data-placeholder);
pointer-events: none;
}
.rich-editor .tiptap h2,
.rich-editor .tiptap h3,
.rich-article .tiptap h2,
.rich-article .tiptap h3 {
color: var(--ink);
font-family: Georgia, "Times New Roman", serif;
font-weight: 400;
letter-spacing: -0.035em;
line-height: 1.15;
}
.rich-editor .tiptap h2 { margin: 30px 0 13px; font-size: 34px; }
.rich-editor .tiptap h3 { margin: 25px 0 11px; font-size: 26px; }
.rich-editor .tiptap p { margin: 0 0 17px; }
.rich-editor .tiptap em,
.rich-article .tiptap em { font-style: italic; }
.rich-editor .tiptap u,
.rich-article .tiptap u { text-decoration-thickness: 1px; text-underline-offset: 3px; }
.rich-editor .tiptap ul,
.rich-editor .tiptap ol { padding-left: 26px; }
.rich-article .tiptap {
color: #465751;
font-family: Georgia, "Times New Roman", serif;
font-size: 19px;
line-height: 1.85;
}
.rich-article .tiptap h2 { margin: 55px 0 20px; font-size: clamp(30px, 4vw, 42px); }
.rich-article .tiptap h2:first-child { margin-top: 0; }
.rich-article .tiptap h3 { margin: 38px 0 16px; font-size: clamp(24px, 3vw, 32px); }
.rich-article .tiptap p { margin: 0 0 24px; }
.rich-article .tiptap ul,
.rich-article .tiptap ol { margin: 0 0 26px; padding-left: 28px; }
.rich-article .tiptap li { margin-bottom: 8px; }
.rich-article .tiptap blockquote {
margin: 32px 0;
padding: 20px 25px;
border-left: 3px solid #8fa99e;
border-radius: 0 16px 16px 0;
background: var(--mint);
}
.editor-loading {
min-height: 220px;
padding: 25px;
color: var(--ink-faint);
}
@media (max-width: 1120px) {
:root {
--gutter: clamp(24px, 5vw, 70px);
@@ -2060,10 +2313,6 @@ button {
.contact-aside {
grid-template-columns: 1fr 1fr;
}
.contact-aside .social-list {
grid-column: 1 / -1;
}
}
@media (max-width: 720px) {
@@ -2230,6 +2479,20 @@ button {
align-items: flex-start;
}
.culture-intro {
grid-template-columns: 1fr;
gap: 10px;
}
.culture-links {
grid-template-columns: 1fr;
}
.rich-editor .tiptap {
min-height: 350px;
padding: 22px;
}
.form-footer {
align-items: stretch;
flex-direction: column;
@@ -2295,11 +2558,11 @@ button {
}
.about-copy h2 {
font-size: 18px;
font-size: 20px;
}
.about-summary {
font-size: 14px;
font-size: 16px;
}
.hero-actions {
@@ -2324,7 +2587,7 @@ button {
}
.page-description {
font-size: 14px;
font-size: 16px;
}
.timeline-card__header {
@@ -2370,7 +2633,15 @@ button {
}
.article-content section p {
font-size: 17px;
font-size: 18px;
}
.custom-topic {
max-width: none;
}
.editor-tool-group--history {
margin-left: 0;
}
}
@@ -2392,7 +2663,7 @@ button {
}
.about-summary {
font-size: 13px;
font-size: 14px;
line-height: 1.62;
}
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env bash
set -Eeuo pipefail
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
project_dir="$(cd -- "${script_dir}/.." && pwd)"
env_file="${SUPABASE_ENV_FILE:-${project_dir}/backend/.env}"
schema_file="${project_dir}/backend/supabase/schema.sql"
seed_articles=true
if [[ "${1:-}" == "--schema-only" ]]; then
seed_articles=false
elif [[ -n "${1:-}" ]]; then
echo "Usage: bash scripts/setup_supabase.sh [--schema-only]" >&2
exit 2
fi
load_supabase_env() {
local key value
[[ -f "${env_file}" ]] || return 0
while IFS='=' read -r key value; do
key="${key%$'\r'}"
value="${value:-}"
value="${value%$'\r'}"
case "${key}" in
SUPABASE_PROJECT_REF|SUPABASE_ACCESS_TOKEN|SUPABASE_URL|SUPABASE_SECRET_KEY|SUPABASE_SERVICE_ROLE_KEY|SUPABASE_ARTICLES_TABLE)
if [[ "${value}" == \"*\" && "${value}" == *\" ]]; then
value="${value:1:${#value}-2}"
elif [[ "${value}" == \'*\' && "${value}" == *\' ]]; then
value="${value:1:${#value}-2}"
fi
export "${key}=${value}"
;;
esac
done < "${env_file}"
}
require_value() {
local variable_name="$1"
if [[ -z "${!variable_name:-}" ]]; then
echo "Missing ${variable_name}. Add it to ${env_file}." >&2
exit 1
fi
}
find_python() {
if [[ -x "${project_dir}/.venv/Scripts/python.exe" ]]; then
printf '%s' "${project_dir}/.venv/Scripts/python.exe"
elif [[ -x "${project_dir}/.venv/bin/python" ]]; then
printf '%s' "${project_dir}/.venv/bin/python"
elif command -v python3 >/dev/null 2>&1; then
command -v python3
elif command -v python >/dev/null 2>&1; then
command -v python
else
echo "Python is required to prepare the API request and seed articles." >&2
exit 1
fi
}
load_supabase_env
require_value SUPABASE_PROJECT_REF
require_value SUPABASE_ACCESS_TOKEN
if [[ ! -f "${schema_file}" ]]; then
echo "Schema file not found: ${schema_file}" >&2
exit 1
fi
if ! command -v curl >/dev/null 2>&1; then
echo "curl is required to call the Supabase Management API." >&2
exit 1
fi
python_bin="$(find_python)"
payload_file="$(mktemp)"
response_file="$(mktemp)"
cleanup() {
rm -f -- "${payload_file}" "${response_file}"
}
trap cleanup EXIT
"${python_bin}" - "${schema_file}" "${payload_file}" <<'PY'
import json
import sys
from pathlib import Path
schema_path = Path(sys.argv[1])
payload_path = Path(sys.argv[2])
payload_path.write_text(
json.dumps({"query": schema_path.read_text(encoding="utf-8"), "read_only": False}),
encoding="utf-8",
)
PY
echo "Applying the articles schema to Supabase project ${SUPABASE_PROJECT_REF}..."
http_status="$(
curl \
--silent \
--show-error \
--output "${response_file}" \
--write-out '%{http_code}' \
--request POST \
"https://api.supabase.com/v1/projects/${SUPABASE_PROJECT_REF}/database/query" \
--header "Authorization: Bearer ${SUPABASE_ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--data-binary "@${payload_file}"
)"
if [[ ! "${http_status}" =~ ^2 ]]; then
echo "Supabase rejected the schema request with HTTP ${http_status}." >&2
sed -n '1,120p' "${response_file}" >&2
exit 1
fi
echo "Supabase articles table is ready."
if [[ "${seed_articles}" == true ]]; then
require_value SUPABASE_URL
if [[ -z "${SUPABASE_SECRET_KEY:-}" && -z "${SUPABASE_SERVICE_ROLE_KEY:-}" ]]; then
echo "Missing SUPABASE_SECRET_KEY. Add the server secret to ${env_file} to seed articles." >&2
exit 1
fi
echo "Importing the starter journal articles..."
cd -- "${project_dir}"
"${python_bin}" -m backend.seed_supabase
fi
echo "Supabase setup is complete. Restart FastAPI to use the remote article store."