Supabase was integrated to store articles, article editor is standardized editor using react component
This commit is contained in:
+1
-1
@@ -11,10 +11,10 @@ venv/
|
|||||||
# Local configuration
|
# Local configuration
|
||||||
.env
|
.env
|
||||||
backend/.env
|
backend/.env
|
||||||
|
backend/data/uploads/
|
||||||
|
|
||||||
# Editors and operating systems
|
# Editors and operating systems
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
# Alex Herlan Portfolio
|
# 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
|
## Project structure
|
||||||
|
|
||||||
```text
|
```text
|
||||||
backend/
|
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
|
main.py FastAPI routes, SMTP delivery, and production serving
|
||||||
requirements.txt
|
requirements.txt
|
||||||
frontend/
|
frontend/
|
||||||
@@ -29,7 +31,7 @@ pip install -r backend\requirements.txt
|
|||||||
Start FastAPI:
|
Start FastAPI:
|
||||||
|
|
||||||
```powershell
|
```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:
|
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
|
## 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`:
|
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.
|
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
|
## Content API
|
||||||
|
|
||||||
- `GET /api/profile`
|
- `GET /api/profile`
|
||||||
@@ -80,7 +104,7 @@ The login endpoint returns a signed session that expires after four hours. The p
|
|||||||
- `GET /api/resume`
|
- `GET /api/resume`
|
||||||
- `POST /api/contact`
|
- `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
|
## Production build
|
||||||
|
|
||||||
|
|||||||
@@ -16,3 +16,12 @@ FRONTEND_ORIGINS=http://localhost:5173
|
|||||||
# Lightweight journal publisher authentication. Use long, unique values in production.
|
# Lightweight journal publisher authentication. Use long, unique values in production.
|
||||||
JOURNAL_ADMIN_PASSWORD=replace-with-a-strong-password
|
JOURNAL_ADMIN_PASSWORD=replace-with-a-strong-password
|
||||||
JOURNAL_TOKEN_SECRET=replace-with-a-long-random-secret
|
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
@@ -1,25 +1,29 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import smtplib
|
import smtplib
|
||||||
import ssl
|
import ssl
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import io
|
||||||
|
from uuid import uuid4
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Literal
|
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.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel, EmailStr, Field
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
from starlette.concurrency import run_in_threadpool
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
from supabase import Client, create_client
|
||||||
|
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent
|
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_PATH = DATA_DIR / "posts.json"
|
||||||
POSTS_LOCK = threading.Lock()
|
POSTS_LOCK = threading.Lock()
|
||||||
ADMIN_TOKEN_TTL = 60 * 60 * 4
|
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(
|
app = FastAPI(
|
||||||
title="Alex Herlan Portfolio API",
|
title="Alex Herlan Portfolio API",
|
||||||
description="JSON-backed content and contact delivery for Alex Herlan's portfolio.",
|
description="Supabase-ready content and contact delivery for Alex Herlan's portfolio.",
|
||||||
version="1.0.0",
|
version="1.1.0",
|
||||||
)
|
)
|
||||||
|
|
||||||
origins = [
|
origins = [
|
||||||
@@ -64,11 +77,6 @@ class LoginPayload(BaseModel):
|
|||||||
password: str = Field(min_length=1, max_length=200)
|
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):
|
class NewPostPayload(BaseModel):
|
||||||
title: str = Field(min_length=5, max_length=160)
|
title: str = Field(min_length=5, max_length=160)
|
||||||
excerpt: str = Field(min_length=20, max_length=360)
|
excerpt: str = Field(min_length=20, max_length=360)
|
||||||
@@ -76,7 +84,9 @@ class NewPostPayload(BaseModel):
|
|||||||
read_time: int = Field(ge=1, le=60)
|
read_time: int = Field(ge=1, le=60)
|
||||||
tags: list[str] = Field(min_length=1, max_length=6)
|
tags: list[str] = Field(min_length=1, max_length=6)
|
||||||
accent: Literal["blue", "lavender", "peach", "yellow", "mint"] = "mint"
|
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)
|
@lru_cache(maxsize=8)
|
||||||
@@ -87,6 +97,77 @@ def read_data(filename: str) -> Any:
|
|||||||
return json.loads(path.read_text(encoding="utf-8"))
|
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]:
|
def journal_credentials() -> tuple[str, str]:
|
||||||
password = os.getenv("JOURNAL_ADMIN_PASSWORD", "")
|
password = os.getenv("JOURNAL_ADMIN_PASSWORD", "")
|
||||||
token_secret = os.getenv("JOURNAL_TOKEN_SECRET", "")
|
token_secret = os.getenv("JOURNAL_TOKEN_SECRET", "")
|
||||||
@@ -147,7 +228,10 @@ def post_slug(title: str) -> str:
|
|||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
def health() -> dict[str, str]:
|
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")
|
@app.get("/api/profile")
|
||||||
@@ -192,7 +276,7 @@ def posts(
|
|||||||
q: str | None = Query(default=None, max_length=100),
|
q: str | None = Query(default=None, max_length=100),
|
||||||
tag: str | None = Query(default=None, max_length=50),
|
tag: str | None = Query(default=None, max_length=50),
|
||||||
) -> list[dict[str, Any]]:
|
) -> 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
|
query = q.casefold().strip() if q else None
|
||||||
requested_tag = tag.casefold().strip() if tag 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)
|
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)
|
@app.post("/api/posts", status_code=status.HTTP_201_CREATED)
|
||||||
def create_post(
|
def create_post(
|
||||||
payload: NewPostPayload, _: None = Depends(require_admin)
|
payload: NewPostPayload, _: None = Depends(require_admin)
|
||||||
@@ -226,6 +363,43 @@ def create_post(
|
|||||||
article = payload.model_dump(mode="json")
|
article = payload.model_dump(mode="json")
|
||||||
article["slug"] = slug
|
article["slug"] = slug
|
||||||
article["tags"] = clean_tags
|
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:
|
with POSTS_LOCK:
|
||||||
current_posts = json.loads(POSTS_PATH.read_text(encoding="utf-8"))
|
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]:
|
def post_by_slug(slug: str) -> dict[str, Any]:
|
||||||
if not re.fullmatch(r"[a-z0-9-]+", slug):
|
if not re.fullmatch(r"[a-z0-9-]+", slug):
|
||||||
raise HTTPException(status_code=404, detail="Post not found")
|
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:
|
if post["slug"] == slug:
|
||||||
return post
|
return post
|
||||||
raise HTTPException(status_code=404, detail="Post not found")
|
raise HTTPException(status_code=404, detail="Post not found")
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
fastapi
|
fastapi
|
||||||
|
Pillow
|
||||||
uvicorn[standard]
|
uvicorn[standard]
|
||||||
email-validator
|
email-validator
|
||||||
|
supabase==2.31.0
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
@@ -10,7 +10,7 @@
|
|||||||
<meta name="theme-color" content="#f7f8f4" />
|
<meta name="theme-color" content="#f7f8f4" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<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>" />
|
<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>
|
<title>Alex Herlan — Software Engineer</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
Generated
+14
@@ -8,6 +8,7 @@
|
|||||||
"name": "alex-herlan-portfolio",
|
"name": "alex-herlan-portfolio",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tiptap/extension-placeholder": "^3.31.3",
|
||||||
"@tiptap/extension-text-style": "^3.31.3",
|
"@tiptap/extension-text-style": "^3.31.3",
|
||||||
"@tiptap/pm": "^3.31.3",
|
"@tiptap/pm": "^3.31.3",
|
||||||
"@tiptap/react": "^3.31.3",
|
"@tiptap/react": "^3.31.3",
|
||||||
@@ -629,6 +630,19 @@
|
|||||||
"@tiptap/core": "3.31.3"
|
"@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": {
|
"node_modules/@tiptap/extension-strike": {
|
||||||
"version": "3.31.3",
|
"version": "3.31.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.31.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.31.3.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tiptap/extension-placeholder": "^3.31.3",
|
||||||
"@tiptap/extension-text-style": "^3.31.3",
|
"@tiptap/extension-text-style": "^3.31.3",
|
||||||
"@tiptap/pm": "^3.31.3",
|
"@tiptap/pm": "^3.31.3",
|
||||||
"@tiptap/react": "^3.31.3",
|
"@tiptap/react": "^3.31.3",
|
||||||
|
|||||||
@@ -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 { Route, Routes, useLocation } from "react-router-dom";
|
||||||
import { getProfile } from "./api";
|
import { getProfile } from "./api";
|
||||||
import Layout from "./components/Layout";
|
import Layout from "./components/Layout";
|
||||||
import AboutPage from "./pages/AboutPage";
|
import AboutPage from "./pages/AboutPage";
|
||||||
import BlogPage from "./pages/BlogPage";
|
import BlogPage from "./pages/BlogPage";
|
||||||
import BlogPostPage from "./pages/BlogPostPage";
|
|
||||||
import ContactPage from "./pages/ContactPage";
|
import ContactPage from "./pages/ContactPage";
|
||||||
import ExperiencePage from "./pages/ExperiencePage";
|
import ExperiencePage from "./pages/ExperiencePage";
|
||||||
import JournalAdminPage from "./pages/JournalAdminPage";
|
|
||||||
import NotFoundPage from "./pages/NotFoundPage";
|
import NotFoundPage from "./pages/NotFoundPage";
|
||||||
import SkillsPage from "./pages/SkillsPage";
|
import SkillsPage from "./pages/SkillsPage";
|
||||||
|
import { LoadingState } from "./components/Status";
|
||||||
|
|
||||||
|
const BlogPostPage = lazy(() => import("./pages/BlogPostPage"));
|
||||||
|
const JournalAdminPage = lazy(() => import("./pages/JournalAdminPage"));
|
||||||
|
|
||||||
function ScrollToTop() {
|
function ScrollToTop() {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
@@ -38,6 +40,7 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<Layout profile={profile}>
|
<Layout profile={profile}>
|
||||||
<ScrollToTop />
|
<ScrollToTop />
|
||||||
|
<Suspense fallback={<div className="container content-page"><LoadingState label="Opening page" /></div>}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="/"
|
||||||
@@ -54,6 +57,7 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-2
@@ -4,7 +4,7 @@ async function request(path, options = {}) {
|
|||||||
const response = await fetch(`${API_BASE}${path}`, {
|
const response = await fetch(`${API_BASE}${path}`, {
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": options.body instanceof File ? "application/octet-stream" : "application/json",
|
||||||
...options.headers,
|
...options.headers,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -13,7 +13,7 @@ async function request(path, options = {}) {
|
|||||||
let detail = "Something went wrong. Please try again.";
|
let detail = "Something went wrong. Please try again.";
|
||||||
try {
|
try {
|
||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
detail = body.detail ?? detail;
|
detail = typeof body.detail === "string" ? body.detail : detail;
|
||||||
} catch {
|
} catch {
|
||||||
// Keep the friendly fallback when a proxy or server returns non-JSON.
|
// 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 getSkills = (signal) => request("/api/skills", { signal });
|
||||||
export const getPosts = (signal) => request("/api/posts", { signal });
|
export const getPosts = (signal) => request("/api/posts", { signal });
|
||||||
export const getPost = (slug, signal) => request(`/api/posts/${slug}`, { 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) =>
|
export const loginAdmin = (password) =>
|
||||||
request("/api/auth/login", {
|
request("/api/auth/login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
import { getPost } from "../api";
|
import { getPost } from "../api";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
|
import { RichTextArticle } from "../components/RichTextEditor";
|
||||||
import { ErrorState, LoadingState } from "../components/Status";
|
import { ErrorState, LoadingState } from "../components/Status";
|
||||||
|
|
||||||
function formatDate(date) {
|
function formatDate(date) {
|
||||||
@@ -58,20 +59,21 @@ export default function BlogPostPage() {
|
|||||||
</div>
|
</div>
|
||||||
<h1>{post.title}</h1>
|
<h1>{post.title}</h1>
|
||||||
<p>{post.excerpt}</p>
|
<p>{post.excerpt}</p>
|
||||||
<div className="article-byline">
|
<div className="article-byline article-byline--simple">
|
||||||
<span className="mini-avatar">AH</span>
|
<Icon name="calendar" size={16} />
|
||||||
<span><strong>Alex Herlan</strong><small>{formatDate(post.published_at)} · {post.read_time} min read</small></span>
|
<span>{formatDate(post.published_at)}</span>
|
||||||
|
<span>{post.read_time} min read</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="article-container article-content">
|
<div className="article-container article-content">
|
||||||
{post.content.map((section) => (
|
{Array.isArray(post.content) ? post.content.map((section) => (
|
||||||
<section key={section.heading}>
|
<section key={section.heading}>
|
||||||
<h2>{section.heading}</h2>
|
<h2>{section.heading}</h2>
|
||||||
{section.paragraphs.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
|
{section.paragraphs.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
|
||||||
</section>
|
</section>
|
||||||
))}
|
)) : <RichTextArticle content={post.content} />}
|
||||||
<div className="article-end">
|
<div className="article-end">
|
||||||
<Icon name="spark" />
|
<Icon name="spark" />
|
||||||
<p>Thanks for reading.</p>
|
<p>Thanks for reading.</p>
|
||||||
|
|||||||
@@ -121,27 +121,6 @@ export default function ContactPage({ profile, error }) {
|
|||||||
<p>I’m especially interested in full-stack product work, applied AI, and systems that make demanding workflows feel calmer.</p>
|
<p>I’m especially interested in full-stack product work, applied AI, and systems that make demanding workflows feel calmer.</p>
|
||||||
</div>
|
</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">
|
<div className="social-list">
|
||||||
<p className="eyebrow">Find me here</p>
|
<p className="eyebrow">Find me here</p>
|
||||||
{socials.map((social) => (
|
{socials.map((social) => (
|
||||||
@@ -158,6 +137,29 @@ export default function ContactPage({ profile, error }) {
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,20 +3,32 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { createPost, getAdminSession, loginAdmin } from "../api";
|
import { createPost, getAdminSession, loginAdmin } from "../api";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
import PageHeader from "../components/PageHeader";
|
import PageHeader from "../components/PageHeader";
|
||||||
|
import RichTextEditor from "../components/RichTextEditor";
|
||||||
import { LoadingState } from "../components/Status";
|
import { LoadingState } from "../components/Status";
|
||||||
|
|
||||||
const TOKEN_KEY = "alex-journal-admin";
|
const TOKEN_KEY = "alex-journal-admin";
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
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 = {
|
const initialPost = {
|
||||||
title: "",
|
title: "",
|
||||||
excerpt: "",
|
excerpt: "",
|
||||||
published_at: today,
|
published_at: today,
|
||||||
read_time: 5,
|
read_time: 5,
|
||||||
tags: "AI, Engineering",
|
tags: ["AI"],
|
||||||
accent: "mint",
|
accent: "mint",
|
||||||
section_heading: "The idea",
|
|
||||||
body: "",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function JournalAdminPage() {
|
export default function JournalAdminPage() {
|
||||||
@@ -25,6 +37,9 @@ export default function JournalAdminPage() {
|
|||||||
const [checking, setChecking] = useState(Boolean(token));
|
const [checking, setChecking] = useState(Boolean(token));
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [post, setPost] = useState(initialPost);
|
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: "" });
|
const [status, setStatus] = useState({ type: "idle", message: "" });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -72,20 +87,45 @@ export default function JournalAdminPage() {
|
|||||||
setPost((current) => ({ ...current, [event.target.name]: event.target.value }));
|
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) {
|
async function publish(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const paragraphs = post.body
|
if (!post.tags.length) {
|
||||||
.split(/\n\s*\n/)
|
setStatus({ type: "error", message: "Choose at least one topic." });
|
||||||
.map((paragraph) => paragraph.trim())
|
return;
|
||||||
.filter(Boolean);
|
}
|
||||||
|
if (articleText.length < 40) {
|
||||||
|
setStatus({ type: "error", message: "Write at least 40 characters before publishing." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
title: post.title,
|
...post,
|
||||||
excerpt: post.excerpt,
|
|
||||||
published_at: post.published_at,
|
|
||||||
read_time: Number(post.read_time),
|
read_time: Number(post.read_time),
|
||||||
tags: post.tags.split(",").map((tag) => tag.trim()).filter(Boolean),
|
content: article,
|
||||||
accent: post.accent,
|
|
||||||
content: [{ heading: post.section_heading, paragraphs }],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
setStatus({ type: "sending", message: "Publishing…" });
|
setStatus({ type: "sending", message: "Publishing…" });
|
||||||
@@ -107,7 +147,7 @@ export default function JournalAdminPage() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
eyebrow="Journal studio"
|
eyebrow="Journal studio"
|
||||||
title="Publish a field note."
|
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}
|
aside={token ? <span className="publisher-badge"><span /> Authenticated</span> : null}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -145,7 +185,7 @@ export default function JournalAdminPage() {
|
|||||||
<div className="publisher-toolbar">
|
<div className="publisher-toolbar">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">New article</p>
|
<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>
|
</div>
|
||||||
<button className="text-button" onClick={signOut} type="button"><Icon name="logout" size={16} /> Sign out</button>
|
<button className="text-button" onClick={signOut} type="button"><Icon name="logout" size={16} /> Sign out</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -181,21 +221,43 @@ export default function JournalAdminPage() {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="publisher-field publisher-field--wide">
|
<fieldset className="topic-picker">
|
||||||
<span>Topics</span>
|
<legend>Topics <span>{post.tags.length}/6 selected</span></legend>
|
||||||
<input name="tags" onChange={updatePost} placeholder="AI, React, Systems" required value={post.tags} />
|
<div className="topic-options">
|
||||||
<small>Separate up to six topics with commas.</small>
|
{suggestedTopics.map((topic) => (
|
||||||
</label>
|
<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">
|
<div 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">
|
|
||||||
<span>Article</span>
|
<span>Article</span>
|
||||||
<textarea minLength="40" name="body" onChange={updatePost} placeholder="Write the article here…" required rows="15" value={post.body} />
|
<RichTextEditor onChange={(content, text) => { setArticle(content); setArticleText(text); }} />
|
||||||
</label>
|
<small>{articleText.length} characters · Use headings to break longer pieces into sections.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="publisher-submit">
|
<div className="publisher-submit">
|
||||||
<button className="button button--primary" disabled={status.type === "sending"} type="submit">
|
<button className="button button--primary" disabled={status.type === "sending"} type="submit">
|
||||||
|
|||||||
+306
-35
@@ -25,7 +25,7 @@
|
|||||||
--container: 1160px;
|
--container: 1160px;
|
||||||
--gutter: clamp(24px, 8vw, 150px);
|
--gutter: clamp(24px, 8vw, 150px);
|
||||||
color: var(--ink);
|
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;
|
font-synthesis: none;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
}
|
}
|
||||||
@@ -182,7 +182,7 @@ button {
|
|||||||
padding: 10px 15px;
|
padding: 10px 15px;
|
||||||
border-radius: 13px;
|
border-radius: 13px;
|
||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
font-weight: 620;
|
font-weight: 620;
|
||||||
transition: color 160ms ease, background 160ms ease, transform 160ms ease;
|
transition: color 160ms ease, background 160ms ease, transform 160ms ease;
|
||||||
}
|
}
|
||||||
@@ -335,7 +335,7 @@ button {
|
|||||||
max-width: 660px;
|
max-width: 660px;
|
||||||
margin: 22px 0 14px;
|
margin: 22px 0 14px;
|
||||||
color: #3e514a;
|
color: #3e514a;
|
||||||
font-size: clamp(18px, 1.7vw, 24px);
|
font-size: clamp(20px, 1.8vw, 26px);
|
||||||
font-weight: 540;
|
font-weight: 540;
|
||||||
letter-spacing: -0.025em;
|
letter-spacing: -0.025em;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
@@ -345,7 +345,7 @@ button {
|
|||||||
max-width: 680px;
|
max-width: 680px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
font-size: clamp(14px, 1.1vw, 16px);
|
font-size: clamp(16px, 1.18vw, 18px);
|
||||||
line-height: 1.75;
|
line-height: 1.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,7 +367,7 @@ button {
|
|||||||
position: relative;
|
position: relative;
|
||||||
padding-left: 13px;
|
padding-left: 13px;
|
||||||
color: var(--ink-faint);
|
color: var(--ink-faint);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-weight: 680;
|
font-weight: 680;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -500,7 +500,7 @@ button {
|
|||||||
max-width: 660px;
|
max-width: 660px;
|
||||||
margin: 19px 0 0;
|
margin: 19px 0 0;
|
||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
font-size: 16px;
|
font-size: 18px;
|
||||||
line-height: 1.75;
|
line-height: 1.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -713,7 +713,7 @@ button {
|
|||||||
.company-line {
|
.company-line {
|
||||||
margin: 6px 0 0;
|
margin: 6px 0 0;
|
||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
font-weight: 630;
|
font-weight: 630;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -764,7 +764,7 @@ button {
|
|||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
padding-top: 1px;
|
padding-top: 1px;
|
||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
font-size: 14px;
|
font-size: 16px;
|
||||||
line-height: 1.75;
|
line-height: 1.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -781,7 +781,7 @@ button {
|
|||||||
grid-template-columns: 22px 1fr;
|
grid-template-columns: 22px 1fr;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -904,7 +904,7 @@ button {
|
|||||||
|
|
||||||
.principles-list p {
|
.principles-list p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 12px;
|
font-size: 14px;
|
||||||
font-weight: 620;
|
font-weight: 620;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
}
|
}
|
||||||
@@ -973,7 +973,7 @@ button {
|
|||||||
min-height: 43px;
|
min-height: 43px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
font-size: 12px;
|
font-size: 14px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -999,7 +999,7 @@ button {
|
|||||||
|
|
||||||
.issue-count span {
|
.issue-count span {
|
||||||
color: var(--ink-faint);
|
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-size: 10px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
@@ -1174,7 +1174,7 @@ button {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
color: var(--ink-faint);
|
color: var(--ink-faint);
|
||||||
font-size: 9px;
|
font-size: 10px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.07em;
|
letter-spacing: 0.07em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -1201,7 +1201,7 @@ button {
|
|||||||
.post-card__body > p {
|
.post-card__body > p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
font-size: 12px;
|
font-size: 14px;
|
||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1334,6 +1334,21 @@ button {
|
|||||||
margin-top: 29px;
|
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 {
|
.mini-avatar {
|
||||||
display: grid;
|
display: grid;
|
||||||
width: 38px;
|
width: 38px;
|
||||||
@@ -1387,7 +1402,7 @@ button {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
color: #465751;
|
color: #465751;
|
||||||
font-family: Georgia, "Times New Roman", serif;
|
font-family: Georgia, "Times New Roman", serif;
|
||||||
font-size: 18px;
|
font-size: 19px;
|
||||||
line-height: 1.85;
|
line-height: 1.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1492,7 +1507,7 @@ button {
|
|||||||
outline: 0;
|
outline: 0;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
background: #fbfcfa;
|
background: #fbfcfa;
|
||||||
font-size: 13px;
|
font-size: 15px;
|
||||||
transition: border 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
transition: border 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1574,7 +1589,7 @@ button {
|
|||||||
.contact-note p {
|
.contact-note p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
font-size: 12px;
|
font-size: 14px;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1625,7 +1640,7 @@ button {
|
|||||||
|
|
||||||
.social-list strong {
|
.social-list strong {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -1727,6 +1742,23 @@ button {
|
|||||||
box-shadow: 0 12px 34px rgba(75, 67, 95, 0.06);
|
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 {
|
.culture-card .eyebrow {
|
||||||
margin-bottom: 13px;
|
margin-bottom: 13px;
|
||||||
color: var(--lavender-deep);
|
color: var(--lavender-deep);
|
||||||
@@ -1735,7 +1767,7 @@ button {
|
|||||||
.culture-card h2 {
|
.culture-card h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--ink);
|
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-size: 18px;
|
||||||
font-weight: 520;
|
font-weight: 520;
|
||||||
letter-spacing: -0.025em;
|
letter-spacing: -0.025em;
|
||||||
@@ -1746,21 +1778,22 @@ button {
|
|||||||
font-weight: 850;
|
font-weight: 850;
|
||||||
}
|
}
|
||||||
|
|
||||||
.culture-card > p:not(.eyebrow) {
|
.culture-intro > p:not(.eyebrow) {
|
||||||
margin: 11px 0 20px;
|
margin: 0 0 2px;
|
||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
font-size: 12px;
|
font-size: 15px;
|
||||||
line-height: 1.65;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.culture-links {
|
.culture-links {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
gap: 9px;
|
gap: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.culture-link {
|
.culture-link {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-height: 58px;
|
min-height: 72px;
|
||||||
grid-template-columns: 38px minmax(0, 1fr) auto;
|
grid-template-columns: 38px minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 11px;
|
gap: 11px;
|
||||||
@@ -1804,7 +1837,7 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.culture-link strong {
|
.culture-link strong {
|
||||||
font-size: 12px;
|
font-size: 14px;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
font-weight: 850;
|
font-weight: 850;
|
||||||
}
|
}
|
||||||
@@ -1897,7 +1930,7 @@ button {
|
|||||||
outline: 0;
|
outline: 0;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
background: #fbfcfa;
|
background: #fbfcfa;
|
||||||
font-size: 13px;
|
font-size: 15px;
|
||||||
transition: border 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
transition: border 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1970,6 +2003,226 @@ button {
|
|||||||
padding-top: 4px;
|
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) {
|
@media (max-width: 1120px) {
|
||||||
:root {
|
:root {
|
||||||
--gutter: clamp(24px, 5vw, 70px);
|
--gutter: clamp(24px, 5vw, 70px);
|
||||||
@@ -2060,10 +2313,6 @@ button {
|
|||||||
.contact-aside {
|
.contact-aside {
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.contact-aside .social-list {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
@@ -2230,6 +2479,20 @@ button {
|
|||||||
align-items: flex-start;
|
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 {
|
.form-footer {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -2295,11 +2558,11 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.about-copy h2 {
|
.about-copy h2 {
|
||||||
font-size: 18px;
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.about-summary {
|
.about-summary {
|
||||||
font-size: 14px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-actions {
|
.hero-actions {
|
||||||
@@ -2324,7 +2587,7 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.page-description {
|
.page-description {
|
||||||
font-size: 14px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-card__header {
|
.timeline-card__header {
|
||||||
@@ -2370,7 +2633,15 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.article-content section p {
|
.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 {
|
.about-summary {
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
line-height: 1.62;
|
line-height: 1.62;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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."
|
||||||
Reference in New Issue
Block a user