Processed some UI issues on journal
This commit is contained in:
@@ -31,7 +31,7 @@ pip install -r backend\requirements.txt
|
|||||||
Start FastAPI:
|
Start FastAPI:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
uvicorn backend.main:app --reload --port 8000 --env-file backend\.env
|
uvicorn backend.main:app --reload --reload-dir backend --port 8000 --env-file backend\.env
|
||||||
```
|
```
|
||||||
|
|
||||||
In a second terminal, start React:
|
In a second terminal, start React:
|
||||||
@@ -51,14 +51,16 @@ The interface loads Roboto Flex from Google Fonts for its compact UI copy, while
|
|||||||
The form uses `POST /api/contact` and sends email through SMTP. Copy `backend/.env.example` to `backend/.env`, fill in the SMTP values, and start the API with the environment file:
|
The form uses `POST /api/contact` and sends email through SMTP. Copy `backend/.env.example` to `backend/.env`, fill in the SMTP values, and start the API with the environment file:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
uvicorn backend.main:app --reload --port 8000 --env-file backend\.env
|
uvicorn backend.main:app --reload --reload-dir backend --port 8000 --env-file backend\.env
|
||||||
```
|
```
|
||||||
|
|
||||||
If SMTP is unavailable, the API returns a clear delivery error and the page keeps Alex's direct email, LinkedIn, and GitHub links visible.
|
If SMTP is unavailable, the API returns a clear delivery error and the page keeps Alex's direct email, LinkedIn, and GitHub links visible.
|
||||||
|
|
||||||
## Journal publisher
|
## Journal publisher
|
||||||
|
|
||||||
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.
|
Click the lock icon beside the header navigation and enter the journal admin password. Signing in enables **New article**, **Edit**, and **Delete** controls throughout the journal. The account icon opens the admin menu and sign-out control. You can also open `http://localhost:5173/blog/manage` directly; the page prompts you to sign in before writing.
|
||||||
|
|
||||||
|
The writing room uses Tiptap for headings, bold and italic text, lists, undo/redo, and inline font sizes. Creating, updating, and deleting articles uses Supabase when configured, or `backend/data/posts.json` in local fallback mode. Edit controls open `/blog/{slug}/edit` with the existing sections, formatting, topics, banner, and attachments. Editing a title preserves the published URL. Deleting an article requires confirmation and removes it from the journal; stored uploads remain available at their existing URLs.
|
||||||
|
|
||||||
Set unique production values in `backend/.env`:
|
Set unique production values in `backend/.env`:
|
||||||
|
|
||||||
@@ -67,11 +69,15 @@ 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
|
||||||
```
|
```
|
||||||
|
|
||||||
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 admin session that expires after four hours. The password remains server-side and the browser stores only the temporary token. The shared session restores after refresh and expires automatically. Every create, update, delete, and upload endpoint verifies the admin token on the server.
|
||||||
|
|
||||||
|
Reading the journal is public and independent of the admin session. Journal lists and article pages retry temporary failures once, refresh after successful sign-in, and offer **Try again** if the store remains unavailable. Existing content stays visible during failed refreshes. A temporary session-check failure preserves the saved token and can recover when the connection returns. Supabase reads use bounded retries and log the failure type/code without credentials or article content.
|
||||||
|
|
||||||
Use **+ Section** in the editor to add a section heading and opening paragraph. Section headings receive the same divider and uppercase drop cap as the starter articles, with formatting visible in the editor. Existing H2 headings use this styling automatically.
|
Use **+ Section** in the editor to add a section heading and opening paragraph. Section headings receive the same divider and uppercase drop cap as the starter articles, with formatting visible in the editor. Existing H2 headings use this styling automatically.
|
||||||
|
|
||||||
The publisher accepts a banner image (JPEG, PNG, WebP, or GIF, up to 8 MB) and up to ten additional photos or files (20 MB each). The banner appears in the journal thumbnail and above the article title; additional photos and downloadable files appear below the body. Uploads require an admin session. File contents are saved in `backend/data/uploads` in both article storage modes; set `JOURNAL_UPLOAD_DIR` to a persistent mounted directory in production and include it in backups. Uploaded files are publicly accessible by their generated URLs. Removing a selection from an unpublished draft does not delete its stored upload.
|
The media picker supports drag-and-drop or file browsing, a live banner preview with the article title and subtitle, file-size and format checks, and duplicate detection by file name and size. Uploads have individual progress, cancel, and retry controls; a failed file does not stop the remaining queue. Retry or remove pending/failed uploads before publishing. Use the up/down controls to set the order of additional photos and files. Replacing a banner keeps the previous image until the replacement uploads successfully.
|
||||||
|
|
||||||
|
The publisher accepts a banner image (JPEG, PNG, WebP, or GIF, up to 8 MB) and up to ten additional photos or files (20 MB each). The banner appears in the journal thumbnail and behind the article title and subtitle with a readability overlay; additional photos and downloadable files appear below the body. Uploads require an admin session. File contents are saved in `backend/data/uploads` in both article storage modes; set `JOURNAL_UPLOAD_DIR` to a persistent mounted directory in production and include it in backups. Uploaded files are publicly accessible by their generated URLs. Removing a selection from an unpublished draft does not delete its stored upload.
|
||||||
|
|
||||||
For an existing Supabase project, run `bash scripts/setup_supabase.sh --schema-only` to add the nullable `banner` and default-empty `attachments` columns before running the updated backend. Existing articles remain compatible.
|
For an existing Supabase project, run `bash scripts/setup_supabase.sh --schema-only` to add the nullable `banner` and default-empty `attachments` columns before running the updated backend. Existing articles remain compatible.
|
||||||
|
|
||||||
@@ -109,6 +115,10 @@ The access token must be project-scoped with **Database: Read-write** permission
|
|||||||
- `POST /api/auth/login`
|
- `POST /api/auth/login`
|
||||||
- `GET /api/auth/session`
|
- `GET /api/auth/session`
|
||||||
- `POST /api/posts` (authenticated)
|
- `POST /api/posts` (authenticated)
|
||||||
|
- `PUT /api/posts/{slug}` (authenticated)
|
||||||
|
- `DELETE /api/posts/{slug}` (authenticated)
|
||||||
|
- `POST /api/uploads` (authenticated)
|
||||||
|
- `GET /api/uploads/{upload_id}`
|
||||||
- `GET /api/resume`
|
- `GET /api/resume`
|
||||||
- `POST /api/contact`
|
- `POST /api/contact`
|
||||||
|
|
||||||
|
|||||||
+108
-38
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import smtplib
|
import smtplib
|
||||||
@@ -23,7 +24,8 @@ 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
|
from httpx import TransportError
|
||||||
|
from supabase import Client, ClientOptions, PostgrestAPIError, create_client
|
||||||
|
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
@@ -36,6 +38,7 @@ 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()
|
UPLOAD_DIR = Path(os.getenv("JOURNAL_UPLOAD_DIR", str(DATA_DIR / "uploads"))).resolve()
|
||||||
MAX_UPLOAD_BYTES = 20 * 1024 * 1024
|
MAX_UPLOAD_BYTES = 20 * 1024 * 1024
|
||||||
|
logger = logging.getLogger("uvicorn.error")
|
||||||
|
|
||||||
|
|
||||||
class ArticleMedia(BaseModel):
|
class ArticleMedia(BaseModel):
|
||||||
@@ -60,7 +63,7 @@ app.add_middleware(
|
|||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=origins,
|
allow_origins=origins,
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["GET", "POST"],
|
allow_methods=["GET", "POST", "PUT", "DELETE"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -111,7 +114,11 @@ def get_supabase() -> Client | None:
|
|||||||
status_code=500,
|
status_code=500,
|
||||||
detail="Supabase configuration is incomplete.",
|
detail="Supabase configuration is incomplete.",
|
||||||
)
|
)
|
||||||
return create_client(url, key)
|
return create_client(url, key, options=ClientOptions(
|
||||||
|
postgrest_client_timeout=8,
|
||||||
|
auto_refresh_token=False,
|
||||||
|
persist_session=False,
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
def articles_table() -> str:
|
def articles_table() -> str:
|
||||||
@@ -121,6 +128,25 @@ def articles_table() -> str:
|
|||||||
return table
|
return table
|
||||||
|
|
||||||
|
|
||||||
|
def read_article_query(query: Any) -> Any:
|
||||||
|
# Bound reads to two attempts, including SDK retries. Never retry mutations.
|
||||||
|
for attempt in range(2):
|
||||||
|
try:
|
||||||
|
return query.retry(False).execute()
|
||||||
|
except Exception as exc:
|
||||||
|
code = str(getattr(exc, "code", ""))
|
||||||
|
transient = isinstance(exc, TransportError) or (
|
||||||
|
isinstance(exc, PostgrestAPIError) and code in {"500", "502", "503", "504", "520", "522", "524"}
|
||||||
|
)
|
||||||
|
# Log the error type and code, not credentials, article data, or response bodies.
|
||||||
|
safe_code = code if re.fullmatch(r"[A-Za-z0-9_]{1,20}", code) else "unknown"
|
||||||
|
logger.warning("Journal read failed: type=%s code=%s attempt=%s", type(exc).__name__, safe_code, attempt + 1)
|
||||||
|
if transient and attempt == 0:
|
||||||
|
time.sleep(0.2)
|
||||||
|
continue
|
||||||
|
raise HTTPException(status_code=502, detail="The journal store is temporarily unavailable. Please try again.") from exc
|
||||||
|
|
||||||
|
|
||||||
def article_records(include_content: bool = True) -> list[dict[str, Any]]:
|
def article_records(include_content: bool = True) -> list[dict[str, Any]]:
|
||||||
client = get_supabase()
|
client = get_supabase()
|
||||||
if client is None:
|
if client is None:
|
||||||
@@ -135,18 +161,9 @@ def article_records(include_content: bool = True) -> list[dict[str, Any]]:
|
|||||||
columns = "slug,title,excerpt,published_at,read_time,tags,accent,banner,attachments"
|
columns = "slug,title,excerpt,published_at,read_time,tags,accent,banner,attachments"
|
||||||
if include_content:
|
if include_content:
|
||||||
columns += ",content"
|
columns += ",content"
|
||||||
try:
|
response = read_article_query(
|
||||||
response = (
|
client.table(articles_table()).select(columns).order("published_at", desc=True)
|
||||||
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 []
|
return response.data or []
|
||||||
|
|
||||||
|
|
||||||
@@ -263,12 +280,13 @@ def admin_login(payload: LoginPayload) -> dict[str, Any]:
|
|||||||
"token_type": "bearer",
|
"token_type": "bearer",
|
||||||
"expires_at": expires_at,
|
"expires_at": expires_at,
|
||||||
"expires_in": ADMIN_TOKEN_TTL,
|
"expires_in": ADMIN_TOKEN_TTL,
|
||||||
|
"role": "admin",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/auth/session")
|
@app.get("/api/auth/session")
|
||||||
def admin_session(_: None = Depends(require_admin)) -> dict[str, bool]:
|
def admin_session(_: None = Depends(require_admin)) -> dict[str, Any]:
|
||||||
return {"authenticated": True}
|
return {"authenticated": True, "role": "admin"}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/posts")
|
@app.get("/api/posts")
|
||||||
@@ -349,11 +367,7 @@ def download_media(upload_id: str) -> FileResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/posts", status_code=status.HTTP_201_CREATED)
|
def validated_article(payload: NewPostPayload, slug: str) -> dict[str, Any]:
|
||||||
def create_post(
|
|
||||||
payload: NewPostPayload, _: None = Depends(require_admin)
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
slug = post_slug(payload.title)
|
|
||||||
clean_tags = list(dict.fromkeys(tag.strip() for tag in payload.tags if tag.strip()))
|
clean_tags = list(dict.fromkeys(tag.strip() for tag in payload.tags if tag.strip()))
|
||||||
if not clean_tags:
|
if not clean_tags:
|
||||||
raise HTTPException(status_code=422, detail="Add at least one topic tag.")
|
raise HTTPException(status_code=422, detail="Add at least one topic tag.")
|
||||||
@@ -373,6 +387,25 @@ def create_post(
|
|||||||
raise HTTPException(status_code=422, detail="The article body is too short.")
|
raise HTTPException(status_code=422, detail="The article body is too short.")
|
||||||
if len(json.dumps(article["content"], ensure_ascii=False)) > 250_000:
|
if len(json.dumps(article["content"], ensure_ascii=False)) > 250_000:
|
||||||
raise HTTPException(status_code=422, detail="The formatted article is too large.")
|
raise HTTPException(status_code=422, detail="The formatted article is too large.")
|
||||||
|
return article
|
||||||
|
|
||||||
|
|
||||||
|
def save_local_posts(records: list[dict[str, Any]]) -> None:
|
||||||
|
# Call while holding POSTS_LOCK so readers only see a complete replacement.
|
||||||
|
temporary_path = POSTS_PATH.with_suffix(".json.tmp")
|
||||||
|
temporary_path.write_text(
|
||||||
|
json.dumps(records, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
temporary_path.replace(POSTS_PATH)
|
||||||
|
read_data.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/posts", status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_post(
|
||||||
|
payload: NewPostPayload, _: None = Depends(require_admin)
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
slug = post_slug(payload.title)
|
||||||
|
article = validated_article(payload, slug)
|
||||||
|
|
||||||
client = get_supabase()
|
client = get_supabase()
|
||||||
if client is not None:
|
if client is not None:
|
||||||
@@ -409,17 +442,62 @@ def create_post(
|
|||||||
detail="A journal post with this title already exists.",
|
detail="A journal post with this title already exists.",
|
||||||
)
|
)
|
||||||
current_posts.append(article)
|
current_posts.append(article)
|
||||||
temporary_path = POSTS_PATH.with_suffix(".json.tmp")
|
save_local_posts(current_posts)
|
||||||
temporary_path.write_text(
|
|
||||||
json.dumps(current_posts, indent=2, ensure_ascii=False) + "\n",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
temporary_path.replace(POSTS_PATH)
|
|
||||||
read_data.cache_clear()
|
|
||||||
|
|
||||||
return article
|
return article
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/posts/{slug}")
|
||||||
|
def update_post(
|
||||||
|
slug: str, payload: NewPostPayload, _: None = Depends(require_admin)
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not re.fullmatch(r"[a-z0-9-]+", slug):
|
||||||
|
raise HTTPException(status_code=404, detail="Post not found")
|
||||||
|
# Keep published URLs stable when an admin changes the title.
|
||||||
|
article = validated_article(payload, slug)
|
||||||
|
client = get_supabase()
|
||||||
|
if client is not None:
|
||||||
|
try:
|
||||||
|
response = client.table(articles_table()).update(article).eq("slug", slug).execute()
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=502, detail="The article could not be updated. Please try again.") from exc
|
||||||
|
if not response.data:
|
||||||
|
raise HTTPException(status_code=404, detail="Post not found")
|
||||||
|
return response.data[0]
|
||||||
|
|
||||||
|
with POSTS_LOCK:
|
||||||
|
records = json.loads(POSTS_PATH.read_text(encoding="utf-8"))
|
||||||
|
for index, existing in enumerate(records):
|
||||||
|
if existing["slug"] == slug:
|
||||||
|
records[index] = {**existing, **article}
|
||||||
|
save_local_posts(records)
|
||||||
|
return records[index]
|
||||||
|
raise HTTPException(status_code=404, detail="Post not found")
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/posts/{slug}")
|
||||||
|
def delete_post(slug: str, _: None = Depends(require_admin)) -> dict[str, bool]:
|
||||||
|
if not re.fullmatch(r"[a-z0-9-]+", slug):
|
||||||
|
raise HTTPException(status_code=404, detail="Post not found")
|
||||||
|
client = get_supabase()
|
||||||
|
if client is not None:
|
||||||
|
try:
|
||||||
|
response = client.table(articles_table()).delete().eq("slug", slug).execute()
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=502, detail="The article could not be deleted. Please try again.") from exc
|
||||||
|
if not response.data:
|
||||||
|
raise HTTPException(status_code=404, detail="Post not found")
|
||||||
|
return {"deleted": True}
|
||||||
|
|
||||||
|
with POSTS_LOCK:
|
||||||
|
records = json.loads(POSTS_PATH.read_text(encoding="utf-8"))
|
||||||
|
remaining = [post for post in records if post["slug"] != slug]
|
||||||
|
if len(remaining) == len(records):
|
||||||
|
raise HTTPException(status_code=404, detail="Post not found")
|
||||||
|
save_local_posts(remaining)
|
||||||
|
return {"deleted": True}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/posts/{slug}")
|
@app.get("/api/posts/{slug}")
|
||||||
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):
|
||||||
@@ -427,19 +505,11 @@ def post_by_slug(slug: str) -> dict[str, Any]:
|
|||||||
|
|
||||||
client = get_supabase()
|
client = get_supabase()
|
||||||
if client is not None:
|
if client is not None:
|
||||||
try:
|
response = read_article_query(
|
||||||
response = (
|
|
||||||
client.table(articles_table())
|
client.table(articles_table())
|
||||||
.select("slug,title,excerpt,published_at,read_time,tags,accent,content,banner,attachments")
|
.select("slug,title,excerpt,published_at,read_time,tags,accent,content,banner,attachments")
|
||||||
.eq("slug", slug)
|
.eq("slug", slug).limit(1)
|
||||||
.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:
|
if response.data:
|
||||||
return response.data[0]
|
return response.data[0]
|
||||||
raise HTTPException(status_code=404, detail="Post not found")
|
raise HTTPException(status_code=404, detail="Post not found")
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
fastapi
|
fastapi
|
||||||
|
httpx
|
||||||
Pillow
|
Pillow
|
||||||
uvicorn[standard]
|
uvicorn[standard]
|
||||||
email-validator
|
email-validator
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
from httpx import ReadTimeout, RemoteProtocolError
|
||||||
|
from supabase import PostgrestAPIError
|
||||||
|
|
||||||
from backend import main
|
from backend import main
|
||||||
|
|
||||||
@@ -79,6 +81,99 @@ class ArticleMediaTests(unittest.TestCase):
|
|||||||
self.assertEqual(downloaded.headers["x-content-type-options"], "nosniff")
|
self.assertEqual(downloaded.headers["x-content-type-options"], "nosniff")
|
||||||
self.assertIn("attachment", downloaded.headers["content-disposition"])
|
self.assertIn("attachment", downloaded.headers["content-disposition"])
|
||||||
|
|
||||||
|
def article_payload(self):
|
||||||
|
return {
|
||||||
|
"title": "An editable article", "excerpt": "An introduction with enough detail for readers.",
|
||||||
|
"published_at": "2026-09-12", "read_time": 3, "tags": ["Custom topic"],
|
||||||
|
"content": {"type": "doc", "content": [{"type": "paragraph", "content": [
|
||||||
|
{"type": "text", "text": "An original article body with enough text to validate and publish."}
|
||||||
|
]}]},
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_admin_role_and_protected_crud(self):
|
||||||
|
self.assertEqual(self.client.post("/api/auth/login", json={"password": "wrong"}).status_code, 401)
|
||||||
|
session = self.client.get("/api/auth/session", headers=self.headers)
|
||||||
|
self.assertEqual(session.json(), {"authenticated": True, "role": "admin"})
|
||||||
|
payload = self.article_payload()
|
||||||
|
self.assertEqual(self.client.post("/api/posts", json=payload).status_code, 401)
|
||||||
|
created = self.client.post("/api/posts", json=payload, headers=self.headers).json()
|
||||||
|
path = f'/api/posts/{created["slug"]}'
|
||||||
|
for headers in ({}, {"Authorization": "Bearer 1.invalid"}):
|
||||||
|
self.assertEqual(self.client.put(path, json=payload, headers=headers).status_code, 401)
|
||||||
|
self.assertEqual(self.client.delete(path, headers=headers).status_code, 401)
|
||||||
|
self.assertEqual(self.client.get(path).json()["title"], payload["title"])
|
||||||
|
|
||||||
|
payload["title"] = "The updated article title"
|
||||||
|
payload["tags"] = ["Updated topic"]
|
||||||
|
payload["attachments"] = [self.upload(b"updated notes", name="notes.txt").json()]
|
||||||
|
updated = self.client.put(path, json=payload, headers=self.headers)
|
||||||
|
self.assertEqual(updated.status_code, 200, updated.text)
|
||||||
|
self.assertEqual(updated.json()["slug"], created["slug"])
|
||||||
|
self.assertEqual(self.client.get(path).json()["attachments"], payload["attachments"])
|
||||||
|
self.assertEqual(self.client.get("/api/posts").json()[0]["title"], payload["title"])
|
||||||
|
invalid = {**payload, "content": {"type": "doc", "content": []}}
|
||||||
|
self.assertEqual(self.client.put(path, json=invalid, headers=self.headers).status_code, 422)
|
||||||
|
self.assertEqual(self.client.put("/api/posts/missing", json=payload, headers=self.headers).status_code, 404)
|
||||||
|
self.assertEqual(self.client.delete(path, headers=self.headers).status_code, 200)
|
||||||
|
self.assertEqual(self.client.get(path).status_code, 404)
|
||||||
|
self.assertEqual(self.client.get("/api/posts").json(), [])
|
||||||
|
self.assertEqual(json.loads(main.POSTS_PATH.read_text()), [])
|
||||||
|
self.assertEqual(self.client.delete(path, headers=self.headers).status_code, 404)
|
||||||
|
|
||||||
|
def test_supabase_update_delete_are_scoped_and_report_failures(self):
|
||||||
|
client = MagicMock()
|
||||||
|
table = client.table.return_value
|
||||||
|
payload = self.article_payload()
|
||||||
|
table.update.return_value.eq.return_value.execute.return_value.data = [{**payload, "slug": "original-url"}]
|
||||||
|
table.delete.return_value.eq.return_value.execute.return_value.data = [{"slug": "original-url"}]
|
||||||
|
with patch.object(main, "get_supabase", return_value=client):
|
||||||
|
updated = self.client.put("/api/posts/original-url", json=payload, headers=self.headers)
|
||||||
|
self.assertEqual(updated.status_code, 200)
|
||||||
|
table.update.return_value.eq.assert_called_once_with("slug", "original-url")
|
||||||
|
self.assertEqual(self.client.delete("/api/posts/original-url", headers=self.headers).status_code, 200)
|
||||||
|
table.delete.return_value.eq.assert_called_once_with("slug", "original-url")
|
||||||
|
table.update.return_value.eq.return_value.execute.return_value.data = []
|
||||||
|
self.assertEqual(self.client.put("/api/posts/missing", json=payload, headers=self.headers).status_code, 404)
|
||||||
|
table.delete.return_value.eq.return_value.execute.return_value.data = []
|
||||||
|
self.assertEqual(self.client.delete("/api/posts/missing", headers=self.headers).status_code, 404)
|
||||||
|
table.update.return_value.eq.return_value.execute.side_effect = RuntimeError("offline")
|
||||||
|
self.assertEqual(self.client.put("/api/posts/original-url", json=payload, headers=self.headers).status_code, 502)
|
||||||
|
table.delete.return_value.eq.return_value.execute.side_effect = RuntimeError("offline")
|
||||||
|
self.assertEqual(self.client.delete("/api/posts/original-url", headers=self.headers).status_code, 502)
|
||||||
|
|
||||||
|
def test_journal_reads_retry_transient_errors_but_remain_public(self):
|
||||||
|
client = MagicMock()
|
||||||
|
query = MagicMock()
|
||||||
|
query.retry.return_value = query
|
||||||
|
client.table.return_value.select.return_value.order.return_value = query
|
||||||
|
client.table.return_value.select.return_value.eq.return_value.limit.return_value = query
|
||||||
|
article = {**self.article_payload(), "slug": "test-article", "accent": "mint"}
|
||||||
|
response = MagicMock(data=[article])
|
||||||
|
with patch.object(main, "get_supabase", return_value=client), patch.object(main.time, "sleep"):
|
||||||
|
for path in ("/api/posts", "/api/posts/test-article"):
|
||||||
|
for failure in (
|
||||||
|
RemoteProtocolError("connection ended"),
|
||||||
|
ReadTimeout("read timed out"),
|
||||||
|
PostgrestAPIError({"code": "503", "message": "unavailable"}),
|
||||||
|
):
|
||||||
|
query.execute.reset_mock()
|
||||||
|
query.execute.side_effect = [failure, response]
|
||||||
|
result = self.client.get(path) # No admin token: reading stays public.
|
||||||
|
self.assertEqual(result.status_code, 200, result.text)
|
||||||
|
self.assertEqual(query.execute.call_count, 2)
|
||||||
|
query.execute.reset_mock()
|
||||||
|
query.execute.side_effect = ReadTimeout("offline")
|
||||||
|
self.assertEqual(self.client.get(path).status_code, 502)
|
||||||
|
self.assertEqual(query.execute.call_count, 2)
|
||||||
|
query.execute.reset_mock()
|
||||||
|
query.execute.side_effect = PostgrestAPIError({"code": "42P01", "message": "missing table"})
|
||||||
|
self.assertEqual(self.client.get(path).status_code, 502)
|
||||||
|
self.assertEqual(query.execute.call_count, 1)
|
||||||
|
query.execute.side_effect = None
|
||||||
|
query.execute.return_value = MagicMock(data=[])
|
||||||
|
self.assertEqual(self.client.get("/api/posts").json(), [])
|
||||||
|
self.assertEqual(self.client.get("/api/posts/missing").status_code, 404)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
+3
-1
@@ -11,7 +11,9 @@
|
|||||||
<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:ital,wght@0,100..900;1,100..900&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="/favicon.ico" sizes="16x16 32x32 48x48" />
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
<title>Alex Herlan — Software Engineer</title>
|
<title>Alex Herlan — Software Engineer</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.1 KiB |
@@ -2,6 +2,7 @@ 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 AdminProvider from "./components/AdminSession";
|
||||||
import AboutPage from "./pages/AboutPage";
|
import AboutPage from "./pages/AboutPage";
|
||||||
import BlogPage from "./pages/BlogPage";
|
import BlogPage from "./pages/BlogPage";
|
||||||
import ContactPage from "./pages/ContactPage";
|
import ContactPage from "./pages/ContactPage";
|
||||||
@@ -38,7 +39,7 @@ export default function App() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout profile={profile}>
|
<AdminProvider><Layout profile={profile}>
|
||||||
<ScrollToTop />
|
<ScrollToTop />
|
||||||
<Suspense fallback={<div className="container content-page"><LoadingState label="Opening page" /></div>}>
|
<Suspense fallback={<div className="container content-page"><LoadingState label="Opening page" /></div>}>
|
||||||
<Routes>
|
<Routes>
|
||||||
@@ -50,6 +51,7 @@ export default function App() {
|
|||||||
<Route path="/skills" element={<SkillsPage />} />
|
<Route path="/skills" element={<SkillsPage />} />
|
||||||
<Route path="/blog" element={<BlogPage />} />
|
<Route path="/blog" element={<BlogPage />} />
|
||||||
<Route path="/blog/manage" element={<JournalAdminPage />} />
|
<Route path="/blog/manage" element={<JournalAdminPage />} />
|
||||||
|
<Route path="/blog/:slug/edit" element={<JournalAdminPage />} />
|
||||||
<Route path="/blog/:slug" element={<BlogPostPage />} />
|
<Route path="/blog/:slug" element={<BlogPostPage />} />
|
||||||
<Route
|
<Route
|
||||||
path="/contact"
|
path="/contact"
|
||||||
@@ -58,6 +60,6 @@ export default function App() {
|
|||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Layout>
|
</Layout></AdminProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+92
-19
@@ -1,48 +1,112 @@
|
|||||||
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
|
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
|
||||||
|
|
||||||
async function request(path, options = {}) {
|
function reportExpiredSession(token) {
|
||||||
|
window.dispatchEvent(new CustomEvent("journal-session-expired", { detail: { token } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(path, { timeoutMs = 0, ...options } = {}) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const abort = () => controller.abort();
|
||||||
|
if (options.signal?.aborted) controller.abort();
|
||||||
|
options.signal?.addEventListener("abort", abort, { once: true });
|
||||||
|
const timer = timeoutMs ? setTimeout(abort, timeoutMs) : null;
|
||||||
|
try {
|
||||||
const response = await fetch(`${API_BASE}${path}`, {
|
const response = await fetch(`${API_BASE}${path}`, {
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
signal: controller.signal,
|
||||||
"Content-Type": options.body instanceof File ? "application/octet-stream" : "application/json",
|
headers: { "Content-Type": "application/json", ...options.headers },
|
||||||
...options.headers,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
if (response.status === 401 && options.headers?.Authorization) {
|
||||||
|
reportExpiredSession(options.headers.Authorization.replace(/^Bearer /, ""));
|
||||||
|
}
|
||||||
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 = typeof body.detail === "string" ? body.detail : detail;
|
if (typeof body.detail === "string") detail = body.detail;
|
||||||
} catch {
|
} catch { /* Keep the fallback for proxy errors. */ }
|
||||||
// Keep the friendly fallback when a proxy or server returns non-JSON.
|
|
||||||
}
|
|
||||||
const error = new Error(detail);
|
const error = new Error(detail);
|
||||||
error.status = response.status;
|
error.status = response.status;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
if (controller.signal.aborted && !options.signal?.aborted) {
|
||||||
|
const timeout = new Error("The request took too long. Please try again.");
|
||||||
|
timeout.status = 408;
|
||||||
|
throw timeout;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (timer !== null) clearTimeout(timer);
|
||||||
|
options.signal?.removeEventListener("abort", abort);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return response.json();
|
function retryDelay(signal) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const abort = () => { clearTimeout(timer); reject(new DOMException("Canceled", "AbortError")); };
|
||||||
|
const timer = setTimeout(() => { signal?.removeEventListener("abort", abort); resolve(); }, 400);
|
||||||
|
if (signal?.aborted) abort();
|
||||||
|
else signal?.addEventListener("abort", abort, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readRequest(path, options = {}) {
|
||||||
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||||
|
try { return await request(path, { timeoutMs: 20000, ...options }); }
|
||||||
|
catch (error) {
|
||||||
|
const temporary = error instanceof TypeError || error.status === 408 || error.status >= 500;
|
||||||
|
if (options.signal?.aborted || !temporary || attempt === 1) throw error;
|
||||||
|
await retryDelay(options.signal);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getProfile = (signal) => request("/api/profile", { signal });
|
export const getProfile = (signal) => request("/api/profile", { signal });
|
||||||
export const getExperience = (signal) => request("/api/experience", { signal });
|
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) => readRequest("/api/posts", { signal });
|
||||||
export const getPost = (slug, signal) => request(`/api/posts/${slug}`, { signal });
|
export const getPost = (slug, signal) => readRequest(`/api/posts/${slug}`, { signal });
|
||||||
export const mediaUrl = (media) => `${API_BASE}${media.url}`;
|
export const mediaUrl = (media) => `${API_BASE}${media.url}`;
|
||||||
export const uploadMedia = (file, purpose, token) => request(`/api/uploads?name=${encodeURIComponent(file.name)}&purpose=${purpose}`, {
|
export function uploadMedia(file, purpose, token, { onProgress, signal } = {}) {
|
||||||
method: "POST",
|
return new Promise((resolve, reject) => {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
const xhr = new XMLHttpRequest();
|
||||||
body: file,
|
const abort = () => xhr.abort();
|
||||||
});
|
if (signal?.aborted) { reject(new DOMException("Upload canceled", "AbortError")); return; }
|
||||||
|
xhr.open("POST", `${API_BASE}/api/uploads?name=${encodeURIComponent(file.name)}&purpose=${purpose}`);
|
||||||
|
xhr.setRequestHeader("Authorization", `Bearer ${token}`);
|
||||||
|
xhr.setRequestHeader("Content-Type", "application/octet-stream");
|
||||||
|
xhr.responseType = "json";
|
||||||
|
xhr.timeout = 120000;
|
||||||
|
xhr.upload.onprogress = (event) => {
|
||||||
|
if (event.lengthComputable) onProgress?.(Math.round(event.loaded / event.total * 100));
|
||||||
|
};
|
||||||
|
xhr.onload = () => {
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300 && xhr.response) resolve(xhr.response);
|
||||||
|
else {
|
||||||
|
if (xhr.status === 401) reportExpiredSession(token);
|
||||||
|
const error = new Error(typeof xhr.response?.detail === "string" ? xhr.response.detail : "Upload failed. Please try again.");
|
||||||
|
error.status = xhr.status;
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.onerror = () => reject(new Error("Connection lost. Check your connection and retry."));
|
||||||
|
xhr.ontimeout = () => reject(new Error("Upload timed out. Please retry."));
|
||||||
|
xhr.onabort = () => reject(new DOMException("Upload canceled", "AbortError"));
|
||||||
|
xhr.onloadend = () => signal?.removeEventListener("abort", abort);
|
||||||
|
signal?.addEventListener("abort", abort, { once: true });
|
||||||
|
xhr.send(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
export const loginAdmin = (password) =>
|
export const loginAdmin = (password) =>
|
||||||
request("/api/auth/login", {
|
request("/api/auth/login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ password }),
|
body: JSON.stringify({ password }),
|
||||||
});
|
});
|
||||||
export const getAdminSession = (token, signal) =>
|
export const getAdminSession = (token, signal) =>
|
||||||
request("/api/auth/session", {
|
readRequest("/api/auth/session", {
|
||||||
|
timeoutMs: 5000,
|
||||||
signal,
|
signal,
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
@@ -52,6 +116,15 @@ export const createPost = (payload, token) =>
|
|||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
|
export const updateArticle = (slug, payload, token) => request(`/api/posts/${slug}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
export const deleteArticle = (slug, token) => request(`/api/posts/${slug}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
export const sendContactMessage = (payload) =>
|
export const sendContactMessage = (payload) =>
|
||||||
request("/api/contact", {
|
request("/api/contact", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// Starter articles use section arrays; the editor uses Tiptap documents.
|
||||||
|
export function editableDocument(content) {
|
||||||
|
if (!Array.isArray(content)) return content;
|
||||||
|
return { type: "doc", content: content.flatMap((section) => [
|
||||||
|
...(section.heading ? [{ type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: section.heading }] }] : []),
|
||||||
|
...section.paragraphs.map((text) => ({ type: "paragraph", ...(text ? { content: [{ type: "text", text }] } : {}) })),
|
||||||
|
]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function articlePlainText(node) {
|
||||||
|
if (!node) return "";
|
||||||
|
return [node.text ?? "", ...(node.content ?? []).map(articlePlainText)].filter(Boolean).join(" ").trim();
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { getAdminSession, loginAdmin } from "../api";
|
||||||
|
import Modal from "./Modal";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
|
||||||
|
const TOKEN_KEY = "alex-journal-admin";
|
||||||
|
const AdminContext = createContext(null);
|
||||||
|
export const useAdmin = () => useContext(AdminContext);
|
||||||
|
|
||||||
|
export default function AdminProvider({ children }) {
|
||||||
|
const [token, setToken] = useState(() => sessionStorage.getItem(TOKEN_KEY) ?? "");
|
||||||
|
const [checking, setChecking] = useState(Boolean(token));
|
||||||
|
const [role, setRole] = useState(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [sessionRevision, setSessionRevision] = useState(0);
|
||||||
|
const [checkAttempt, setCheckAttempt] = useState(0);
|
||||||
|
const tokenRef = useRef(token);
|
||||||
|
const verifiedToken = useRef("");
|
||||||
|
|
||||||
|
const signOut = useCallback(() => {
|
||||||
|
tokenRef.current = "";
|
||||||
|
verifiedToken.current = "";
|
||||||
|
sessionStorage.removeItem(TOKEN_KEY);
|
||||||
|
setToken("");
|
||||||
|
setRole(null);
|
||||||
|
setChecking(false);
|
||||||
|
setPassword("");
|
||||||
|
setOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token || verifiedToken.current === token) return;
|
||||||
|
const controller = new AbortController();
|
||||||
|
setChecking(true);
|
||||||
|
getAdminSession(token, controller.signal)
|
||||||
|
.then((session) => {
|
||||||
|
if (!controller.signal.aborted && tokenRef.current === token) {
|
||||||
|
verifiedToken.current = token;
|
||||||
|
setRole(session.role);
|
||||||
|
setError("");
|
||||||
|
setSessionRevision((current) => current + 1);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((requestError) => {
|
||||||
|
if (!controller.signal.aborted && tokenRef.current === token) {
|
||||||
|
if (requestError.status === 401) signOut();
|
||||||
|
setError(requestError.status === 401 ? requestError.message : "Could not check your session. Retry when your connection is back, or sign in again.");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => { if (!controller.signal.aborted) setChecking(false); });
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [token, checkAttempt, signOut]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const retry = () => { if (tokenRef.current && !verifiedToken.current) setCheckAttempt((current) => current + 1); };
|
||||||
|
window.addEventListener("online", retry);
|
||||||
|
window.addEventListener("focus", retry);
|
||||||
|
return () => { window.removeEventListener("online", retry); window.removeEventListener("focus", retry); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const expire = (event) => {
|
||||||
|
const expiredToken = event?.detail?.token ?? token;
|
||||||
|
if (!expiredToken || expiredToken !== tokenRef.current) return;
|
||||||
|
signOut();
|
||||||
|
setError("Your session ended. Sign in again to manage articles.");
|
||||||
|
};
|
||||||
|
window.addEventListener("journal-session-expired", expire);
|
||||||
|
const expiresAt = Number(token.split(".")[0]) * 1000;
|
||||||
|
const timer = token ? setTimeout(expire, Math.max(0, expiresAt - Date.now())) : null;
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("journal-session-expired", expire);
|
||||||
|
if (timer !== null) clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [token, signOut]);
|
||||||
|
|
||||||
|
async function signIn(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const session = await loginAdmin(password);
|
||||||
|
sessionStorage.setItem(TOKEN_KEY, session.access_token);
|
||||||
|
tokenRef.current = session.access_token;
|
||||||
|
verifiedToken.current = session.access_token;
|
||||||
|
setToken(session.access_token);
|
||||||
|
setRole(session.role);
|
||||||
|
setChecking(false);
|
||||||
|
setSessionRevision((current) => current + 1);
|
||||||
|
setPassword("");
|
||||||
|
setOpen(false);
|
||||||
|
} catch (requestError) {
|
||||||
|
setError(requestError.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = role === "admin" && Boolean(token) && !checking;
|
||||||
|
const close = () => { setOpen(false); setPassword(""); };
|
||||||
|
return <AdminContext.Provider value={{ token, checking, isAdmin, sessionRevision, signOut, openSignIn: () => setOpen(true) }}>
|
||||||
|
{children}
|
||||||
|
{open && <Modal title={isAdmin ? "Admin account" : "Admin sign in"} onClose={close} busy={busy}>
|
||||||
|
{isAdmin ? <div className="admin-account">
|
||||||
|
<span className="publisher-badge"><span /> Admin</span>
|
||||||
|
<p>You can create, edit, and delete journal articles.</p>
|
||||||
|
<Link className="button button--primary" to="/blog/manage" onClick={close}><Icon name="plus" size={18} /> New article</Link>
|
||||||
|
<Link className="button" to="/blog" onClick={close}>Manage articles</Link>
|
||||||
|
<button className="text-button" type="button" onClick={signOut}><Icon name="logout" size={18} /> Sign out</button>
|
||||||
|
</div> : <form className="admin-signin" onSubmit={signIn}>
|
||||||
|
<p>Enter the admin password to manage journal articles.</p>
|
||||||
|
<label className="publisher-field">
|
||||||
|
<span>Admin password</span>
|
||||||
|
<input autoFocus autoComplete="current-password" type="password" required maxLength={200}
|
||||||
|
value={password} onChange={(event) => setPassword(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
{error && <p className="form-status form-status--error" role="alert">{error}</p>}
|
||||||
|
{token && !checking && <button className="text-button" type="button" onClick={() => setCheckAttempt((current) => current + 1)}>Retry session check</button>}
|
||||||
|
<button className="button button--primary" type="submit" disabled={busy || checking}>{busy ? "Signing in…" : "Sign in"}</button>
|
||||||
|
</form>}
|
||||||
|
</Modal>}
|
||||||
|
</AdminContext.Provider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { deleteArticle } from "../api";
|
||||||
|
import { useAdmin } from "./AdminSession";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
import Modal from "./Modal";
|
||||||
|
|
||||||
|
export default function ArticleAdminActions({ post, onDeleted }) {
|
||||||
|
const { isAdmin, token } = useAdmin();
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
if (!isAdmin) return null;
|
||||||
|
|
||||||
|
async function remove() {
|
||||||
|
if (busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await deleteArticle(post.slug, token);
|
||||||
|
setConfirming(false);
|
||||||
|
onDeleted(post.slug);
|
||||||
|
} catch (requestError) {
|
||||||
|
setError(requestError.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="article-admin-actions">
|
||||||
|
<Link className="text-button" to={`/blog/${post.slug}/edit`}><Icon name="edit" size={16} /> Edit</Link>
|
||||||
|
<button className="text-button text-button--danger" type="button" onClick={() => { setError(""); setConfirming(true); }}>
|
||||||
|
<Icon name="trash" size={16} /> Delete
|
||||||
|
</button>
|
||||||
|
{confirming && <Modal title="Delete article?" onClose={() => setConfirming(false)} busy={busy}>
|
||||||
|
<p>“{post.title}” will be removed from the journal. This cannot be undone.</p>
|
||||||
|
{error && <p className="form-status form-status--error" role="alert">{error}</p>}
|
||||||
|
<div className="dialog-actions">
|
||||||
|
<button autoFocus className="button" type="button" disabled={busy} onClick={() => setConfirming(false)}>Cancel</button>
|
||||||
|
<button className="button button--danger" type="button" disabled={busy} onClick={remove}>{busy ? "Deleting…" : "Delete article"}</button>
|
||||||
|
</div>
|
||||||
|
</Modal>}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -1,40 +1,184 @@
|
|||||||
|
import { useEffect, useId, useRef, useState } from "react";
|
||||||
import { mediaUrl } from "../api";
|
import { mediaUrl } from "../api";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
|
||||||
export default function ArticleUploads({ banner, attachments, busy, onUpload, onRemoveBanner, onRemoveAttachment }) {
|
const imageTypes = ["image/jpeg", "image/png", "image/webp", "image/gif"];
|
||||||
return (
|
const isImage = (file) => imageTypes.includes(file.type) || (!file.type && /\.(jpe?g|png|webp|gif)$/i.test(file.name));
|
||||||
<fieldset className="article-uploads" disabled={busy}>
|
const fileSize = (size) => size < 1024 * 1024 ? `${Math.max(1, Math.round(size / 1024))} KB` : `${(size / 1024 / 1024).toFixed(1)} MB`;
|
||||||
|
|
||||||
|
function Dropzone({ purpose, disabled, onFiles, hasBanner }) {
|
||||||
|
const input = useRef(null);
|
||||||
|
const depth = useRef(0);
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
const hintId = useId();
|
||||||
|
const banner = purpose === "banner";
|
||||||
|
return <div className={`upload-dropzone ${dragging && !disabled ? "is-dragging" : ""} ${disabled ? "is-disabled" : ""}`}
|
||||||
|
onDragEnter={(event) => { event.preventDefault(); depth.current += 1; setDragging(true); }}
|
||||||
|
onDragOver={(event) => { event.preventDefault(); event.dataTransfer.dropEffect = disabled ? "none" : "copy"; }}
|
||||||
|
onDragLeave={(event) => { event.preventDefault(); depth.current -= 1; if (depth.current <= 0) setDragging(false); }}
|
||||||
|
onDrop={(event) => {
|
||||||
|
event.preventDefault(); depth.current = 0; setDragging(false);
|
||||||
|
if (!disabled) onFiles(Array.from(event.dataTransfer.files), purpose);
|
||||||
|
}}>
|
||||||
|
<span className="upload-dropzone__icon"><Icon name={banner ? "photo" : "upload"} size={25} /></span>
|
||||||
|
<strong>{banner ? hasBanner ? "Drop a new banner to replace it" : "Give your article a cover" : "Add something worth sharing"}</strong>
|
||||||
|
<p>{banner ? "Drag an image here, or choose one below." : "Drop photos, documents, or other files here."}</p>
|
||||||
|
<button type="button" className="upload-browse" disabled={disabled} aria-describedby={hintId} onClick={() => input.current.click()}>
|
||||||
|
<Icon name="plus" size={16} /> {banner ? hasBanner ? "Replace banner" : "Choose banner" : "Browse files"}
|
||||||
|
</button>
|
||||||
|
<small id={hintId}>{banner ? "JPG, PNG, WebP or GIF · Up to 8 MB" : "Up to 10 files · 20 MB each"}</small>
|
||||||
|
<input ref={input} className="upload-input" type="file" tabIndex={-1}
|
||||||
|
aria-label={banner ? "Banner image" : "Additional photos & files"}
|
||||||
|
accept={banner ? "image/jpeg,image/png,image/webp,image/gif" : undefined}
|
||||||
|
multiple={!banner} disabled={disabled} onChange={(event) => {
|
||||||
|
onFiles(Array.from(event.target.files), purpose);
|
||||||
|
event.target.value = "";
|
||||||
|
}} />
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ArticleUploads({ banner, attachments, busy, title, subtitle, onUpload, onPendingChange, onRemoveBanner, onRemoveAttachment, onMoveAttachment }) {
|
||||||
|
const [tasks, setTasks] = useState([]);
|
||||||
|
const [messages, setMessages] = useState([]);
|
||||||
|
const queue = useRef([]);
|
||||||
|
const running = useRef(false);
|
||||||
|
const mounted = useRef(true);
|
||||||
|
const previews = useRef(new Set());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mounted.current = true;
|
||||||
|
return () => {
|
||||||
|
mounted.current = false;
|
||||||
|
queue.current.forEach((task) => task.controller?.abort());
|
||||||
|
previews.current.forEach((url) => URL.revokeObjectURL(url));
|
||||||
|
previews.current.clear();
|
||||||
|
onPendingChange(false);
|
||||||
|
};
|
||||||
|
}, [onPendingChange]);
|
||||||
|
|
||||||
|
function updateQueue(next) {
|
||||||
|
queue.current = next;
|
||||||
|
if (mounted.current) { setTasks(next); onPendingChange(next.length > 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function forget(id) {
|
||||||
|
const task = queue.current.find((item) => item.id === id);
|
||||||
|
if (task?.preview) { URL.revokeObjectURL(task.preview); previews.current.delete(task.preview); }
|
||||||
|
updateQueue(queue.current.filter((item) => item.id !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function change(id, values) {
|
||||||
|
updateQueue(queue.current.map((task) => task.id === id ? { ...task, ...values } : task));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processQueue() {
|
||||||
|
if (running.current) return;
|
||||||
|
running.current = true;
|
||||||
|
try {
|
||||||
|
let task;
|
||||||
|
while (mounted.current && (task = queue.current.find((item) => item.status === "queued"))) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const id = task.id;
|
||||||
|
change(id, { status: "uploading", controller, progress: 0 });
|
||||||
|
try {
|
||||||
|
await onUpload(task.file, task.purpose, {
|
||||||
|
signal: controller.signal,
|
||||||
|
onProgress: (progress) => { if (mounted.current) change(id, { progress }); },
|
||||||
|
});
|
||||||
|
if (mounted.current) forget(id);
|
||||||
|
} catch (error) {
|
||||||
|
if (mounted.current && error.name !== "AbortError") change(id, { status: "error", error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
running.current = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFiles(files, purpose) {
|
||||||
|
if (busy || !files.length) return;
|
||||||
|
const errors = [];
|
||||||
|
if (purpose === "banner" && (files.length > 1 || queue.current.some((task) => task.purpose === "banner"))) {
|
||||||
|
setMessages(["Choose one banner at a time. Finish or remove the current banner upload first."]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const additions = [];
|
||||||
|
const existing = [...attachments, ...queue.current.filter((task) => task.purpose === "attachment").map((task) => task.file)];
|
||||||
|
for (const file of files) {
|
||||||
|
const limit = (purpose === "banner" ? 8 : 20) * 1024 * 1024;
|
||||||
|
if (!file.size) { errors.push(`${file.name}: this file is empty.`); continue; }
|
||||||
|
if (file.size > limit) { errors.push(`${file.name}: exceeds the ${purpose === "banner" ? 8 : 20} MB limit.`); continue; }
|
||||||
|
if (purpose === "banner" && !isImage(file)) { errors.push(`${file.name}: choose a JPG, PNG, WebP, or GIF image.`); continue; }
|
||||||
|
if (purpose === "attachment" && existing.some((item) => item.name === file.name && item.size === file.size)) {
|
||||||
|
errors.push(`${file.name}: already added.`); continue;
|
||||||
|
}
|
||||||
|
if (purpose === "attachment" && existing.length >= 10) { errors.push(`${file.name}: all 10 attachment slots are filled.`); continue; }
|
||||||
|
const preview = isImage(file) ? URL.createObjectURL(file) : null;
|
||||||
|
if (preview) previews.current.add(preview);
|
||||||
|
additions.push({ id: crypto.randomUUID(), file, purpose, preview, status: "queued", progress: 0 });
|
||||||
|
if (purpose === "attachment") existing.push(file);
|
||||||
|
}
|
||||||
|
setMessages(errors);
|
||||||
|
if (additions.length) {
|
||||||
|
updateQueue([...queue.current, ...additions]);
|
||||||
|
processQueue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel(task) { task.controller?.abort(); forget(task.id); }
|
||||||
|
const bannerTask = tasks.find((task) => task.purpose === "banner");
|
||||||
|
const heroImage = bannerTask?.preview ?? (banner ? mediaUrl(banner) : null);
|
||||||
|
const attachmentCount = attachments.length + tasks.filter((task) => task.purpose === "attachment").length;
|
||||||
|
|
||||||
|
return <fieldset className="article-uploads" disabled={busy}>
|
||||||
<legend>Photos & files</legend>
|
<legend>Photos & files</legend>
|
||||||
<label className="publisher-field">
|
<div className="upload-section">
|
||||||
<span>Banner image</span>
|
<div className="upload-section__heading"><div><h3>Banner image</h3><p>Your journal thumbnail and article cover.</p></div><span className="upload-label">Optional</span></div>
|
||||||
<small>Shown on the journal thumbnail and above the article title. JPEG, PNG, WebP or GIF, up to 8 MB.</small>
|
{heroImage && <div className="upload-hero-preview">
|
||||||
<input type="file" accept="image/jpeg,image/png,image/webp,image/gif" onChange={(event) => {
|
<img src={heroImage} alt="Banner preview" />
|
||||||
onUpload(Array.from(event.target.files), "banner");
|
<div><span>{bannerTask ? "Preview · Upload pending" : "Hero preview"}</span><strong>{title || "Your article title"}</strong><p>{subtitle || "Your subtitle appears here, over the cover image."}</p></div>
|
||||||
event.target.value = "";
|
|
||||||
}} />
|
|
||||||
</label>
|
|
||||||
{banner && <div className="upload-preview">
|
|
||||||
<img src={mediaUrl(banner)} alt="Banner preview" />
|
|
||||||
<span>{banner.name}</span>
|
|
||||||
<button type="button" className="text-button" onClick={onRemoveBanner}>Remove banner</button>
|
|
||||||
</div>}
|
</div>}
|
||||||
<label className="publisher-field">
|
{banner && <div className="upload-saved-banner"><span><Icon name="check" size={15} /> {banner.name} <small>{fileSize(banner.size)}</small></span>
|
||||||
<span>Additional photos & files</span>
|
<button type="button" className="text-button" disabled={Boolean(bannerTask)} onClick={onRemoveBanner}>Remove banner</button></div>}
|
||||||
<small>Displayed below the article text, in upload order. Up to 10 files, 20 MB each.</small>
|
<Dropzone purpose="banner" hasBanner={Boolean(banner)} disabled={busy || Boolean(bannerTask)} onFiles={addFiles} />
|
||||||
<input type="file" multiple onChange={(event) => {
|
<small className="upload-tip">Wide images work best. The cover is cropped to fill the hero and thumbnail.</small>
|
||||||
onUpload(Array.from(event.target.files), "attachment");
|
</div>
|
||||||
event.target.value = "";
|
|
||||||
}} />
|
<div className="upload-section">
|
||||||
</label>
|
<div className="upload-section__heading"><div><h3>Additional photos & files</h3><p>Shown below your article, in this order.</p></div><span className="upload-label">{attachmentCount}/10</span></div>
|
||||||
|
<Dropzone purpose="attachment" disabled={busy || attachmentCount >= 10} onFiles={addFiles} />
|
||||||
{attachments.length > 0 && <ul className="upload-list">
|
{attachments.length > 0 && <ul className="upload-list">
|
||||||
{attachments.map((file) => <li key={file.url}>
|
{attachments.map((file, index) => <li key={file.url}>
|
||||||
{file.media_type.startsWith("image/") && <img src={mediaUrl(file)} alt="" />}
|
<span className="upload-file-icon">{file.media_type.startsWith("image/") ? <img src={mediaUrl(file)} alt="" /> : <Icon name="file" size={24} />}</span>
|
||||||
<span>{file.name} <small>({Math.ceil(file.size / 1024)} KB)</small></span>
|
<div className="upload-file-info"><strong>{file.name}</strong><small>{fileSize(file.size)} · <span className="upload-ready">Ready</span></small></div>
|
||||||
<button type="button" className="text-button" aria-label={`Remove ${file.name}`} onClick={() => onRemoveAttachment(file.url)}>Remove</button>
|
<div className="upload-file-actions">
|
||||||
|
<button type="button" className="upload-icon-button" disabled={index === 0} aria-label={`Move ${file.name} up`} title="Move up" onClick={() => onMoveAttachment(file.url, -1)}><Icon name="up" size={17} /></button>
|
||||||
|
<button type="button" className="upload-icon-button" disabled={index === attachments.length - 1} aria-label={`Move ${file.name} down`} title="Move down" onClick={() => onMoveAttachment(file.url, 1)}><Icon name="down" size={17} /></button>
|
||||||
|
<button type="button" className="upload-icon-button" aria-label={`Remove ${file.name}`} title="Remove file" onClick={() => onRemoveAttachment(file.url)}><Icon name="close" size={17} /></button>
|
||||||
|
</div>
|
||||||
</li>)}
|
</li>)}
|
||||||
</ul>}
|
</ul>}
|
||||||
{busy && <p role="status">Uploading…</p>}
|
</div>
|
||||||
</fieldset>
|
|
||||||
);
|
{messages.length > 0 && <div className="upload-feedback" role="alert"><strong>Some files could not be added</strong><ul>{messages.map((message, index) => <li key={index}>{message}</li>)}</ul><button type="button" className="text-button" onClick={() => setMessages([])}>Dismiss</button></div>}
|
||||||
|
{tasks.length > 0 && <div className="upload-queue">
|
||||||
|
<h3>Upload queue</h3>
|
||||||
|
<ul className="upload-list">{tasks.map((task) => <li key={task.id} className={task.status === "error" ? "has-error" : ""}>
|
||||||
|
<span className="upload-file-icon">{task.preview ? <img src={task.preview} alt="" /> : <Icon name="file" size={24} />}</span>
|
||||||
|
<div className="upload-file-info"><strong>{task.file.name}</strong><small>{fileSize(task.file.size)} · {task.purpose === "banner" ? "Banner" : "Attachment"}</small>
|
||||||
|
{task.status === "error" ? <p role="alert">{task.error}</p> : <>
|
||||||
|
<small>{task.status === "queued" ? "Waiting to upload" : task.progress === 100 ? "Processing file…" : `Uploading ${task.progress}%`}</small>
|
||||||
|
<progress max="100" value={task.progress} aria-label={`Uploading ${task.file.name}`} />
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
<div className="upload-file-actions">
|
||||||
|
{task.status === "error" && <button type="button" className="text-button" aria-label={`Retry ${task.file.name}`} onClick={() => { change(task.id, { status: "queued", error: "" }); processQueue(); }}>Retry</button>}
|
||||||
|
<button type="button" className="upload-icon-button" aria-label={`${task.status === "error" ? "Remove" : "Cancel"} upload ${task.file.name}`} onClick={() => cancel(task)}><Icon name="close" size={18} /></button>
|
||||||
|
</div>
|
||||||
|
</li>)}</ul>
|
||||||
|
<p className="upload-tip" role="status">{tasks.some((task) => task.status === "error") ? "Retry or remove failed uploads before publishing." : "You can keep writing while your files upload."}</p>
|
||||||
|
</div>}
|
||||||
|
</fieldset>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ArticleAttachments({ attachments = [] }) {
|
export function ArticleAttachments({ attachments = [] }) {
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
const paths = {
|
const paths = {
|
||||||
|
upload: <path d="M12 16V3m-5 5 5-5 5 5M4 16v5h16v-5" />,
|
||||||
|
photo: <><rect x="3" y="3" width="18" height="18" rx="3" /><circle cx="8" cy="8" r="1.5" /><path d="m3 17 6-6 4 4 3-3 5 5" /></>,
|
||||||
|
file: <><path d="M14 2H5v20h14V7l-5-5Zm0 0v6h5M8 13h8M8 17h5" /></>,
|
||||||
|
up: <path d="m6 14 6-6 6 6" />,
|
||||||
|
down: <path d="m6 10 6 6 6-6" />,
|
||||||
|
user: <><circle cx="12" cy="8" r="4" /><path d="M4 21v-2a8 8 0 0 1 16 0v2" /></>,
|
||||||
|
edit: <><path d="m15 4 5 5M4 20l5-1L21 7a2 2 0 0 0-5-5L4 14v6Z" /></>,
|
||||||
|
trash: <><path d="M3 6h18M9 6V3h6v3M5 6l1 15h12l1-15M10 10v7M14 10v7" /></>,
|
||||||
arrow: <path d="M5 12h14m-5-5 5 5-5 5" />,
|
arrow: <path d="M5 12h14m-5-5 5 5-5 5" />,
|
||||||
arrowLeft: <path d="m11 17-5-5 5-5m-5 5h13" />,
|
arrowLeft: <path d="m11 17-5-5 5-5m-5 5h13" />,
|
||||||
arrowUpRight: <path d="M7 17 17 7M8 7h9v9" />,
|
arrowUpRight: <path d="M7 17 17 7M8 7h9v9" />,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { NavLink, useLocation } from "react-router-dom";
|
import { NavLink, useLocation } from "react-router-dom";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
import { useAdmin } from "./AdminSession";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ label: "About", to: "/" },
|
{ label: "About", to: "/" },
|
||||||
@@ -11,6 +12,7 @@ const navItems = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
function Header({ name }) {
|
function Header({ name }) {
|
||||||
|
const { isAdmin, checking, openSignIn } = useAdmin();
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
@@ -20,10 +22,11 @@ function Header({ name }) {
|
|||||||
<header className="site-header">
|
<header className="site-header">
|
||||||
<div className="container header-inner">
|
<div className="container header-inner">
|
||||||
<NavLink className="brand" to="/" aria-label={`${name} home`}>
|
<NavLink className="brand" to="/" aria-label={`${name} home`}>
|
||||||
<span className="brand-mark">AH</span>
|
<img className="brand-mark brand-photo" src="/alex-avatar-96.webp" width="42" height="42" alt="" />
|
||||||
<span className="brand-name">{name}</span>
|
<span className="brand-name">{name}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
|
<div className="header-actions">
|
||||||
<nav className={`nav-shell ${menuOpen ? "is-open" : ""}`} aria-label="Main navigation">
|
<nav className={`nav-shell ${menuOpen ? "is-open" : ""}`} aria-label="Main navigation">
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
@@ -37,6 +40,13 @@ function Header({ name }) {
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<button type="button" className={`icon-button header-signin ${isAdmin ? "is-admin" : ""}`}
|
||||||
|
aria-label={isAdmin ? "Admin account" : "Admin sign in"} title={isAdmin ? "Admin account" : "Admin sign in"}
|
||||||
|
aria-haspopup="dialog" disabled={checking} onClick={openSignIn}>
|
||||||
|
<Icon name={isAdmin ? "user" : "lock"} />
|
||||||
|
{isAdmin && <span className="admin-indicator" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
aria-expanded={menuOpen}
|
aria-expanded={menuOpen}
|
||||||
aria-label={menuOpen ? "Close navigation" : "Open navigation"}
|
aria-label={menuOpen ? "Close navigation" : "Open navigation"}
|
||||||
@@ -47,6 +57,7 @@ function Header({ name }) {
|
|||||||
<Icon name={menuOpen ? "close" : "menu"} />
|
<Icon name={menuOpen ? "close" : "menu"} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -75,6 +86,41 @@ export default function Layout({ children, profile }) {
|
|||||||
const name = profile?.display_name ?? "Alex Herlan";
|
const name = profile?.display_name ?? "Alex Herlan";
|
||||||
const isAbout = location.pathname === "/";
|
const isAbout = location.pathname === "/";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let pressed = null;
|
||||||
|
let origin = null;
|
||||||
|
const clear = () => {
|
||||||
|
pressed?.removeAttribute("data-pressed");
|
||||||
|
pressed = null;
|
||||||
|
origin = null;
|
||||||
|
};
|
||||||
|
const press = (event) => {
|
||||||
|
clear();
|
||||||
|
if (!event.isPrimary || event.button !== 0) return;
|
||||||
|
const control = event.target.closest?.(".button, .culture-link");
|
||||||
|
if (!control || control.matches(":disabled")) return;
|
||||||
|
pressed = control;
|
||||||
|
origin = { x: event.clientX, y: event.clientY };
|
||||||
|
pressed.setAttribute("data-pressed", "true");
|
||||||
|
};
|
||||||
|
const move = (event) => {
|
||||||
|
if (origin && Math.hypot(event.clientX - origin.x, event.clientY - origin.y) > 10) clear();
|
||||||
|
};
|
||||||
|
window.addEventListener("pointerdown", press, { passive: true });
|
||||||
|
window.addEventListener("pointermove", move, { passive: true });
|
||||||
|
window.addEventListener("pointerup", clear);
|
||||||
|
window.addEventListener("pointercancel", clear);
|
||||||
|
window.addEventListener("blur", clear);
|
||||||
|
return () => {
|
||||||
|
clear();
|
||||||
|
window.removeEventListener("pointerdown", press);
|
||||||
|
window.removeEventListener("pointermove", move);
|
||||||
|
window.removeEventListener("pointerup", clear);
|
||||||
|
window.removeEventListener("pointercancel", clear);
|
||||||
|
window.removeEventListener("blur", clear);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<a className="skip-link" href="#main-content">Skip to content</a>
|
<a className="skip-link" href="#main-content">Skip to content</a>
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useEffect, useId, useRef } from "react";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
|
||||||
|
export default function Modal({ title, children, onClose, busy = false }) {
|
||||||
|
const dialog = useRef(null);
|
||||||
|
const titleId = useId();
|
||||||
|
useEffect(() => {
|
||||||
|
const element = dialog.current;
|
||||||
|
element.showModal();
|
||||||
|
return () => element.close();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <dialog ref={dialog} className="admin-dialog" aria-labelledby={titleId}
|
||||||
|
onCancel={(event) => { event.preventDefault(); if (!busy) onClose(); }}>
|
||||||
|
<div className="admin-dialog__heading">
|
||||||
|
<h2 id={titleId}>{title}</h2>
|
||||||
|
<button type="button" className="icon-button" aria-label="Close dialog" disabled={busy} onClick={onClose}><Icon name="close" /></button>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</dialog>;
|
||||||
|
}
|
||||||
@@ -7,15 +7,15 @@ export function LoadingState({ label = "Loading" }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorState({ message }) {
|
export function ErrorState({ message, onRetry, retrying = false }) {
|
||||||
return (
|
return (
|
||||||
<div className="state-card state-card--error" role="alert">
|
<div className="state-card state-card--error" role="alert">
|
||||||
<span className="state-icon">!</span>
|
<span className="state-icon">!</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>That didn’t load.</strong>
|
<strong>That didn’t load.</strong>
|
||||||
<p>{message}</p>
|
<p>{message}</p>
|
||||||
|
{onRetry && <button className="button" type="button" disabled={retrying} onClick={onRetry}>{retrying ? "Retrying..." : "Try again"}</button>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { getPosts, mediaUrl } from "../api";
|
import { mediaUrl } from "../api";
|
||||||
|
import { mainTopics, topicKey } from "../topics";
|
||||||
|
import useJournalResource from "../useJournalResource";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
import PageHeader from "../components/PageHeader";
|
import PageHeader from "../components/PageHeader";
|
||||||
|
import ArticleAdminActions from "../components/ArticleAdminActions";
|
||||||
|
import { useAdmin } from "../components/AdminSession";
|
||||||
import { ErrorState, LoadingState } from "../components/Status";
|
import { ErrorState, LoadingState } from "../components/Status";
|
||||||
|
|
||||||
function formatDate(date) {
|
function formatDate(date) {
|
||||||
@@ -13,7 +17,7 @@ function formatDate(date) {
|
|||||||
}).format(new Date(`${date}T12:00:00`));
|
}).format(new Date(`${date}T12:00:00`));
|
||||||
}
|
}
|
||||||
|
|
||||||
function PostCard({ post, featured }) {
|
function PostCard({ post, featured, onDeleted }) {
|
||||||
return (
|
return (
|
||||||
<article className={`post-card post-card--${post.accent} ${featured ? "post-card--featured" : ""} reveal`}>
|
<article className={`post-card post-card--${post.accent} ${featured ? "post-card--featured" : ""} reveal`}>
|
||||||
<Link to={`/blog/${post.slug}`} aria-label={`Read ${post.title}`}>
|
<Link to={`/blog/${post.slug}`} aria-label={`Read ${post.title}`}>
|
||||||
@@ -39,42 +43,34 @@ function PostCard({ post, featured }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
<ArticleAdminActions post={post} onDeleted={onDeleted} />
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BlogPage() {
|
export default function BlogPage() {
|
||||||
const [posts, setPosts] = useState([]);
|
const { isAdmin } = useAdmin();
|
||||||
|
const { data, setData: setPosts, loading, error, reload } = useJournalResource();
|
||||||
|
const posts = data ?? [];
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [activeTag, setActiveTag] = useState("All");
|
const [activeTag, setActiveTag] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [showOthers, setShowOthers] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const tags = [null, ...mainTopics];
|
||||||
|
const customTopics = useMemo(() => {
|
||||||
useEffect(() => {
|
const mainKeys = new Set(mainTopics.map(topicKey));
|
||||||
const controller = new AbortController();
|
const custom = new Map();
|
||||||
getPosts(controller.signal)
|
posts.flatMap((post) => post.tags).forEach((tag) => {
|
||||||
.then((result) => {
|
const key = topicKey(tag);
|
||||||
if (!controller.signal.aborted) setPosts(result);
|
if (key && !mainKeys.has(key) && !custom.has(key)) custom.set(key, tag.trim());
|
||||||
})
|
|
||||||
.catch((requestError) => {
|
|
||||||
if (requestError.name !== "AbortError") setError(requestError.message);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (!controller.signal.aborted) setLoading(false);
|
|
||||||
});
|
});
|
||||||
return () => controller.abort();
|
return [...custom.values()].sort((a, b) => a.localeCompare(b));
|
||||||
}, []);
|
|
||||||
|
|
||||||
const tags = useMemo(() => {
|
|
||||||
const counts = new Map();
|
|
||||||
posts.flatMap((post) => post.tags).forEach((tag) => counts.set(tag, (counts.get(tag) ?? 0) + 1));
|
|
||||||
return ["All", ...[...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6).map(([tag]) => tag)];
|
|
||||||
}, [posts]);
|
}, [posts]);
|
||||||
|
const customActive = activeTag !== null && !mainTopics.some((tag) => topicKey(tag) === topicKey(activeTag));
|
||||||
|
|
||||||
const filteredPosts = useMemo(() => {
|
const filteredPosts = useMemo(() => {
|
||||||
const needle = query.trim().toLowerCase();
|
const needle = query.trim().toLowerCase();
|
||||||
return posts.filter((post) => {
|
return posts.filter((post) => {
|
||||||
const matchesTag = activeTag === "All" || post.tags.includes(activeTag);
|
const matchesTag = activeTag === null || post.tags.some((tag) => topicKey(tag) === topicKey(activeTag));
|
||||||
const matchesQuery = !needle || `${post.title} ${post.excerpt} ${post.tags.join(" ")}`.toLowerCase().includes(needle);
|
const matchesQuery = !needle || `${post.title} ${post.excerpt} ${post.tags.join(" ")}`.toLowerCase().includes(needle);
|
||||||
return matchesTag && matchesQuery;
|
return matchesTag && matchesQuery;
|
||||||
});
|
});
|
||||||
@@ -88,8 +84,8 @@ export default function BlogPage() {
|
|||||||
description="Practical observations on applied AI, resilient products, and the technology choices behind them."
|
description="Practical observations on applied AI, resilient products, and the technology choices behind them."
|
||||||
aside={(
|
aside={(
|
||||||
<div className="journal-heading-aside">
|
<div className="journal-heading-aside">
|
||||||
<p className="issue-count">{posts.length || 10}<span>field notes</span></p>
|
<p className="issue-count">{posts.length}<span>field notes</span></p>
|
||||||
<Link className="write-link" to="/blog/manage"><Icon name="plus" size={15} /> Write</Link>
|
{isAdmin && <Link className="write-link" to="/blog/manage"><Icon name="plus" size={15} /> New article</Link>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -105,27 +101,44 @@ export default function BlogPage() {
|
|||||||
value={query}
|
value={query}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div className="filter-row" aria-label="Filter articles by topic">
|
<div className="journal-filters">
|
||||||
|
<div className="filter-row filter-row--wrap" role="group" aria-label="Filter articles by topic">
|
||||||
{tags.map((tag) => (
|
{tags.map((tag) => (
|
||||||
<button
|
<button
|
||||||
className={activeTag === tag ? "is-active" : ""}
|
className={activeTag === tag ? "is-active" : ""}
|
||||||
key={tag}
|
aria-pressed={activeTag === tag}
|
||||||
onClick={() => setActiveTag(tag)}
|
key={tag ?? "all-filter"}
|
||||||
|
onClick={() => { setActiveTag(tag); setShowOthers(false); }}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{tag}
|
{tag ?? "All"}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
<button type="button" className={`others-filter ${showOthers || customActive ? "is-active" : ""}`}
|
||||||
|
aria-expanded={showOthers} aria-controls="journal-custom-topics" onClick={() => setShowOthers((current) => !current)}>
|
||||||
|
Others{customActive && <span> · {activeTag}</span>} <Icon name="chevron" size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="journal-custom-topics" className="custom-topic-filters" hidden={!showOthers}>
|
||||||
|
<p>Custom topics <span>{customTopics.length}</span></p>
|
||||||
|
{customTopics.length ? <div className="filter-row filter-row--wrap" role="group" aria-label="Filter by custom topic">
|
||||||
|
{customTopics.map((tag) => <button key={topicKey(tag)} type="button"
|
||||||
|
className={topicKey(activeTag ?? "") === topicKey(tag) ? "is-active" : ""}
|
||||||
|
aria-pressed={topicKey(activeTag ?? "") === topicKey(tag)} onClick={() => setActiveTag(tag)}>{tag}</button>)}
|
||||||
|
</div> : <small>{loading ? "Loading topics..." : "No custom topics yet."}</small>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading && <LoadingState label="Fetching field notes" />}
|
{loading && !data && <LoadingState label="Fetching field notes" />}
|
||||||
{error && <ErrorState message={error} />}
|
{error && <ErrorState message={error} onRetry={reload} />}
|
||||||
|
{loading && data && <p role="status">Refreshing articles...</p>}
|
||||||
|
|
||||||
{!loading && !error && filteredPosts.length > 0 && (
|
{filteredPosts.length > 0 && (
|
||||||
<div className="posts-grid">
|
<div className="posts-grid">
|
||||||
{filteredPosts.map((post, index) => (
|
{filteredPosts.map((post, index) => (
|
||||||
<PostCard featured={index === 0 && !query && activeTag === "All"} key={post.slug} post={post} />
|
<PostCard featured={index === 0 && !query && activeTag === null} key={post.slug} post={post}
|
||||||
|
onDeleted={(slug) => setPosts((current) => current.filter((item) => item.slug !== slug))} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
import { Link, useParams } from "react-router-dom";
|
import { mediaUrl } from "../api";
|
||||||
import { getPost, mediaUrl } from "../api";
|
|
||||||
import { ArticleAttachments } from "../components/ArticleUploads";
|
import { ArticleAttachments } from "../components/ArticleUploads";
|
||||||
|
import ArticleAdminActions from "../components/ArticleAdminActions";
|
||||||
|
import useJournalResource from "../useJournalResource";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
import { RichTextArticle } from "../components/RichTextEditor";
|
import { RichTextArticle } from "../components/RichTextEditor";
|
||||||
import { ErrorState, LoadingState } from "../components/Status";
|
import { ErrorState, LoadingState } from "../components/Status";
|
||||||
@@ -16,33 +17,15 @@ function formatDate(date) {
|
|||||||
|
|
||||||
export default function BlogPostPage() {
|
export default function BlogPostPage() {
|
||||||
const { slug } = useParams();
|
const { slug } = useParams();
|
||||||
const [post, setPost] = useState(null);
|
const navigate = useNavigate();
|
||||||
const [loading, setLoading] = useState(true);
|
const { data: post, loading, error, reload } = useJournalResource(slug);
|
||||||
const [error, setError] = useState("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
if (loading && !post) {
|
||||||
const controller = new AbortController();
|
|
||||||
setLoading(true);
|
|
||||||
setError("");
|
|
||||||
getPost(slug, controller.signal)
|
|
||||||
.then((result) => {
|
|
||||||
if (!controller.signal.aborted) setPost(result);
|
|
||||||
})
|
|
||||||
.catch((requestError) => {
|
|
||||||
if (requestError.name !== "AbortError") setError(requestError.message);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (!controller.signal.aborted) setLoading(false);
|
|
||||||
});
|
|
||||||
return () => controller.abort();
|
|
||||||
}, [slug]);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <div className="container content-page"><LoadingState label="Opening the note" /></div>;
|
return <div className="container content-page"><LoadingState label="Opening the note" /></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error && !post) {
|
||||||
return <div className="container content-page"><ErrorState message={error} /></div>;
|
return <div className="container content-page"><ErrorState message={error} onRetry={reload} /></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!post) {
|
if (!post) {
|
||||||
@@ -51,11 +34,13 @@ export default function BlogPostPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="article-page">
|
<article className="article-page">
|
||||||
|
{error && <div className="article-container"><ErrorState message={error} onRetry={reload} /></div>}
|
||||||
<header className={`article-hero article-hero--${post.accent}`}>
|
<header className={`article-hero article-hero--${post.accent}`}>
|
||||||
{post.banner && <img className="article-banner" src={mediaUrl(post.banner)} alt="" />}
|
{post.banner && <img className="article-banner" src={mediaUrl(post.banner)} alt="" />}
|
||||||
<div className="article-hero__shape" aria-hidden="true"><Icon name="spark" size={50} /></div>
|
<div className="article-hero__shape" aria-hidden="true"><Icon name="spark" size={50} /></div>
|
||||||
<div className="article-container reveal">
|
<div className="article-container reveal">
|
||||||
<Link className="back-link" to="/blog"><Icon name="arrowLeft" size={17} /> Back to journal</Link>
|
<Link className="back-link" to="/blog"><Icon name="arrowLeft" size={17} /> Back to journal</Link>
|
||||||
|
<ArticleAdminActions post={post} onDeleted={() => navigate("/blog")} />
|
||||||
<div className="article-tags">
|
<div className="article-tags">
|
||||||
{post.tags.map((tag) => <span key={tag}>{tag}</span>)}
|
{post.tags.map((tag) => <span key={tag}>{tag}</span>)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -139,6 +139,8 @@ export default function ContactPage({ profile, error }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="culture-card culture-card--wide reveal">
|
<div className="culture-card culture-card--wide reveal">
|
||||||
|
<img className="culture-photo" src="/alex_music.jpg" alt="Alex playing guitar" width="206" height="206" loading="lazy" />
|
||||||
|
<div className="culture-content">
|
||||||
<div className="culture-intro">
|
<div className="culture-intro">
|
||||||
<p className="eyebrow">Beyond the build</p>
|
<p className="eyebrow">Beyond the build</p>
|
||||||
<h2><strong>I love music</strong> and nearly always have something playing on Spotify.</h2>
|
<h2><strong>I love music</strong> and nearly always have something playing on Spotify.</h2>
|
||||||
@@ -160,6 +162,7 @@ export default function ContactPage({ profile, error }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,18 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
import { createPost, getAdminSession, loginAdmin, uploadMedia } from "../api";
|
import { createPost, getPost, updateArticle, uploadMedia } from "../api";
|
||||||
import ArticleUploads from "../components/ArticleUploads";
|
import ArticleUploads from "../components/ArticleUploads";
|
||||||
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 RichTextEditor from "../components/RichTextEditor";
|
||||||
import { LoadingState } from "../components/Status";
|
import { ErrorState, LoadingState } from "../components/Status";
|
||||||
|
import { useAdmin } from "../components/AdminSession";
|
||||||
|
import { editableDocument, articlePlainText } from "../articleContent";
|
||||||
|
import { mainTopics as suggestedTopics } from "../topics";
|
||||||
|
|
||||||
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 emptyDocument = { type: "doc", content: [{ type: "paragraph" }] };
|
||||||
const suggestedTopics = [
|
|
||||||
"AI",
|
|
||||||
"React",
|
|
||||||
"FastAPI",
|
|
||||||
"Supabase",
|
|
||||||
"Python",
|
|
||||||
"Cloud",
|
|
||||||
"DevOps",
|
|
||||||
"Product",
|
|
||||||
"Reliability",
|
|
||||||
"Security",
|
|
||||||
];
|
|
||||||
|
|
||||||
const initialPost = {
|
const initialPost = {
|
||||||
title: "",
|
title: "",
|
||||||
@@ -33,10 +24,15 @@ const initialPost = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function JournalAdminPage() {
|
export default function JournalAdminPage() {
|
||||||
|
const { slug } = useParams();
|
||||||
|
return <ArticleForm key={slug ?? "new"} slug={slug} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArticleForm({ slug }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [token, setToken] = useState(() => sessionStorage.getItem(TOKEN_KEY) ?? "");
|
const { token, checking, isAdmin, signOut, openSignIn } = useAdmin();
|
||||||
const [checking, setChecking] = useState(Boolean(token));
|
const [loading, setLoading] = useState(Boolean(slug));
|
||||||
const [password, setPassword] = useState("");
|
const [loadError, setLoadError] = useState("");
|
||||||
const [post, setPost] = useState(initialPost);
|
const [post, setPost] = useState(initialPost);
|
||||||
const [article, setArticle] = useState(emptyDocument);
|
const [article, setArticle] = useState(emptyDocument);
|
||||||
const [articleText, setArticleText] = useState("");
|
const [articleText, setArticleText] = useState("");
|
||||||
@@ -44,80 +40,72 @@ export default function JournalAdminPage() {
|
|||||||
const [attachments, setAttachments] = useState([]);
|
const [attachments, setAttachments] = useState([]);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [customTopic, setCustomTopic] = useState("");
|
const [customTopic, setCustomTopic] = useState("");
|
||||||
|
const [topicMessage, setTopicMessage] = useState("");
|
||||||
|
const topicInput = useRef(null);
|
||||||
const [status, setStatus] = useState({ type: "idle", message: "" });
|
const [status, setStatus] = useState({ type: "idle", message: "" });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) {
|
if (!slug) return;
|
||||||
setChecking(false);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
getAdminSession(token, controller.signal)
|
getPost(slug, controller.signal)
|
||||||
.catch((error) => {
|
.then((existing) => {
|
||||||
if (error.name !== "AbortError") {
|
if (controller.signal.aborted) return;
|
||||||
sessionStorage.removeItem(TOKEN_KEY);
|
const document = editableDocument(existing.content);
|
||||||
setToken("");
|
setPost({ title: existing.title, excerpt: existing.excerpt, published_at: existing.published_at,
|
||||||
setStatus({ type: "error", message: "Your session ended. Sign in again." });
|
read_time: existing.read_time, tags: existing.tags, accent: existing.accent });
|
||||||
}
|
setArticle(document);
|
||||||
|
setArticleText(articlePlainText(document));
|
||||||
|
setBanner(existing.banner ?? null);
|
||||||
|
setAttachments(existing.attachments ?? []);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.catch((error) => { if (error.name !== "AbortError") setLoadError(error.message); })
|
||||||
if (!controller.signal.aborted) setChecking(false);
|
.finally(() => { if (!controller.signal.aborted) setLoading(false); });
|
||||||
});
|
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [token]);
|
}, [slug]);
|
||||||
|
|
||||||
async function signIn(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
setStatus({ type: "sending", message: "Signing in…" });
|
|
||||||
try {
|
|
||||||
const result = await loginAdmin(password);
|
|
||||||
sessionStorage.setItem(TOKEN_KEY, result.access_token);
|
|
||||||
setToken(result.access_token);
|
|
||||||
setPassword("");
|
|
||||||
setStatus({ type: "success", message: "Signed in. Your session lasts four hours." });
|
|
||||||
} catch (error) {
|
|
||||||
setStatus({ type: "error", message: error.message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function signOut() {
|
|
||||||
sessionStorage.removeItem(TOKEN_KEY);
|
|
||||||
setToken("");
|
|
||||||
setStatus({ type: "idle", message: "" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function updatePost(event) {
|
function updatePost(event) {
|
||||||
setPost((current) => ({ ...current, [event.target.name]: event.target.value }));
|
setPost((current) => ({ ...current, [event.target.name]: event.target.value }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleTopic(topic) {
|
function toggleTopic(topic) {
|
||||||
setPost((current) => {
|
if (!post.tags.includes(topic) && post.tags.length >= 6) {
|
||||||
if (current.tags.includes(topic)) {
|
setTopicMessage("You can select up to six topics. Remove one to add another.");
|
||||||
return { ...current, tags: current.tags.filter((item) => item !== topic) };
|
return;
|
||||||
}
|
}
|
||||||
if (current.tags.length >= 6) {
|
setTopicMessage("");
|
||||||
setStatus({ type: "error", message: "Choose up to six topics." });
|
setPost((current) => ({ ...current, tags: current.tags.includes(topic)
|
||||||
return current;
|
? current.tags.filter((item) => item !== topic) : [...current.tags, topic] }));
|
||||||
}
|
|
||||||
return { ...current, tags: [...current.tags, topic] };
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function addCustomTopic() {
|
function addCustomTopic() {
|
||||||
const topic = customTopic.trim();
|
const typed = customTopic.trim();
|
||||||
if (!topic || post.tags.includes(topic)) return;
|
if (!typed) return;
|
||||||
|
const topic = [...suggestedTopics, ...post.tags].find((item) => item.toLowerCase() === typed.toLowerCase()) ?? typed;
|
||||||
|
if (post.tags.includes(topic)) {
|
||||||
|
setCustomTopic("");
|
||||||
|
setTopicMessage(`${topic} is already selected.`);
|
||||||
|
topicInput.current?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (post.tags.length >= 6) {
|
if (post.tags.length >= 6) {
|
||||||
setStatus({ type: "error", message: "Choose up to six topics." });
|
setTopicMessage("You can select up to six topics. Remove one to add another.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPost((current) => ({ ...current, tags: [...current.tags, topic] }));
|
setPost((current) => ({ ...current, tags: [...current.tags, topic] }));
|
||||||
setCustomTopic("");
|
setCustomTopic("");
|
||||||
|
setTopicMessage(`${topic} added and selected.`);
|
||||||
|
topicInput.current?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCustomTopic(topic) {
|
||||||
|
setPost((current) => ({ ...current, tags: current.tags.filter((item) => item !== topic) }));
|
||||||
|
setTopicMessage(`${topic} removed.`);
|
||||||
|
topicInput.current?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function publish(event) {
|
async function publish(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (uploading || status.type === "sending") return;
|
if (!isAdmin || uploading || status.type === "sending") return;
|
||||||
if (!post.tags.length) {
|
if (!post.tags.length) {
|
||||||
setStatus({ type: "error", message: "Choose at least one topic." });
|
setStatus({ type: "error", message: "Choose at least one topic." });
|
||||||
return;
|
return;
|
||||||
@@ -135,89 +123,60 @@ export default function JournalAdminPage() {
|
|||||||
attachments,
|
attachments,
|
||||||
};
|
};
|
||||||
|
|
||||||
setStatus({ type: "sending", message: "Publishing…" });
|
setStatus({ type: "sending", message: slug ? "Saving changes..." : "Publishing..." });
|
||||||
try {
|
try {
|
||||||
const created = await createPost(payload, token);
|
const created = slug ? await updateArticle(slug, payload, token) : await createPost(payload, token);
|
||||||
setStatus({ type: "success", message: "Published. Opening the article…" });
|
setStatus({ type: "success", message: "Published. Opening the article…" });
|
||||||
navigate(`/blog/${created.slug}`);
|
navigate(`/blog/${created.slug}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.status === 401) {
|
|
||||||
sessionStorage.removeItem(TOKEN_KEY);
|
|
||||||
setToken("");
|
|
||||||
}
|
|
||||||
setStatus({ type: "error", message: error.message });
|
setStatus({ type: "error", message: error.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadFiles(files, purpose) {
|
async function uploadFile(file, purpose, options) {
|
||||||
if (!files.length || uploading) return;
|
const uploaded = await uploadMedia(file, purpose, token, options);
|
||||||
if (purpose === "attachment" && attachments.length + files.length > 10) {
|
if (options.signal.aborted) return;
|
||||||
setStatus({ type: "error", message: "Choose up to 10 additional files." });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const limit = (purpose === "banner" ? 8 : 20) * 1024 * 1024;
|
|
||||||
if (files.some((file) => !file.size || file.size > limit)) {
|
|
||||||
setStatus({ type: "error", message: `Choose non-empty files up to ${limit / 1024 / 1024} MB each.` });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setUploading(true);
|
|
||||||
setStatus({ type: "idle", message: "" });
|
|
||||||
try {
|
|
||||||
for (const file of files) {
|
|
||||||
const uploaded = await uploadMedia(file, purpose, token);
|
|
||||||
if (purpose === "banner") setBanner(uploaded);
|
if (purpose === "banner") setBanner(uploaded);
|
||||||
else setAttachments((current) => [...current, uploaded]);
|
else setAttachments((current) => [...current, uploaded]);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
setStatus({ type: "error", message: error.message });
|
function moveAttachment(url, direction) {
|
||||||
} finally {
|
setAttachments((current) => {
|
||||||
setUploading(false);
|
const index = current.findIndex((file) => file.url === url);
|
||||||
}
|
const next = index + direction;
|
||||||
|
if (index < 0 || next < 0 || next >= current.length) return current;
|
||||||
|
const ordered = [...current];
|
||||||
|
[ordered[index], ordered[next]] = [ordered[next], ordered[index]];
|
||||||
|
return ordered;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="content-page container publisher-page">
|
<section className="content-page container publisher-page">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
eyebrow="Journal studio"
|
eyebrow="Journal studio"
|
||||||
title="Publish a field note."
|
title={slug ? "Edit your field note." : "Publish a field note."}
|
||||||
description="A focused writing room with rich-text editing and Supabase-ready storage."
|
description="Write in sections, add photos and files, and share your field notes."
|
||||||
aside={token ? <span className="publisher-badge"><span /> Authenticated</span> : null}
|
aside={isAdmin ? <span className="publisher-badge"><span /> Admin</span> : null}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{checking && <LoadingState label="Checking your session" />}
|
{checking && <LoadingState label="Checking your session" />}
|
||||||
|
|
||||||
{!checking && !token && (
|
{!checking && !isAdmin && (
|
||||||
<form className="publisher-login reveal" onSubmit={signIn}>
|
<div className="publisher-login">
|
||||||
<span className="publisher-login__icon"><Icon name="lock" size={24} /></span>
|
<h2>Sign in to manage articles</h2>
|
||||||
<div>
|
<p>Use your admin password to create or edit a journal article.</p>
|
||||||
<p className="eyebrow">Private access</p>
|
<button className="button button--primary" type="button" onClick={openSignIn}><Icon name="lock" size={18} /> Sign in</button>
|
||||||
<h2>Sign in to write</h2>
|
|
||||||
<p>The publisher uses one server-side password. It is never stored in the browser.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<label>
|
|
||||||
<span>Admin password</span>
|
|
||||||
<input
|
|
||||||
autoComplete="current-password"
|
|
||||||
autoFocus
|
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
|
||||||
placeholder="Enter your journal password"
|
|
||||||
required
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<button className="button button--primary" disabled={status.type === "sending"} type="submit">
|
|
||||||
Unlock publisher <Icon name="arrow" size={18} />
|
|
||||||
</button>
|
|
||||||
{status.message && <p className={`form-status form-status--${status.type}`} role="status">{status.message}</p>}
|
|
||||||
</form>
|
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && loading && <LoadingState label="Loading article" />}
|
||||||
|
{isAdmin && loadError && <ErrorState message={loadError} />}
|
||||||
|
|
||||||
{!checking && token && (
|
{isAdmin && !loading && !loadError && (
|
||||||
<form className="publisher-form reveal" onSubmit={publish}>
|
<form className="publisher-form reveal" onSubmit={publish}>
|
||||||
<div className="publisher-toolbar">
|
<div className="publisher-toolbar">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">New article</p>
|
<p className="eyebrow">{slug ? "Edit article" : "New article"}</p>
|
||||||
<p>Write, format, choose the topics, and publish from one clean workspace.</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>
|
||||||
@@ -268,22 +227,41 @@ export default function JournalAdminPage() {
|
|||||||
{post.tags.includes(topic) ? "✓ " : "+ "}{topic}
|
{post.tags.includes(topic) ? "✓ " : "+ "}{topic}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
{post.tags.filter((topic) => !suggestedTopics.includes(topic)).map((topic) => (
|
||||||
|
<span className="topic-chip is-active" key={topic}>
|
||||||
|
<span><Icon name="check" size={13} /> {topic}</span>
|
||||||
|
<button className="topic-chip__remove" type="button" aria-label={`Remove topic ${topic}`}
|
||||||
|
title={`Remove ${topic}`} onClick={() => removeCustomTopic(topic)}><Icon name="close" size={14} /></button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="custom-topic">
|
<div className="custom-topic">
|
||||||
|
<div className="custom-topic__input">
|
||||||
|
<label className="sr-only" htmlFor="custom-topic">Custom topic</label>
|
||||||
<input
|
<input
|
||||||
|
ref={topicInput}
|
||||||
|
id="custom-topic"
|
||||||
|
aria-describedby="topic-shortcut topic-feedback"
|
||||||
|
aria-keyshortcuts="Enter"
|
||||||
|
enterKeyHint="done"
|
||||||
maxLength="40"
|
maxLength="40"
|
||||||
onChange={(event) => setCustomTopic(event.target.value)}
|
onChange={(event) => { setCustomTopic(event.target.value); setTopicMessage(""); }}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === "Enter") {
|
if (event.key === "Enter") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
if (event.nativeEvent.isComposing || event.keyCode === 229) return;
|
||||||
addCustomTopic();
|
addCustomTopic();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Add another topic"
|
placeholder="Add another topic"
|
||||||
value={customTopic}
|
value={customTopic}
|
||||||
/>
|
/>
|
||||||
<button onClick={addCustomTopic} type="button">Add topic</button>
|
<kbd aria-hidden="true">Enter ↵</kbd>
|
||||||
</div>
|
</div>
|
||||||
|
<button onClick={addCustomTopic} disabled={!customTopic.trim()} type="button">Add topic</button>
|
||||||
|
</div>
|
||||||
|
<p className="topic-hint" id="topic-shortcut">Type a topic and press <kbd>Enter</kbd> to add and select it. Use × on a custom topic to remove it.</p>
|
||||||
|
<p className="topic-feedback" id="topic-feedback" role="status">{topicMessage}</p>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<div className="publisher-field publisher-field--wide">
|
<div className="publisher-field publisher-field--wide">
|
||||||
@@ -293,13 +271,15 @@ export default function JournalAdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="section-help">Use + Section to add a heading and a new section. Each section gets a divider and an automatic uppercase drop cap on its opening paragraph.</p>
|
<p className="section-help">Use + Section to add a heading and a new section. Each section gets a divider and an automatic uppercase drop cap on its opening paragraph.</p>
|
||||||
<ArticleUploads banner={banner} attachments={attachments} busy={uploading || status.type === "sending"}
|
<ArticleUploads banner={banner} attachments={attachments} busy={status.type === "sending"}
|
||||||
onUpload={uploadFiles} onRemoveBanner={() => setBanner(null)}
|
title={post.title} subtitle={post.excerpt} onPendingChange={setUploading} onMoveAttachment={moveAttachment}
|
||||||
|
onUpload={uploadFile} onRemoveBanner={() => setBanner(null)}
|
||||||
onRemoveAttachment={(url) => setAttachments((current) => current.filter((file) => file.url !== url))} />
|
onRemoveAttachment={(url) => setAttachments((current) => current.filter((file) => file.url !== url))} />
|
||||||
|
|
||||||
<div className="publisher-submit">
|
<div className="publisher-submit">
|
||||||
<button className="button button--primary" disabled={uploading || status.type === "sending"} type="submit">
|
<Link className="text-button" to={slug ? `/blog/${slug}` : "/blog"}>Cancel</Link>
|
||||||
<Icon name="plus" size={18} /> {status.type === "sending" ? "Publishing…" : "Publish article"}
|
<button className="button button--primary" disabled={!isAdmin || uploading || status.type === "sending"} type="submit">
|
||||||
|
<Icon name={slug ? "check" : "plus"} size={18} /> {status.type === "sending" ? "Saving..." : slug ? "Save changes" : "Publish article"}
|
||||||
</button>
|
</button>
|
||||||
{status.message && <p className={`form-status form-status--${status.type}`} role="status">{status.message}</p>}
|
{status.message && <p className={`form-status form-status--${status.type}`} role="status">{status.message}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+249
-16
@@ -189,7 +189,6 @@ button {
|
|||||||
|
|
||||||
.nav-link:hover {
|
.nav-link:hover {
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link.is-active {
|
.nav-link.is-active {
|
||||||
@@ -1078,6 +1077,21 @@ button {
|
|||||||
background: rgba(255, 255, 255, 0.72);
|
background: rgba(255, 255, 255, 0.72);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.journal-filters { flex: 1; min-width: 0; }
|
||||||
|
.journal-tools:has(.journal-filters) { align-items: flex-start; }
|
||||||
|
.filter-row--wrap { flex-wrap: wrap; overflow: visible; }
|
||||||
|
.filter-row--wrap button { max-width: 100%; overflow-wrap: anywhere; }
|
||||||
|
.filter-row .others-filter { display: inline-flex; align-items: center; gap: 5px; border: 1px solid var(--line); }
|
||||||
|
.others-filter svg { flex-shrink: 0; transition: transform 150ms ease; }
|
||||||
|
.others-filter[aria-expanded="true"] svg { transform: rotate(180deg); }
|
||||||
|
.custom-topic-filters { margin-top: 12px; padding: 15px; border: 1px solid var(--line); border-radius: 16px; background: rgba(255, 255, 255, 0.45); }
|
||||||
|
.custom-topic-filters[hidden] { display: none; }
|
||||||
|
.custom-topic-filters > p { display: flex; align-items: center; gap: 8px; margin: 0 0 10px; color: var(--ink-soft); font-size: 11px; font-weight: 650; }
|
||||||
|
.custom-topic-filters > p span { padding: 2px 7px; border-radius: 10px; background: var(--mint); font-size: 10px; }
|
||||||
|
.custom-topic-filters > small { color: var(--ink-soft); font-size: 11px; }
|
||||||
|
.custom-topic-filters .filter-row button { background: var(--surface); }
|
||||||
|
.custom-topic-filters .filter-row button.is-active { color: white; background: var(--sage-deep); }
|
||||||
|
|
||||||
.posts-grid {
|
.posts-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
@@ -1750,7 +1764,7 @@ button {
|
|||||||
|
|
||||||
.culture-intro {
|
.culture-intro {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(240px, 0.8fr) minmax(320px, 1.2fr);
|
grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr);
|
||||||
align-items: end;
|
align-items: end;
|
||||||
gap: 8px 48px;
|
gap: 8px 48px;
|
||||||
margin-bottom: 28px;
|
margin-bottom: 28px;
|
||||||
@@ -2057,6 +2071,32 @@ button {
|
|||||||
box-shadow: 0 8px 19px rgba(47, 81, 71, 0.15);
|
box-shadow: 0 8px 19px rgba(47, 81, 71, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topic-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
min-height: 38px;
|
||||||
|
max-width: 100%;
|
||||||
|
padding-left: 13px;
|
||||||
|
border-radius: 12px;
|
||||||
|
color: white;
|
||||||
|
background: var(--sage-deep);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 680;
|
||||||
|
box-shadow: 0 8px 19px rgba(47, 81, 71, 0.15);
|
||||||
|
}
|
||||||
|
.topic-chip > span { display: inline-flex; align-items: center; gap: 5px; min-width: 0; overflow-wrap: anywhere; }
|
||||||
|
.topic-chip > span svg { flex-shrink: 0; }
|
||||||
|
.topic-options .topic-chip__remove { display: grid; place-items: center; flex-shrink: 0; width: 36px; padding: 0; color: white; background: transparent; }
|
||||||
|
.topic-options .topic-chip__remove:hover { background: rgba(255, 255, 255, 0.16); }
|
||||||
|
.topic-options .topic-chip__remove:focus-visible { outline: 2px solid var(--sage-deep); outline-offset: 3px; }
|
||||||
|
.custom-topic__input { position: relative; display: flex; flex: 1; min-width: 0; }
|
||||||
|
.custom-topic__input input { width: 100%; padding-right: 82px; }
|
||||||
|
.custom-topic__input > kbd { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); pointer-events: none; }
|
||||||
|
.topic-picker kbd { padding: 3px 6px; border: 1px solid var(--line); border-bottom-width: 2px; border-radius: 6px; color: var(--ink-soft); background: #f0f3ef; font: 10px ui-monospace, monospace; white-space: nowrap; }
|
||||||
|
.topic-hint, .topic-feedback { margin: 0; font-size: 11px; line-height: 1.8; color: var(--ink-soft); }
|
||||||
|
.topic-feedback { color: var(--sage-deep); }
|
||||||
|
|
||||||
.custom-topic {
|
.custom-topic {
|
||||||
display: flex;
|
display: flex;
|
||||||
max-width: 430px;
|
max-width: 430px;
|
||||||
@@ -2075,6 +2115,7 @@ button {
|
|||||||
background: #fbfcfa;
|
background: #fbfcfa;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
.custom-topic .custom-topic__input input { padding-right: 82px; }
|
||||||
|
|
||||||
.custom-topic input:focus {
|
.custom-topic input:focus {
|
||||||
border-color: rgba(47, 81, 71, 0.43);
|
border-color: rgba(47, 81, 71, 0.43);
|
||||||
@@ -2715,37 +2756,229 @@ button {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
.article-hero:has(.article-banner) { padding-top: 0; }
|
.article-hero:has(.article-banner) {
|
||||||
|
isolation: isolate;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
.article-banner {
|
.article-banner {
|
||||||
display: block;
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: clamp(220px, 38vw, 500px);
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
margin-bottom: clamp(36px, 6vw, 72px);
|
z-index: -2;
|
||||||
|
}
|
||||||
|
.article-hero:has(.article-banner)::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(110deg, rgba(15, 29, 24, 0.82), rgba(15, 29, 24, 0.58));
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.article-hero:has(.article-banner) .article-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
.article-hero:has(.article-banner) .article-container > p,
|
||||||
|
.article-hero:has(.article-banner) .article-byline,
|
||||||
|
.article-hero:has(.article-banner) .article-byline--simple span + span::before {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
.article-hero:has(.article-banner) .article-tags span {
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(255, 255, 255, 0.16);
|
||||||
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
.article-hero:has(.article-banner) .article-hero__shape { display: none; }
|
.article-hero:has(.article-banner) .article-hero__shape { display: none; }
|
||||||
.article-uploads {
|
.article-uploads {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 24px;
|
grid-column: 1 / -1;
|
||||||
|
gap: 30px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 24px;
|
margin: 0;
|
||||||
|
padding: clamp(18px, 3vw, 30px);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 18px;
|
border-radius: 22px;
|
||||||
|
background: rgba(255, 255, 255, 0.45);
|
||||||
}
|
}
|
||||||
.article-uploads legend { padding-inline: 8px; }
|
.article-uploads legend { padding-inline: 10px; font-size: 16px; }
|
||||||
|
.section-help { grid-column: 1 / -1; }
|
||||||
.article-uploads small, .section-help { color: var(--ink-soft); line-height: 1.6; }
|
.article-uploads small, .section-help { color: var(--ink-soft); line-height: 1.6; }
|
||||||
.article-uploads input[type="file"] { max-width: 100%; padding: 12px 0; }
|
.upload-section { display: grid; gap: 14px; min-width: 0; }
|
||||||
.upload-preview { display: grid; gap: 12px; justify-items: start; overflow-wrap: anywhere; }
|
.upload-section + .upload-section { border-top: 1px solid var(--line); padding-top: 28px; }
|
||||||
.upload-preview img { width: 100%; max-height: 240px; object-fit: cover; border-radius: 12px; }
|
.upload-section__heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; }
|
||||||
.upload-list { display: grid; gap: 12px; list-style: none; margin: 0; padding: 0; }
|
.upload-section__heading h3, .upload-queue h3 { margin: 0 0 5px; font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; }
|
||||||
.upload-list li { display: flex; align-items: center; gap: 14px; }
|
.upload-section__heading p { margin: 0; color: var(--ink-soft); font-size: 12px; line-height: 1.6; }
|
||||||
.upload-list li > span { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
.upload-label { flex-shrink: 0; padding: 3px 9px; border-radius: 20px; background: var(--mint); color: var(--sage-deep); font-size: 11px; }
|
||||||
.upload-list img { width: 64px; height: 64px; object-fit: cover; border-radius: 8px; }
|
.upload-dropzone {
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 26px 18px;
|
||||||
|
border: 1.5px dashed #b7cbc0;
|
||||||
|
border-radius: 17px;
|
||||||
|
background: linear-gradient(135deg, #f2f7f3, #fafcf9);
|
||||||
|
text-align: center;
|
||||||
|
transition: border-color 150ms, background 150ms, box-shadow 150ms;
|
||||||
|
}
|
||||||
|
.upload-dropzone:hover, .upload-dropzone:focus-within { border-color: var(--sage-deep); }
|
||||||
|
.upload-dropzone.is-dragging { border-color: var(--sage-deep); background: var(--mint); box-shadow: inset 0 0 0 2px var(--sage-deep); }
|
||||||
|
.upload-dropzone.is-disabled { opacity: 0.6; }
|
||||||
|
.upload-dropzone__icon { display: grid; place-items: center; width: 48px; height: 48px; margin-bottom: 4px; border-radius: 16px; background: #e3eee6; color: var(--sage-deep); }
|
||||||
|
.upload-dropzone strong { font-size: 15px; font-weight: 600; }
|
||||||
|
.upload-dropzone p { margin: 0; color: var(--ink-soft); font-size: 12px; line-height: 1.6; }
|
||||||
|
.upload-dropzone small { font-size: 11px; }
|
||||||
|
.upload-browse { display: inline-flex; justify-content: center; align-items: center; gap: 7px; min-height: 40px; margin-top: 5px; padding: 9px 15px; border: 1px solid var(--line); border-radius: 11px; background: white; color: var(--sage-deep); font-size: 12px; font-weight: 650; cursor: pointer; }
|
||||||
|
.article-uploads .upload-input { display: none; }
|
||||||
|
.upload-tip { margin: 0; color: var(--ink-soft); font-size: 11px; line-height: 1.6; }
|
||||||
|
.upload-hero-preview { position: relative; isolation: isolate; display: grid; align-items: end; min-height: 220px; overflow: hidden; border-radius: 16px; background: var(--sage-deep); color: white; }
|
||||||
|
.upload-hero-preview > img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; z-index: -2; }
|
||||||
|
.upload-hero-preview::before { content: ""; position: absolute; inset: 0; z-index: -1; background: linear-gradient(110deg, rgba(15,29,24,.82), rgba(15,29,24,.58)); }
|
||||||
|
.upload-hero-preview > div { display: grid; gap: 12px; padding: 25px; }
|
||||||
|
.upload-hero-preview span { font-size: 10px; text-transform: uppercase; letter-spacing: .08em; opacity: .8; }
|
||||||
|
.upload-hero-preview strong { font-family: Georgia, serif; font-size: clamp(24px, 3vw, 38px); line-height: 1.1; font-weight: 400; overflow-wrap: anywhere; }
|
||||||
|
.upload-hero-preview p { margin: 0; max-width: 500px; font-size: 12px; line-height: 1.6; color: rgba(255,255,255,.9); overflow-wrap: anywhere; }
|
||||||
|
.upload-saved-banner { display: flex; align-items: center; justify-content: space-between; gap: 12px; font-size: 12px; }
|
||||||
|
.upload-saved-banner > span { min-width: 0; overflow-wrap: anywhere; color: var(--sage-deep); }
|
||||||
|
.upload-saved-banner .text-button { flex-shrink: 0; font-size: 11px; }
|
||||||
|
.upload-list { display: grid; gap: 10px; list-style: none; margin: 0; padding: 0; }
|
||||||
|
.upload-list li { display: flex; align-items: center; gap: 12px; min-width: 0; padding: 12px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
|
||||||
|
.upload-file-icon { display: grid; place-items: center; flex: 0 0 42px; width: 42px; height: 46px; border-radius: 9px; overflow: hidden; background: var(--mint); color: var(--sage-deep); }
|
||||||
|
.upload-file-icon img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.upload-file-info { flex: 1; min-width: 0; display: grid; gap: 3px; overflow-wrap: anywhere; }
|
||||||
|
.upload-file-info strong { font-size: 12px; font-weight: 600; }
|
||||||
|
.upload-file-info small { font-size: 10px; }
|
||||||
|
.upload-ready { color: var(--sage-deep); }
|
||||||
|
.upload-file-actions { display: flex; align-items: center; flex-shrink: 0; gap: 2px; }
|
||||||
|
.upload-icon-button { display: grid; place-items: center; width: 34px; height: 36px; padding: 0; border: 0; border-radius: 8px; background: transparent; color: var(--ink-soft); cursor: pointer; }
|
||||||
|
.upload-icon-button:hover { background: var(--mint); color: var(--sage-deep); }
|
||||||
|
.upload-icon-button:disabled { opacity: 0.25; }
|
||||||
|
.upload-queue { display: grid; gap: 12px; }
|
||||||
|
.upload-file-info progress { width: 100%; height: 6px; margin-top: 4px; accent-color: var(--sage-deep); }
|
||||||
|
.upload-file-info p { margin: 0; color: #9a3434; font-size: 11px; line-height: 1.6; }
|
||||||
|
.upload-list .has-error { border-color: #d7af9e; background: #fff9f6; }
|
||||||
|
.upload-feedback { padding: 16px; border: 1px solid #d7af9e; border-radius: 14px; background: #fff9f6; color: #803f30; font-size: 12px; overflow-wrap: anywhere; }
|
||||||
|
.upload-feedback ul { margin: 8px 0 12px; padding-left: 18px; line-height: 1.7; }
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.article-uploads { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: start; }
|
||||||
|
.upload-section + .upload-section { border-top: 0; border-left: 1px solid var(--line); padding-top: 0; padding-left: 28px; }
|
||||||
|
.upload-feedback, .upload-queue { grid-column: 1 / -1; }
|
||||||
|
}
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.upload-list li { flex-wrap: wrap; gap: 8px; }
|
||||||
|
.upload-file-actions { margin-left: auto; }
|
||||||
|
.upload-file-info { flex-basis: calc(100% - 54px); }
|
||||||
|
.upload-icon-button { width: 40px; height: 40px; }
|
||||||
|
.upload-saved-banner { align-items: flex-start; }
|
||||||
|
}
|
||||||
.article-attachments { margin-top: 55px; padding-top: 48px; border-top: 1px solid var(--line); }
|
.article-attachments { margin-top: 55px; padding-top: 48px; border-top: 1px solid var(--line); }
|
||||||
.article-attachments figure { margin: 24px 0; }
|
.article-attachments figure { margin: 24px 0; }
|
||||||
.article-attachments img { display: block; max-width: 100%; height: auto; margin-inline: auto; border-radius: 12px; }
|
.article-attachments img { display: block; max-width: 100%; height: auto; margin-inline: auto; border-radius: 12px; }
|
||||||
.article-attachments figcaption { margin-top: 10px; color: var(--ink-soft); font-size: 14px; overflow-wrap: anywhere; }
|
.article-attachments figcaption { margin-top: 10px; color: var(--ink-soft); font-size: 14px; overflow-wrap: anywhere; }
|
||||||
.article-file { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px; padding: 20px; margin-block: 12px; background: var(--surface); border: 1px solid var(--line); border-radius: 12px; overflow-wrap: anywhere; }
|
.article-file { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px; padding: 20px; margin-block: 12px; background: var(--surface); border: 1px solid var(--line); border-radius: 12px; overflow-wrap: anywhere; }
|
||||||
.article-file small { color: var(--ink-soft); }
|
.article-file small { color: var(--ink-soft); }
|
||||||
|
|
||||||
|
.header-actions { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.icon-button {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 15px;
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--surface);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.icon-button:hover { background: var(--mint); }
|
||||||
|
.header-signin { position: relative; }
|
||||||
|
.header-signin.is-admin { color: #fff; background: var(--sage-deep); }
|
||||||
|
.admin-indicator { position: absolute; right: 3px; top: 3px; width: 8px; height: 8px; border-radius: 50%; background: #bde9a8; }
|
||||||
|
.admin-dialog {
|
||||||
|
width: min(460px, calc(100vw - 32px));
|
||||||
|
max-height: calc(100dvh - 40px);
|
||||||
|
overflow: auto;
|
||||||
|
padding: 28px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 24px;
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--paper);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
font-family: Roboto, ui-sans-serif, sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.6;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.admin-dialog::backdrop { background: rgba(15, 29, 24, 0.5); backdrop-filter: blur(5px); }
|
||||||
|
.admin-dialog__heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
|
||||||
|
.admin-dialog .admin-dialog__heading h2 { margin: 0; font-family: Georgia, serif; font-size: 28px; font-weight: 400; line-height: 1.2; }
|
||||||
|
.admin-signin, .admin-account { display: grid; gap: 20px; }
|
||||||
|
.admin-signin p, .admin-account p { margin: 0; }
|
||||||
|
.admin-signin .publisher-field { margin: 0; }
|
||||||
|
.dialog-actions { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 12px; margin-top: 24px; }
|
||||||
|
.article-admin-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 20px; }
|
||||||
|
.post-card:has(.article-admin-actions) { display: flex; flex-direction: column; }
|
||||||
|
.post-card:has(.article-admin-actions) > a { height: auto; flex: 1; }
|
||||||
|
.post-card > .article-admin-actions { padding: 16px 24px; border-top: 1px solid var(--line); }
|
||||||
|
.article-hero .article-admin-actions { width: fit-content; padding: 10px 18px; border-radius: 12px; background: var(--paper); color: var(--ink); margin: -16px 0 28px; }
|
||||||
|
.text-button--danger { color: #9a3434; }
|
||||||
|
.button--danger { color: #fff; background: #9a3434; border-color: #9a3434; }
|
||||||
|
.button--danger:hover { background: #7e2929; }
|
||||||
|
button:disabled { cursor: not-allowed; opacity: 0.6; }
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.header-actions { gap: 8px; }
|
||||||
|
.admin-dialog { padding: 22px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-photo { display: block; flex-shrink: 0; border-radius: 50%; object-fit: cover; background: transparent; }
|
||||||
|
.culture-card--wide { display: grid; grid-template-columns: 206px minmax(0, 1fr); align-items: center; gap: clamp(24px, 3vw, 40px); }
|
||||||
|
.culture-photo { display: block; width: 100%; max-width: 206px; height: auto; aspect-ratio: 1; object-fit: cover; border-radius: 22px; box-shadow: 0 14px 30px rgba(35, 30, 45, 0.18); }
|
||||||
|
.culture-content { min-width: 0; }
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
.culture-card--wide { grid-template-columns: 170px minmax(0, 1fr); gap: 26px; }
|
||||||
|
.culture-content .culture-intro { grid-template-columns: 1fr; gap: 12px; }
|
||||||
|
.culture-content .culture-links { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.culture-card--wide { grid-template-columns: 1fr; }
|
||||||
|
.culture-photo { width: 180px; max-width: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Motion feedback is reserved for large action buttons and interest cards. */
|
||||||
|
:is(.button, .culture-link) {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
touch-action: manipulation;
|
||||||
|
transition: translate 160ms ease, scale 120ms ease, background-color 160ms ease, color 160ms ease, box-shadow 160ms ease, filter 160ms ease;
|
||||||
|
}
|
||||||
|
:is(button, .button, .nav-link, .culture-link, .brand, .write-link, .social-list a):focus-visible {
|
||||||
|
outline: 3px solid #668979;
|
||||||
|
outline-offset: 4px;
|
||||||
|
}
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
:is(.button, .culture-link):not(:disabled):hover {
|
||||||
|
translate: 0 -2px;
|
||||||
|
filter: brightness(1.04);
|
||||||
|
box-shadow: 0 7px 18px rgba(36, 52, 47, 0.14);
|
||||||
|
}
|
||||||
|
.button:hover, .culture-link:hover { transform: none; }
|
||||||
|
}
|
||||||
|
:is(.button, .culture-link):not(:disabled):active,
|
||||||
|
:is(.button, .culture-link)[data-pressed="true"] {
|
||||||
|
translate: 0 1px;
|
||||||
|
scale: 0.97;
|
||||||
|
filter: brightness(0.94);
|
||||||
|
box-shadow: 0 2px 5px rgba(36, 52, 47, 0.12);
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
:is(.button, .culture-link),
|
||||||
|
:is(.button, .culture-link):hover,
|
||||||
|
:is(.button, .culture-link):active,
|
||||||
|
:is(.button, .culture-link)[data-pressed="true"] {
|
||||||
|
translate: none !important;
|
||||||
|
scale: none !important;
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export const mainTopics = [
|
||||||
|
"AI", "React", "FastAPI", "Supabase", "Python", "Cloud",
|
||||||
|
"DevOps", "Product", "Reliability", "Security", "Music", "Life",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const topicKey = (topic) => topic.trim().toLowerCase();
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { getPost, getPosts } from "./api";
|
||||||
|
import { useAdmin } from "./components/AdminSession";
|
||||||
|
|
||||||
|
// Article reads are public. Sign-in only triggers a fresh read, never gates it.
|
||||||
|
export default function useJournalResource(slug = null) {
|
||||||
|
const { sessionRevision } = useAdmin();
|
||||||
|
const [attempt, setAttempt] = useState(0);
|
||||||
|
const [state, setState] = useState({ key: slug, data: null, loading: true, error: "" });
|
||||||
|
const reload = useCallback(() => setAttempt((current) => current + 1), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
setState((current) => ({ key: slug, data: current.key === slug ? current.data : null, loading: true, error: "" }));
|
||||||
|
const request = slug === null ? getPosts(controller.signal) : getPost(slug, controller.signal);
|
||||||
|
request.then((data) => {
|
||||||
|
if (!controller.signal.aborted) setState({ key: slug, data, loading: false, error: "" });
|
||||||
|
}).catch((error) => {
|
||||||
|
if (!controller.signal.aborted) setState((current) => ({ ...current, loading: false, error: error.message }));
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [slug, sessionRevision, attempt]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!state.error) return;
|
||||||
|
const visible = () => { if (document.visibilityState === "visible") reload(); };
|
||||||
|
window.addEventListener("online", reload);
|
||||||
|
window.addEventListener("focus", reload);
|
||||||
|
document.addEventListener("visibilitychange", visible);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("online", reload);
|
||||||
|
window.removeEventListener("focus", reload);
|
||||||
|
document.removeEventListener("visibilitychange", visible);
|
||||||
|
};
|
||||||
|
}, [state.error, reload]);
|
||||||
|
|
||||||
|
const setData = useCallback((update) => {
|
||||||
|
setState((current) => ({ ...current, data: typeof update === "function" ? update(current.data) : update }));
|
||||||
|
}, []);
|
||||||
|
return { ...state, loading: state.key !== slug || state.loading, data: state.key === slug ? state.data : null, reload, setData };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user