From 206bbbc5edb3e44ec6c506453dbbc5ede512b520 Mon Sep 17 00:00:00 2001 From: StormRunner06106 Date: Sat, 12 Sep 2026 22:10:44 -0700 Subject: [PATCH] Processed some UI issues on journal --- README.md | 20 +- backend/main.py | 154 +++++++--- backend/requirements.txt | 1 + backend/test_articles.py | 97 ++++++- frontend/index.html | 4 +- frontend/public/alex-avatar-96.webp | Bin 0 -> 2472 bytes frontend/public/alex_music.jpg | Bin 0 -> 7905 bytes frontend/public/apple-touch-icon.png | Bin 0 -> 34302 bytes frontend/public/favicon-32.png | Bin 0 -> 2307 bytes frontend/public/favicon.ico | Bin 0 -> 7307 bytes frontend/src/App.jsx | 6 +- frontend/src/api.js | 125 +++++++-- frontend/src/articleContent.js | 13 + frontend/src/components/AdminSession.jsx | 128 +++++++++ .../src/components/ArticleAdminActions.jsx | 44 +++ frontend/src/components/ArticleUploads.jsx | 206 ++++++++++++-- frontend/src/components/Icon.jsx | 8 + frontend/src/components/Layout.jsx | 48 +++- frontend/src/components/Modal.jsx | 21 ++ frontend/src/components/Status.jsx | 4 +- frontend/src/pages/BlogPage.jsx | 89 +++--- frontend/src/pages/BlogPostPage.jsx | 37 +-- frontend/src/pages/ContactPage.jsx | 3 + frontend/src/pages/JournalAdminPage.jsx | 252 ++++++++--------- frontend/src/styles.css | 265 ++++++++++++++++-- frontend/src/topics.js | 6 + frontend/src/useJournalResource.js | 41 +++ 27 files changed, 1245 insertions(+), 327 deletions(-) create mode 100644 frontend/public/alex-avatar-96.webp create mode 100644 frontend/public/alex_music.jpg create mode 100644 frontend/public/apple-touch-icon.png create mode 100644 frontend/public/favicon-32.png create mode 100644 frontend/public/favicon.ico create mode 100644 frontend/src/articleContent.js create mode 100644 frontend/src/components/AdminSession.jsx create mode 100644 frontend/src/components/ArticleAdminActions.jsx create mode 100644 frontend/src/components/Modal.jsx create mode 100644 frontend/src/topics.js create mode 100644 frontend/src/useJournalResource.js diff --git a/README.md b/README.md index 06e5b70..7dc1bd4 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ pip install -r backend\requirements.txt Start FastAPI: ```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: @@ -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: ```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. ## 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`: @@ -67,11 +69,15 @@ JOURNAL_ADMIN_PASSWORD=replace-with-a-strong-password 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. -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. @@ -109,6 +115,10 @@ The access token must be project-scoped with **Database: Read-write** permission - `POST /api/auth/login` - `GET /api/auth/session` - `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` - `POST /api/contact` diff --git a/backend/main.py b/backend/main.py index ea88a67..5dcdb1c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -3,6 +3,7 @@ from __future__ import annotations import hashlib import hmac import json +import logging import os import re import smtplib @@ -23,7 +24,8 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse from pydantic import BaseModel, EmailStr, Field from starlette.concurrency import run_in_threadpool -from supabase import Client, create_client +from httpx import TransportError +from supabase import Client, ClientOptions, PostgrestAPIError, create_client BASE_DIR = Path(__file__).resolve().parent @@ -36,6 +38,7 @@ POSTS_LOCK = threading.Lock() ADMIN_TOKEN_TTL = 60 * 60 * 4 UPLOAD_DIR = Path(os.getenv("JOURNAL_UPLOAD_DIR", str(DATA_DIR / "uploads"))).resolve() MAX_UPLOAD_BYTES = 20 * 1024 * 1024 +logger = logging.getLogger("uvicorn.error") class ArticleMedia(BaseModel): @@ -60,7 +63,7 @@ app.add_middleware( CORSMiddleware, allow_origins=origins, allow_credentials=True, - allow_methods=["GET", "POST"], + allow_methods=["GET", "POST", "PUT", "DELETE"], allow_headers=["*"], ) @@ -111,7 +114,11 @@ def get_supabase() -> Client | None: status_code=500, 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: @@ -121,6 +128,25 @@ def articles_table() -> str: 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]]: client = get_supabase() 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" if include_content: columns += ",content" - try: - response = ( - client.table(articles_table()) - .select(columns) - .order("published_at", desc=True) - .execute() - ) - except Exception as exc: - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail="The journal store is temporarily unavailable.", - ) from exc + response = read_article_query( + client.table(articles_table()).select(columns).order("published_at", desc=True) + ) return response.data or [] @@ -263,12 +280,13 @@ def admin_login(payload: LoginPayload) -> dict[str, Any]: "token_type": "bearer", "expires_at": expires_at, "expires_in": ADMIN_TOKEN_TTL, + "role": "admin", } @app.get("/api/auth/session") -def admin_session(_: None = Depends(require_admin)) -> dict[str, bool]: - return {"authenticated": True} +def admin_session(_: None = Depends(require_admin)) -> dict[str, Any]: + return {"authenticated": True, "role": "admin"} @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 create_post( - payload: NewPostPayload, _: None = Depends(require_admin) -) -> dict[str, Any]: - slug = post_slug(payload.title) +def validated_article(payload: NewPostPayload, slug: str) -> dict[str, Any]: clean_tags = list(dict.fromkeys(tag.strip() for tag in payload.tags if tag.strip())) if not clean_tags: 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.") if len(json.dumps(article["content"], ensure_ascii=False)) > 250_000: 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() if client is not None: @@ -409,17 +442,62 @@ def create_post( detail="A journal post with this title already exists.", ) current_posts.append(article) - temporary_path = POSTS_PATH.with_suffix(".json.tmp") - 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() + save_local_posts(current_posts) 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}") def post_by_slug(slug: str) -> dict[str, Any]: 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() if client is not None: - try: - response = ( - client.table(articles_table()) - .select("slug,title,excerpt,published_at,read_time,tags,accent,content,banner,attachments") - .eq("slug", slug) - .limit(1) - .execute() - ) - except Exception as exc: - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail="The journal store is temporarily unavailable.", - ) from exc + response = read_article_query( + client.table(articles_table()) + .select("slug,title,excerpt,published_at,read_time,tags,accent,content,banner,attachments") + .eq("slug", slug).limit(1) + ) if response.data: return response.data[0] raise HTTPException(status_code=404, detail="Post not found") diff --git a/backend/requirements.txt b/backend/requirements.txt index a064abb..fd552e3 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,4 +1,5 @@ fastapi +httpx Pillow uvicorn[standard] email-validator diff --git a/backend/test_articles.py b/backend/test_articles.py index 53803f7..d526cf4 100644 --- a/backend/test_articles.py +++ b/backend/test_articles.py @@ -4,10 +4,12 @@ import os from pathlib import Path from tempfile import TemporaryDirectory import unittest -from unittest.mock import patch +from unittest.mock import MagicMock, patch from fastapi.testclient import TestClient from PIL import Image +from httpx import ReadTimeout, RemoteProtocolError +from supabase import PostgrestAPIError from backend import main @@ -79,6 +81,99 @@ class ArticleMediaTests(unittest.TestCase): self.assertEqual(downloaded.headers["x-content-type-options"], "nosniff") 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__": unittest.main() diff --git a/frontend/index.html b/frontend/index.html index a49f35a..5969a4a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -11,7 +11,9 @@ - + + + Alex Herlan — Software Engineer diff --git a/frontend/public/alex-avatar-96.webp b/frontend/public/alex-avatar-96.webp new file mode 100644 index 0000000000000000000000000000000000000000..3678242c89bcd2037a7b27038d84576bc9493d87 GIT binary patch literal 2472 zcmV;Z30L+~Nk&GX2><|BMM6+kP&goz2><|4Dgd1UDqsL$06sk!i9;eGp%@9ya3BN3 zu>fL)*W`9hwukV4hdAykPd#2==TGJf&zsB#n1<^kMZqCKVlOYCLg+$joyxwxgc9VJo*Lc_GR#^sN2i@|%sJIBmy9o)|M% zR1>huex08EK}Fg^@~?D+{n5YoYTNzW_^dlH?IeHhvL4egD9J7W<4cn~JYQg_Xc{4p zOhvz^#dCmUhib4BNkF0vAc2JttV$Xp{R#_N5ZNoCMWy_!=I97N>seK5~Q3Ik=z zH7IR=BQ40c7bs)!=I78}7-OE;jm!ewfdn=HP`4s|?yz2l6;V*(U1YqMKJropckR|N zF8k-2Lrd`tiE50v#i&+nlPUBhO& z=BhJ6$M6@05%3O?vHJtY;b&WC zu($y_K!OKc=sTx3ra+1D-`Fh>U(F4BW{B&7M6Bsw8~=AjWr*(Q0SU)CmTf8BJ)~Fq zfAOBLbs6sbew|nmm^zLA_w842bWP4A)y>U~6$@U%9T=UquY34Phf z6m=Eh$zPbc%042Hen)36C}9`zyPkCoqJ4UX6@BZ8i}Ro+YR@2&N24b_x1YG9VV3xY z!cGedN?tB}mmko=Y_I?K{gT8ozCPeK{w2Ykv+u!XA}-pl`cGtP;9FL zVS78Gq_PmMGd4+T`0t1ij1UcS*S`1HS~g3hbFne!uv#Gujc;|`G(IDT8<;oL72%fV z#zkvUkd_zQ%5{(PK#QMX+ul7g_a!G@Ts?ALY-?e{iEj!OOcG`$Ih*+_ zbUWtWk9VHg-vzf{rYLnCj1DZ+r5KqvBCO?}P#QwGCZ5k_GBvA~Eu9ziq>q=4Qg@Y`~nN1e+|We z(lt@i99De?IeQ(vyu2s0Nfl`!<}IQkE&rS|j=vD0OFNAiK^8(n zC~b4iX(MCG`%_=x>0d$* z84@si%(zW2CH77;|a^ci-ffc z(Y`^g5;3mKL~B5ZgR>|?=Up3BzW$(iB4~SssLqo55aXe|f9@IR;1p9U;a4kd>yy*} z43xbeeu0rwVCQ*Fu&b?uiWA*1I-Ho?=yy)uJ)jOMwKG^*I_~vbObCmf2u1}ju|97Z z4^hsxQYJ7b1&?v{Pcjy5WNy>M mpvGbl7%##Kcpe3Je-y#+dYn6u#ZAPdD1O$&;NgG(0000XdEPYu literal 0 HcmV?d00001 diff --git a/frontend/public/alex_music.jpg b/frontend/public/alex_music.jpg new file mode 100644 index 0000000000000000000000000000000000000000..3ff816615e30812e7f1cf03ca9bee80865ee28ac GIT binary patch literal 7905 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 literal 0 HcmV?d00001 diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..4b66fc6b810080a4ffa08927d7edad35d2d1ffe5 GIT binary patch literal 34302 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 literal 0 HcmV?d00001 diff --git a/frontend/public/favicon-32.png b/frontend/public/favicon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..c2ee15f0ab6952b20d26566479aba35b44f0b153 GIT binary patch literal 2307 zcmV+e3H*r|4^^v5 z^rfnx2>}90plTZeEj*MZqyiE(A*Lm(L*jUe?ObnnnLF$MpFZ5+EJ*z{nun3jch3JE z&G&yuMDP`8tvTlaU`+PO#~;1_!H0(rA3ZlZQLWa2!1wdLy(@->2DfhAa>pHaN~r)K z%Q7LvSE+!mhA~EKO+*U|^Upr}yN#P}W4sHX17H=vbpQhZ0|3_ntODo+*0p~9Ex-NE zuPc>{M5MJg##}>qRf85a5xw~0ww|7TfD*tB%o>up&C(eX_C{e3a(htN8>O>J=5A)z z5WoOH+w$dWo_p>O*O0V;OA;Al7-K=u_{md0-o58-5PeoIZ%j(Y01y!pFaSgZV*mgU ziK9lwk;yo0Li)vn4{qJDprUYtUuVU0`|5^XU?L?l%%sElq{?|*pUJ#wFvN31Lw6mg{AE+LFr8 zTV>DcDf@lPJ8tOdEZV{Z&3Y{@=gs<+bDR@$!MMz_$X{{O8*lt;`?lve=UJxF7!x$= z>(<{oQ|(laZCV?=1RQ8Pmo;Zgj@9O-!zoOTxy?h0%p-b*2_4km)oyJkdE% zy#f;gWDL_p7sTj^V~0wmc7cdHUwL_av{_oyp2Pv;mwpxkWE7`yy}jUVx^7MX>XpS( zhn4e$l8~gXvMU!SxS2UKJ=@UGm0JvxI3pZ|hsoxQa4FTAaf zNHZakbL1R31FjqqIg&a{en)$WBjcP&!6ajhvRuwBwRJ3C)=_rdGv}-GsSm`v!jRqb85;hl??!Lart4K2_ZN-LJEddLq14q@bSx{3n{k#3%Z`&wqvUpxb1+7>2!KdPnL+;D_dXDx4*!=qML=X+0N|zN z0s-;7;=Nv};QJWxsSwk7NMlAjdE`IG|TIwt1CecxZbY+2E^JK9U0>)bv# zungXn1fW(qA`{ALG$RIx$oW<4||Mj)FCZ<%`xT$pF4ATzWGu5K;JEjsnAkB%NKJ9{>OZVqJT+teT|J z{KQ#D$`ECcWR>}9*|Plhavbwh(1$`Wf@`iV#<+EMG9Un;OS|Hh%ox|s8WygyHQw_#XyE}-U!0}W!9~?-zgXuKX~NZ&-`(J zcke?{ql%VGaa~uA!@`K9Ak#WT-BxHnF*@Cd)40|Mn#p*j9!D|ff&(=tjtKEt1GsQI ziZ^}7>iQa>u$}z*lW#ur{d=TkqcNu0sNKBfzHIYTj%3;RFkSc*nio;n!pbp(=IrEM zH*}gzkBncahcRih%Fk7s(dke~&cY~GmZh^yr;%4|SH*7GxmNdWNj`r5=>C0$QW>)> z5e8(fUuA&&KMU0ffi>ku6DD&W#u?bqA*00Wm$S~vTpk&ARUGg zV~jDD%NH!y(`l@vI6gW5(zCyM;)y4<){JwmwSM@!-}}*B184vJez9W(5o16}k`NIC zO44+4F>c0bBMPH5OSK-E4K%kLO9>$v=UQvW_58MW1S6Gw_T1QGcW-X7bIx%oZ)IBF zd+)u&pP%UK>u-k5G&5i{k_Iva*uSjQ#hXE`T8+);Gfl&Iqh6O%0HBli+dI2B!#Ikj zrf1e&-}mmj|8yLub>}kxKt#rvrPRK?dpF#;_SDJamSrm`W+u*$kDdcS+frkT(E{0v zV1IToO0-Vms1w3l3&nI(cFHpLXxw zeGU6nCs@kD#u(0d5CpI6+_~rV*N+`P8O5nkmg~Ae6h=`{U(DiAS&r*@>(&lD`p831 zKKVo6_l+?v-LLYS{~zB0;K-392M!!KdUW{w*jTMr(^`98esxdJ(9qCbcinl*=3810 dc4f(x_#Xf!$&R<2dtU$m002ovPDHLkV1jQpbvpn6 literal 0 HcmV?d00001 diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..9d04a559aaeb5194dc0272988f2cfedd7bfd9f02 GIT binary patch literal 7307 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 H>j?i3(b~S6 literal 0 HcmV?d00001 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 69dadc5..f3be765 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -2,6 +2,7 @@ import { lazy, Suspense, useEffect, useState } from "react"; import { Route, Routes, useLocation } from "react-router-dom"; import { getProfile } from "./api"; import Layout from "./components/Layout"; +import AdminProvider from "./components/AdminSession"; import AboutPage from "./pages/AboutPage"; import BlogPage from "./pages/BlogPage"; import ContactPage from "./pages/ContactPage"; @@ -38,7 +39,7 @@ export default function App() { }, []); return ( - + }> @@ -50,6 +51,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> - + ); } diff --git a/frontend/src/api.js b/frontend/src/api.js index 8b92874..27f6e87 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1,48 +1,112 @@ const API_BASE = import.meta.env.VITE_API_BASE_URL ?? ""; -async function request(path, options = {}) { - const response = await fetch(`${API_BASE}${path}`, { - ...options, - headers: { - "Content-Type": options.body instanceof File ? "application/octet-stream" : "application/json", - ...options.headers, - }, - }); +function reportExpiredSession(token) { + window.dispatchEvent(new CustomEvent("journal-session-expired", { detail: { token } })); +} - if (!response.ok) { - let detail = "Something went wrong. Please try again."; - try { - const body = await response.json(); - detail = typeof body.detail === "string" ? body.detail : detail; - } catch { - // Keep the friendly fallback when a proxy or server returns non-JSON. +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}`, { + ...options, + signal: controller.signal, + headers: { "Content-Type": "application/json", ...options.headers }, + }); + if (!response.ok) { + if (response.status === 401 && options.headers?.Authorization) { + reportExpiredSession(options.headers.Authorization.replace(/^Bearer /, "")); + } + let detail = "Something went wrong. Please try again."; + try { + const body = await response.json(); + if (typeof body.detail === "string") detail = body.detail; + } catch { /* Keep the fallback for proxy errors. */ } + const error = new Error(detail); + error.status = response.status; + 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; } - const error = new Error(detail); - error.status = response.status; 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 getExperience = (signal) => request("/api/experience", { signal }); export const getSkills = (signal) => request("/api/skills", { signal }); -export const getPosts = (signal) => request("/api/posts", { signal }); -export const getPost = (slug, signal) => request(`/api/posts/${slug}`, { signal }); +export const getPosts = (signal) => readRequest("/api/posts", { signal }); +export const getPost = (slug, signal) => readRequest(`/api/posts/${slug}`, { signal }); export const mediaUrl = (media) => `${API_BASE}${media.url}`; -export const uploadMedia = (file, purpose, token) => request(`/api/uploads?name=${encodeURIComponent(file.name)}&purpose=${purpose}`, { - method: "POST", - headers: { Authorization: `Bearer ${token}` }, - body: file, -}); +export function uploadMedia(file, purpose, token, { onProgress, signal } = {}) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + 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) => request("/api/auth/login", { method: "POST", body: JSON.stringify({ password }), }); export const getAdminSession = (token, signal) => - request("/api/auth/session", { + readRequest("/api/auth/session", { + timeoutMs: 5000, signal, headers: { Authorization: `Bearer ${token}` }, }); @@ -52,6 +116,15 @@ export const createPost = (payload, token) => headers: { Authorization: `Bearer ${token}` }, 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) => request("/api/contact", { method: "POST", diff --git a/frontend/src/articleContent.js b/frontend/src/articleContent.js new file mode 100644 index 0000000..f5ea1ea --- /dev/null +++ b/frontend/src/articleContent.js @@ -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(); +} diff --git a/frontend/src/components/AdminSession.jsx b/frontend/src/components/AdminSession.jsx new file mode 100644 index 0000000..77758a2 --- /dev/null +++ b/frontend/src/components/AdminSession.jsx @@ -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 setOpen(true) }}> + {children} + {open && + {isAdmin ?
+ Admin +

You can create, edit, and delete journal articles.

+ New article + Manage articles + +
:
+

Enter the admin password to manage journal articles.

+ + {error &&

{error}

} + {token && !checking && } + +
} +
} +
; +} diff --git a/frontend/src/components/ArticleAdminActions.jsx b/frontend/src/components/ArticleAdminActions.jsx new file mode 100644 index 0000000..4d3b977 --- /dev/null +++ b/frontend/src/components/ArticleAdminActions.jsx @@ -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
+ Edit + + {confirming && setConfirming(false)} busy={busy}> +

“{post.title}” will be removed from the journal. This cannot be undone.

+ {error &&

{error}

} +
+ + +
+
} +
; +} diff --git a/frontend/src/components/ArticleUploads.jsx b/frontend/src/components/ArticleUploads.jsx index 247223e..0970662 100644 --- a/frontend/src/components/ArticleUploads.jsx +++ b/frontend/src/components/ArticleUploads.jsx @@ -1,40 +1,184 @@ +import { useEffect, useId, useRef, useState } from "react"; import { mediaUrl } from "../api"; +import Icon from "./Icon"; -export default function ArticleUploads({ banner, attachments, busy, onUpload, onRemoveBanner, onRemoveAttachment }) { - return ( -
- Photos & files - - {banner &&
- Banner preview - {banner.name} - +const imageTypes = ["image/jpeg", "image/png", "image/webp", "image/gif"]; +const isImage = (file) => imageTypes.includes(file.type) || (!file.type && /\.(jpe?g|png|webp|gif)$/i.test(file.name)); +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
{ 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); + }}> + + {banner ? hasBanner ? "Drop a new banner to replace it" : "Give your article a cover" : "Add something worth sharing"} +

{banner ? "Drag an image here, or choose one below." : "Drop photos, documents, or other files here."}

+ + {banner ? "JPG, PNG, WebP or GIF · Up to 8 MB" : "Up to 10 files · 20 MB each"} + { + onFiles(Array.from(event.target.files), purpose); + event.target.value = ""; + }} /> +
; +} + +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
+ Photos & files +
+

Banner image

Your journal thumbnail and article cover.

Optional
+ {heroImage &&
+ Banner preview +
{bannerTask ? "Preview · Upload pending" : "Hero preview"}{title || "Your article title"}

{subtitle || "Your subtitle appears here, over the cover image."}

} - + {banner &&
{banner.name} {fileSize(banner.size)} +
} + + Wide images work best. The cover is cropped to fill the hero and thumbnail. +
+ +
+

Additional photos & files

Shown below your article, in this order.

{attachmentCount}/10
+ = 10} onFiles={addFiles} /> {attachments.length > 0 &&
    - {attachments.map((file) =>
  • - {file.media_type.startsWith("image/") && } - {file.name} ({Math.ceil(file.size / 1024)} KB) - + {attachments.map((file, index) =>
  • + {file.media_type.startsWith("image/") ? : } +
    {file.name}{fileSize(file.size)} · Ready
    +
    + + + +
  • )}
} - {busy &&

Uploading…

} -
- ); +
+ + {messages.length > 0 &&
Some files could not be added
    {messages.map((message, index) =>
  • {message}
  • )}
} + {tasks.length > 0 &&
+

Upload queue

+
    {tasks.map((task) =>
  • + {task.preview ? : } +
    {task.file.name}{fileSize(task.file.size)} · {task.purpose === "banner" ? "Banner" : "Attachment"} + {task.status === "error" ?

    {task.error}

    : <> + {task.status === "queued" ? "Waiting to upload" : task.progress === 100 ? "Processing file…" : `Uploading ${task.progress}%`} + + } +
    +
    + {task.status === "error" && } + +
    +
  • )}
+

{tasks.some((task) => task.status === "error") ? "Retry or remove failed uploads before publishing." : "You can keep writing while your files upload."}

+
} +
; } export function ArticleAttachments({ attachments = [] }) { diff --git a/frontend/src/components/Icon.jsx b/frontend/src/components/Icon.jsx index 1dc31b1..d6e2155 100644 --- a/frontend/src/components/Icon.jsx +++ b/frontend/src/components/Icon.jsx @@ -1,4 +1,12 @@ const paths = { + upload: , + photo: <>, + file: <>, + up: , + down: , + user: <>, + edit: <>, + trash: <>, arrow: , arrowLeft: , arrowUpRight: , diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index c5cf243..3d0599b 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { NavLink, useLocation } from "react-router-dom"; import Icon from "./Icon"; +import { useAdmin } from "./AdminSession"; const navItems = [ { label: "About", to: "/" }, @@ -11,6 +12,7 @@ const navItems = [ ]; function Header({ name }) { + const { isAdmin, checking, openSignIn } = useAdmin(); const [menuOpen, setMenuOpen] = useState(false); const location = useLocation(); @@ -20,10 +22,11 @@ function Header({ name }) {
- AH + {name} +
); @@ -75,6 +86,41 @@ export default function Layout({ children, profile }) { const name = profile?.display_name ?? "Alex Herlan"; 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 (
Skip to content diff --git a/frontend/src/components/Modal.jsx b/frontend/src/components/Modal.jsx new file mode 100644 index 0000000..c0a0cd3 --- /dev/null +++ b/frontend/src/components/Modal.jsx @@ -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 { event.preventDefault(); if (!busy) onClose(); }}> +
+

{title}

+ +
+ {children} +
; +} diff --git a/frontend/src/components/Status.jsx b/frontend/src/components/Status.jsx index b6f513b..750c7f2 100644 --- a/frontend/src/components/Status.jsx +++ b/frontend/src/components/Status.jsx @@ -7,15 +7,15 @@ export function LoadingState({ label = "Loading" }) { ); } -export function ErrorState({ message }) { +export function ErrorState({ message, onRetry, retrying = false }) { return (
!
That didn’t load.

{message}

+ {onRetry && }
); } - diff --git a/frontend/src/pages/BlogPage.jsx b/frontend/src/pages/BlogPage.jsx index 2816ff4..69810d0 100644 --- a/frontend/src/pages/BlogPage.jsx +++ b/frontend/src/pages/BlogPage.jsx @@ -1,8 +1,12 @@ -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; 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 PageHeader from "../components/PageHeader"; +import ArticleAdminActions from "../components/ArticleAdminActions"; +import { useAdmin } from "../components/AdminSession"; import { ErrorState, LoadingState } from "../components/Status"; function formatDate(date) { @@ -13,7 +17,7 @@ function formatDate(date) { }).format(new Date(`${date}T12:00:00`)); } -function PostCard({ post, featured }) { +function PostCard({ post, featured, onDeleted }) { return (
@@ -39,42 +43,34 @@ function PostCard({ post, featured }) {
+ ); } 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 [activeTag, setActiveTag] = useState("All"); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(""); - - useEffect(() => { - const controller = new AbortController(); - getPosts(controller.signal) - .then((result) => { - if (!controller.signal.aborted) setPosts(result); - }) - .catch((requestError) => { - if (requestError.name !== "AbortError") setError(requestError.message); - }) - .finally(() => { - if (!controller.signal.aborted) setLoading(false); - }); - return () => controller.abort(); - }, []); - - 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)]; + const [activeTag, setActiveTag] = useState(null); + const [showOthers, setShowOthers] = useState(false); + const tags = [null, ...mainTopics]; + const customTopics = useMemo(() => { + const mainKeys = new Set(mainTopics.map(topicKey)); + const custom = new Map(); + posts.flatMap((post) => post.tags).forEach((tag) => { + const key = topicKey(tag); + if (key && !mainKeys.has(key) && !custom.has(key)) custom.set(key, tag.trim()); + }); + return [...custom.values()].sort((a, b) => a.localeCompare(b)); }, [posts]); + const customActive = activeTag !== null && !mainTopics.some((tag) => topicKey(tag) === topicKey(activeTag)); const filteredPosts = useMemo(() => { const needle = query.trim().toLowerCase(); 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); 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." aside={(
-

{posts.length || 10}field notes

- Write +

{posts.length}field notes

+ {isAdmin && New article}
)} /> @@ -105,27 +101,44 @@ export default function BlogPage() { value={query} /> -
+
+
{tags.map((tag) => ( ))} + +
+
- {loading && } - {error && } + {loading && !data && } + {error && } + {loading && data &&

Refreshing articles...

} - {!loading && !error && filteredPosts.length > 0 && ( + {filteredPosts.length > 0 && (
{filteredPosts.map((post, index) => ( - + setPosts((current) => current.filter((item) => item.slug !== slug))} /> ))}
)} diff --git a/frontend/src/pages/BlogPostPage.jsx b/frontend/src/pages/BlogPostPage.jsx index 8eaa25f..5252a76 100644 --- a/frontend/src/pages/BlogPostPage.jsx +++ b/frontend/src/pages/BlogPostPage.jsx @@ -1,7 +1,8 @@ -import { useEffect, useState } from "react"; -import { Link, useParams } from "react-router-dom"; -import { getPost, mediaUrl } from "../api"; +import { Link, useNavigate, useParams } from "react-router-dom"; +import { mediaUrl } from "../api"; import { ArticleAttachments } from "../components/ArticleUploads"; +import ArticleAdminActions from "../components/ArticleAdminActions"; +import useJournalResource from "../useJournalResource"; import Icon from "../components/Icon"; import { RichTextArticle } from "../components/RichTextEditor"; import { ErrorState, LoadingState } from "../components/Status"; @@ -16,33 +17,15 @@ function formatDate(date) { export default function BlogPostPage() { const { slug } = useParams(); - const [post, setPost] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(""); + const navigate = useNavigate(); + const { data: post, loading, error, reload } = useJournalResource(slug); - useEffect(() => { - 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) { + if (loading && !post) { return
; } - if (error) { - return
; + if (error && !post) { + return
; } if (!post) { @@ -51,11 +34,13 @@ export default function BlogPostPage() { return (
+ {error &&
}
{post.banner && }
Back to journal + navigate("/blog")} />
{post.tags.map((tag) => {tag})}
diff --git a/frontend/src/pages/ContactPage.jsx b/frontend/src/pages/ContactPage.jsx index 5e09edb..d6daefa 100644 --- a/frontend/src/pages/ContactPage.jsx +++ b/frontend/src/pages/ContactPage.jsx @@ -139,6 +139,8 @@ export default function ContactPage({ profile, error }) {
+ Alex playing guitar +

Beyond the build

I love music and nearly always have something playing on Spotify.

@@ -159,6 +161,7 @@ export default function ContactPage({ profile, error }) { ))}
+
); diff --git a/frontend/src/pages/JournalAdminPage.jsx b/frontend/src/pages/JournalAdminPage.jsx index 514af6f..47002ff 100644 --- a/frontend/src/pages/JournalAdminPage.jsx +++ b/frontend/src/pages/JournalAdminPage.jsx @@ -1,27 +1,18 @@ -import { useEffect, useState } from "react"; -import { useNavigate } from "react-router-dom"; -import { createPost, getAdminSession, loginAdmin, uploadMedia } from "../api"; +import { useEffect, useRef, useState } from "react"; +import { Link, useNavigate, useParams } from "react-router-dom"; +import { createPost, getPost, updateArticle, uploadMedia } from "../api"; import ArticleUploads from "../components/ArticleUploads"; import Icon from "../components/Icon"; import PageHeader from "../components/PageHeader"; 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 emptyDocument = { type: "doc", content: [{ type: "paragraph" }] }; -const suggestedTopics = [ - "AI", - "React", - "FastAPI", - "Supabase", - "Python", - "Cloud", - "DevOps", - "Product", - "Reliability", - "Security", -]; + const initialPost = { title: "", @@ -33,10 +24,15 @@ const initialPost = { }; export default function JournalAdminPage() { + const { slug } = useParams(); + return ; +} + +function ArticleForm({ slug }) { const navigate = useNavigate(); - const [token, setToken] = useState(() => sessionStorage.getItem(TOKEN_KEY) ?? ""); - const [checking, setChecking] = useState(Boolean(token)); - const [password, setPassword] = useState(""); + const { token, checking, isAdmin, signOut, openSignIn } = useAdmin(); + const [loading, setLoading] = useState(Boolean(slug)); + const [loadError, setLoadError] = useState(""); const [post, setPost] = useState(initialPost); const [article, setArticle] = useState(emptyDocument); const [articleText, setArticleText] = useState(""); @@ -44,80 +40,72 @@ export default function JournalAdminPage() { const [attachments, setAttachments] = useState([]); const [uploading, setUploading] = useState(false); const [customTopic, setCustomTopic] = useState(""); + const [topicMessage, setTopicMessage] = useState(""); + const topicInput = useRef(null); const [status, setStatus] = useState({ type: "idle", message: "" }); useEffect(() => { - if (!token) { - setChecking(false); - return undefined; - } - + if (!slug) return; const controller = new AbortController(); - getAdminSession(token, controller.signal) - .catch((error) => { - if (error.name !== "AbortError") { - sessionStorage.removeItem(TOKEN_KEY); - setToken(""); - setStatus({ type: "error", message: "Your session ended. Sign in again." }); - } + getPost(slug, controller.signal) + .then((existing) => { + if (controller.signal.aborted) return; + const document = editableDocument(existing.content); + setPost({ title: existing.title, excerpt: existing.excerpt, published_at: existing.published_at, + read_time: existing.read_time, tags: existing.tags, accent: existing.accent }); + setArticle(document); + setArticleText(articlePlainText(document)); + setBanner(existing.banner ?? null); + setAttachments(existing.attachments ?? []); }) - .finally(() => { - if (!controller.signal.aborted) setChecking(false); - }); + .catch((error) => { if (error.name !== "AbortError") setLoadError(error.message); }) + .finally(() => { if (!controller.signal.aborted) setLoading(false); }); return () => controller.abort(); - }, [token]); - - 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: "" }); - } + }, [slug]); function updatePost(event) { setPost((current) => ({ ...current, [event.target.name]: event.target.value })); } function toggleTopic(topic) { - setPost((current) => { - if (current.tags.includes(topic)) { - return { ...current, tags: current.tags.filter((item) => item !== topic) }; - } - if (current.tags.length >= 6) { - setStatus({ type: "error", message: "Choose up to six topics." }); - return current; - } - return { ...current, tags: [...current.tags, topic] }; - }); + if (!post.tags.includes(topic) && post.tags.length >= 6) { + setTopicMessage("You can select up to six topics. Remove one to add another."); + return; + } + setTopicMessage(""); + setPost((current) => ({ ...current, tags: current.tags.includes(topic) + ? current.tags.filter((item) => item !== topic) : [...current.tags, topic] })); } function addCustomTopic() { - const topic = customTopic.trim(); - if (!topic || post.tags.includes(topic)) return; + const typed = customTopic.trim(); + 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) { - setStatus({ type: "error", message: "Choose up to six topics." }); + setTopicMessage("You can select up to six topics. Remove one to add another."); return; } setPost((current) => ({ ...current, tags: [...current.tags, topic] })); 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) { event.preventDefault(); - if (uploading || status.type === "sending") return; + if (!isAdmin || uploading || status.type === "sending") return; if (!post.tags.length) { setStatus({ type: "error", message: "Choose at least one topic." }); return; @@ -135,89 +123,60 @@ export default function JournalAdminPage() { attachments, }; - setStatus({ type: "sending", message: "Publishing…" }); + setStatus({ type: "sending", message: slug ? "Saving changes..." : "Publishing..." }); 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…" }); navigate(`/blog/${created.slug}`); } catch (error) { - if (error.status === 401) { - sessionStorage.removeItem(TOKEN_KEY); - setToken(""); - } setStatus({ type: "error", message: error.message }); } } - async function uploadFiles(files, purpose) { - if (!files.length || uploading) return; - if (purpose === "attachment" && attachments.length + files.length > 10) { - 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); - else setAttachments((current) => [...current, uploaded]); - } - } catch (error) { - setStatus({ type: "error", message: error.message }); - } finally { - setUploading(false); - } + async function uploadFile(file, purpose, options) { + const uploaded = await uploadMedia(file, purpose, token, options); + if (options.signal.aborted) return; + if (purpose === "banner") setBanner(uploaded); + else setAttachments((current) => [...current, uploaded]); + } + + function moveAttachment(url, direction) { + setAttachments((current) => { + 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 (
Authenticated : null} + title={slug ? "Edit your field note." : "Publish a field note."} + description="Write in sections, add photos and files, and share your field notes." + aside={isAdmin ? Admin : null} /> {checking && } - {!checking && !token && ( -
- -
-

Private access

-

Sign in to write

-

The publisher uses one server-side password. It is never stored in the browser.

-
- - - {status.message &&

{status.message}

} -
+ {!checking && !isAdmin && ( +
+

Sign in to manage articles

+

Use your admin password to create or edit a journal article.

+ +
)} + {isAdmin && loading && } + {isAdmin && loadError && } - {!checking && token && ( + {isAdmin && !loading && !loadError && (
-

New article

+

{slug ? "Edit article" : "New article"}

Write, format, choose the topics, and publish from one clean workspace.

@@ -268,22 +227,41 @@ export default function JournalAdminPage() { {post.tags.includes(topic) ? "✓ " : "+ "}{topic} ))} + {post.tags.filter((topic) => !suggestedTopics.includes(topic)).map((topic) => ( + + {topic} + + + ))}
+
+ setCustomTopic(event.target.value)} + onChange={(event) => { setCustomTopic(event.target.value); setTopicMessage(""); }} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); + if (event.nativeEvent.isComposing || event.keyCode === 229) return; addCustomTopic(); } }} placeholder="Add another topic" value={customTopic} /> - + +
+
+

Type a topic and press Enter to add and select it. Use × on a custom topic to remove it.

+

{topicMessage}

@@ -293,13 +271,15 @@ export default function JournalAdminPage() {

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.

- setBanner(null)} + setBanner(null)} onRemoveAttachment={(url) => setAttachments((current) => current.filter((file) => file.url !== url))} />
- {status.message &&

{status.message}

}
diff --git a/frontend/src/styles.css b/frontend/src/styles.css index e646e26..e82f84a 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -189,7 +189,6 @@ button { .nav-link:hover { color: var(--ink); - transform: translateY(-1px); } .nav-link.is-active { @@ -1078,6 +1077,21 @@ button { 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 { display: grid; grid-template-columns: repeat(3, 1fr); @@ -1750,7 +1764,7 @@ button { .culture-intro { 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; gap: 8px 48px; margin-bottom: 28px; @@ -2057,6 +2071,32 @@ button { 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 { display: flex; max-width: 430px; @@ -2075,6 +2115,7 @@ button { background: #fbfcfa; font-size: 14px; } +.custom-topic .custom-topic__input input { padding-right: 82px; } .custom-topic input:focus { border-color: rgba(47, 81, 71, 0.43); @@ -2715,37 +2756,229 @@ button { height: 100%; object-fit: cover; } -.article-hero:has(.article-banner) { padding-top: 0; } +.article-hero:has(.article-banner) { + isolation: isolate; + color: #fff; +} .article-banner { display: block; + position: absolute; + inset: 0; width: 100%; - height: clamp(220px, 38vw, 500px); + height: 100%; 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; 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-uploads { display: grid; - gap: 24px; + grid-column: 1 / -1; + gap: 30px; min-width: 0; - padding: 24px; + margin: 0; + padding: clamp(18px, 3vw, 30px); 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 input[type="file"] { max-width: 100%; padding: 12px 0; } -.upload-preview { display: grid; gap: 12px; justify-items: start; overflow-wrap: anywhere; } -.upload-preview img { width: 100%; max-height: 240px; object-fit: cover; border-radius: 12px; } -.upload-list { display: grid; gap: 12px; list-style: none; margin: 0; padding: 0; } -.upload-list li { display: flex; align-items: center; gap: 14px; } -.upload-list li > span { flex: 1; min-width: 0; overflow-wrap: anywhere; } -.upload-list img { width: 64px; height: 64px; object-fit: cover; border-radius: 8px; } +.upload-section { display: grid; gap: 14px; min-width: 0; } +.upload-section + .upload-section { border-top: 1px solid var(--line); padding-top: 28px; } +.upload-section__heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; } +.upload-section__heading h3, .upload-queue h3 { margin: 0 0 5px; font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; } +.upload-section__heading p { margin: 0; color: var(--ink-soft); font-size: 12px; line-height: 1.6; } +.upload-label { flex-shrink: 0; padding: 3px 9px; border-radius: 20px; background: var(--mint); color: var(--sage-deep); font-size: 11px; } +.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 figure { margin: 24px 0; } .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-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); } + +.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; + } +} diff --git a/frontend/src/topics.js b/frontend/src/topics.js new file mode 100644 index 0000000..16ad251 --- /dev/null +++ b/frontend/src/topics.js @@ -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(); diff --git a/frontend/src/useJournalResource.js b/frontend/src/useJournalResource.js new file mode 100644 index 0000000..1f60142 --- /dev/null +++ b/frontend/src/useJournalResource.js @@ -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 }; +}