Changes include:

- Roboto Flex loaded from Google Fonts for compact UI text
- Reduced decorative borders while retaining useful input and divider borders
- Fixed blank journal detail pages
- Password-protected journal publisher with four-hour signed sessions
- New articles saved directly to posts.json
- Prominent Spotify, Instagram, and Steam buttons on Contact
- Desktop and mobile layouts visually verified
- Production build and authorization tests passed
This commit is contained in:
StormRunner06106
2026-09-11 11:35:01 -07:00
parent f80bceaa3c
commit 14d1dbbb07
15 changed files with 1507 additions and 32 deletions
+18 -1
View File
@@ -42,6 +42,8 @@ npm run dev
Open `http://localhost:5173`. Vite proxies `/api` requests to FastAPI on port 8000. Open `http://localhost:5173`. Vite proxies `/api` requests to FastAPI on port 8000.
The interface loads Roboto Flex from Google Fonts for its compact UI copy, while editorial headings keep the résumé-inspired serif treatment.
## Contact-form delivery ## Contact-form delivery
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:
@@ -52,6 +54,19 @@ uvicorn backend.main:app --reload --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
Open `http://localhost:5173/blog/manage` and sign in with the journal admin password. Publishing writes the new article directly to `backend/data/posts.json`; no database is involved.
Set unique production values in `backend/.env`:
```dotenv
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.
## Content API ## Content API
- `GET /api/profile` - `GET /api/profile`
@@ -59,6 +74,9 @@ If SMTP is unavailable, the API returns a clear delivery error and the page keep
- `GET /api/skills` - `GET /api/skills`
- `GET /api/posts` - `GET /api/posts`
- `GET /api/posts/{slug}` - `GET /api/posts/{slug}`
- `POST /api/auth/login`
- `GET /api/auth/session`
- `POST /api/posts` (authenticated)
- `GET /api/resume` - `GET /api/resume`
- `POST /api/contact` - `POST /api/contact`
@@ -74,4 +92,3 @@ uvicorn backend.main:app --host 0.0.0.0 --port 8000
``` ```
When `frontend/dist` exists, FastAPI serves the built single-page application and supports direct links such as `/experience` and `/blog/{slug}`. When `frontend/dist` exists, FastAPI serves the built single-page application and supports direct links such as `/experience` and `/blog/{slug}`.
+3
View File
@@ -13,3 +13,6 @@ SMTP_USE_SSL=false
# Comma-separated origins allowed to call the API during development. # Comma-separated origins allowed to call the API during development.
FRONTEND_ORIGINS=http://localhost:5173 FRONTEND_ORIGINS=http://localhost:5173
# Lightweight journal publisher authentication. Use long, unique values in production.
JOURNAL_ADMIN_PASSWORD=replace-with-a-strong-password
JOURNAL_TOKEN_SECRET=replace-with-a-long-random-secret
+20 -1
View File
@@ -28,6 +28,25 @@
"kind": "github" "kind": "github"
} }
], ],
"interests": [
{
"label": "Go to Spotify",
"note": "Often playing",
"href": "https://open.spotify.com/user/orbitrix",
"kind": "spotify"
},
{
"label": "Open Instagram",
"note": "Life outside code",
"href": "https://www.instagram.com/alexanderherlan2/",
"kind": "instagram"
},
{
"label": "Find me on Steam",
"note": "Occasionally gaming",
"href": "https://steamcommunity.com/profiles/76561197961799136",
"kind": "steam"
}
],
"focus": ["Product engineering", "Applied AI", "Cloud systems"] "focus": ["Product engineering", "Applied AI", "Cloud systems"]
} }
+143 -3
View File
@@ -1,16 +1,21 @@
from __future__ import annotations from __future__ import annotations
import json import json
import hashlib
import hmac
import os import os
import re import re
import smtplib import smtplib
import ssl import ssl
import threading
import time
from datetime import date
from email.message import EmailMessage from email.message import EmailMessage
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, Literal
from fastapi import FastAPI, HTTPException, Query from fastapi import Depends, FastAPI, Header, HTTPException, Query, status
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from pydantic import BaseModel, EmailStr, Field from pydantic import BaseModel, EmailStr, Field
@@ -22,6 +27,9 @@ PROJECT_DIR = BASE_DIR.parent
DATA_DIR = BASE_DIR / "data" DATA_DIR = BASE_DIR / "data"
FRONTEND_DIST = PROJECT_DIR / "frontend" / "dist" FRONTEND_DIST = PROJECT_DIR / "frontend" / "dist"
RESUME_PATH = PROJECT_DIR / "Alexander Herlan Resume 2024.pdf" RESUME_PATH = PROJECT_DIR / "Alexander Herlan Resume 2024.pdf"
POSTS_PATH = DATA_DIR / "posts.json"
POSTS_LOCK = threading.Lock()
ADMIN_TOKEN_TTL = 60 * 60 * 4
app = FastAPI( app = FastAPI(
@@ -52,6 +60,25 @@ class ContactPayload(BaseModel):
company: str = Field(default="", max_length=200) company: str = Field(default="", max_length=200)
class LoginPayload(BaseModel):
password: str = Field(min_length=1, max_length=200)
class ArticleSection(BaseModel):
heading: str = Field(min_length=3, max_length=160)
paragraphs: list[str] = Field(min_length=1, max_length=12)
class NewPostPayload(BaseModel):
title: str = Field(min_length=5, max_length=160)
excerpt: str = Field(min_length=20, max_length=360)
published_at: date
read_time: int = Field(ge=1, le=60)
tags: list[str] = Field(min_length=1, max_length=6)
accent: Literal["blue", "lavender", "peach", "yellow", "mint"] = "mint"
content: list[ArticleSection] = Field(min_length=1, max_length=8)
@lru_cache(maxsize=8) @lru_cache(maxsize=8)
def read_data(filename: str) -> Any: def read_data(filename: str) -> Any:
path = DATA_DIR / filename path = DATA_DIR / filename
@@ -60,6 +87,64 @@ def read_data(filename: str) -> Any:
return json.loads(path.read_text(encoding="utf-8")) return json.loads(path.read_text(encoding="utf-8"))
def journal_credentials() -> tuple[str, str]:
password = os.getenv("JOURNAL_ADMIN_PASSWORD", "")
token_secret = os.getenv("JOURNAL_TOKEN_SECRET", "")
if not password or not token_secret:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Journal publishing is not configured.",
)
return password, token_secret
def issue_admin_token() -> tuple[str, int]:
_, token_secret = journal_credentials()
expires_at = int(time.time()) + ADMIN_TOKEN_TTL
payload = str(expires_at)
signature = hmac.new(
token_secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256
).hexdigest()
return f"{payload}.{signature}", expires_at
def require_admin(authorization: str | None = Header(default=None)) -> None:
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Admin sign-in required.",
)
token = authorization.removeprefix("Bearer ").strip()
try:
expires, supplied_signature = token.split(".", maxsplit=1)
expires_at = int(expires)
except (TypeError, ValueError):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid admin session.",
)
_, token_secret = journal_credentials()
expected_signature = hmac.new(
token_secret.encode("utf-8"), expires.encode("utf-8"), hashlib.sha256
).hexdigest()
if expires_at <= int(time.time()) or not hmac.compare_digest(
supplied_signature, expected_signature
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Your admin session has expired. Please sign in again.",
)
def post_slug(title: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", title.casefold()).strip("-")
if not slug:
raise HTTPException(status_code=422, detail="The title needs letters or numbers.")
return slug
@app.get("/api/health") @app.get("/api/health")
def health() -> dict[str, str]: def health() -> dict[str, str]:
return {"status": "ok"} return {"status": "ok"}
@@ -80,6 +165,28 @@ def skills() -> dict[str, Any]:
return read_data("skills.json") return read_data("skills.json")
@app.post("/api/auth/login")
def admin_login(payload: LoginPayload) -> dict[str, Any]:
password, _ = journal_credentials()
if not hmac.compare_digest(payload.password, password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="That password is not correct.",
)
token, expires_at = issue_admin_token()
return {
"access_token": token,
"token_type": "bearer",
"expires_at": expires_at,
"expires_in": ADMIN_TOKEN_TTL,
}
@app.get("/api/auth/session")
def admin_session(_: None = Depends(require_admin)) -> dict[str, bool]:
return {"authenticated": True}
@app.get("/api/posts") @app.get("/api/posts")
def posts( def posts(
q: str | None = Query(default=None, max_length=100), q: str | None = Query(default=None, max_length=100),
@@ -105,6 +212,40 @@ def posts(
return sorted(filtered, key=lambda item: item["published_at"], reverse=True) return sorted(filtered, key=lambda item: item["published_at"], reverse=True)
@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)
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.")
if any(len(tag) > 40 for tag in clean_tags):
raise HTTPException(status_code=422, detail="Topic tags must be 40 characters or fewer.")
article = payload.model_dump(mode="json")
article["slug"] = slug
article["tags"] = clean_tags
with POSTS_LOCK:
current_posts = json.loads(POSTS_PATH.read_text(encoding="utf-8"))
if any(post["slug"] == slug for post in current_posts):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
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()
return article
@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):
@@ -193,4 +334,3 @@ if FRONTEND_DIST.is_dir():
if requested_file.is_file(): if requested_file.is_file():
return FileResponse(requested_file) return FileResponse(requested_file)
return FileResponse(FRONTEND_DIST / "index.html") return FileResponse(FRONTEND_DIST / "index.html")
+3 -1
View File
@@ -8,6 +8,9 @@
content="Alex Herlan is a senior software engineer building full-stack products, applied AI, and dependable cloud systems." content="Alex Herlan is a senior software engineer building full-stack products, applied AI, and dependable cloud systems."
/> />
<meta name="theme-color" content="#f7f8f4" /> <meta name="theme-color" content="#f7f8f4" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Roboto+Flex:opsz,wght@8..144,100..1000&display=swap" rel="stylesheet" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><rect width=%22100%22 height=%22100%22 rx=%2230%22 fill=%22%232f5147%22/><text x=%2250%22 y=%2264%22 text-anchor=%22middle%22 font-size=%2244%22 fill=%22white%22 font-family=%22Arial%22>AH</text></svg>" /> <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><rect width=%22100%22 height=%22100%22 rx=%2230%22 fill=%22%232f5147%22/><text x=%2250%22 y=%2264%22 text-anchor=%22middle%22 font-size=%2244%22 fill=%22white%22 font-family=%22Arial%22>AH</text></svg>" />
<title>Alex Herlan — Software Engineer</title> <title>Alex Herlan — Software Engineer</title>
</head> </head>
@@ -16,4 +19,3 @@
<script type="module" src="/src/main.jsx"></script> <script type="module" src="/src/main.jsx"></script>
</body> </body>
</html> </html>
+689
View File
@@ -8,6 +8,10 @@
"name": "alex-herlan-portfolio", "name": "alex-herlan-portfolio",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@tiptap/extension-text-style": "^3.31.3",
"@tiptap/pm": "^3.31.3",
"@tiptap/react": "^3.31.3",
"@tiptap/starter-kit": "^3.31.3",
"react": "^19.3.0", "react": "^19.3.0",
"react-dom": "^19.3.0", "react-dom": "^19.3.0",
"react-router-dom": "^7.18.3" "react-router-dom": "^7.18.3"
@@ -18,6 +22,34 @@
"vite": "^8.3.0" "vite": "^8.3.0"
} }
}, },
"node_modules/@floating-ui/core": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/core": "^1.8.0",
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
"license": "MIT",
"optional": true
},
"node_modules/@oxc-project/types": { "node_modules/@oxc-project/types": {
"version": "0.149.0", "version": "0.149.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz",
@@ -308,6 +340,475 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@tiptap/core": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.31.3.tgz",
"integrity": "sha512-Cz50pvciQrxdSxgTkHOVz0uD0Yl/8Xt0QatGD6ILm47jW8EzyHR9RkUGs/D5IqzXKuVPntfw1ttaT926vXfiRg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "3.31.3"
}
},
"node_modules/@tiptap/extension-blockquote": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.31.3.tgz",
"integrity": "sha512-fyY2XMbyDDDfOTQ1Qdrnqa1qwC9DWE4n7AfE0EKQI0G8MfLV8RaDlLDcOZDJ9JbMPY7/Gx7EjyK4NKxSW9n2hQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3",
"@tiptap/pm": "3.31.3"
}
},
"node_modules/@tiptap/extension-bold": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.31.3.tgz",
"integrity": "sha512-dIuYhKk8TitKU/FeDpoTeWZhU42YgDN5npgWNjAmMmRktPdoxnH3/wGSiwXlqZgJWjehN7kPWDePWpJeAImGpQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3"
}
},
"node_modules/@tiptap/extension-bubble-menu": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.31.3.tgz",
"integrity": "sha512-EV6ZnwKc++2OM/OcD54n8s1B7C9LP7GKAtdEPwu0t3BYJf3sG8Ueoinf7SgGPO9oMPg96GneOkDNm1urMV167g==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/dom": "^1.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3",
"@tiptap/pm": "3.31.3"
}
},
"node_modules/@tiptap/extension-bullet-list": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.31.3.tgz",
"integrity": "sha512-qEyyoPapPef4LO8XKaN83bxtaNzkJ4kFn/IxLnEKd4BJ3Mvi4MH2yJYlyDqwFnMoev4pMA1zBHRAt/C0THRmZw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.31.3"
}
},
"node_modules/@tiptap/extension-code": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.31.3.tgz",
"integrity": "sha512-SzxOqchrD2AcN3uT67PjKmRFEMOU3vNNiwNaamZJUbZrI6Hmy+bvdHJrc3jrIddyyCrAtsnAfRI0cmorW1jGfg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3"
}
},
"node_modules/@tiptap/extension-code-block": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.31.3.tgz",
"integrity": "sha512-nvknt4FhyJQjYcvxptmeUlFsIAc8ibua3E5BN4Pim374/9RWepH4cdE9X0/qUTtguHElLx0iKtSIY3rW2qGTFA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3",
"@tiptap/pm": "3.31.3"
}
},
"node_modules/@tiptap/extension-document": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.31.3.tgz",
"integrity": "sha512-EexgmqnyDNyGlISxo7SMrp5MygpJYmqD+0cY5jB6L1U6L4CpKKRWUt8OO1sWzEHDE1+TTvwt+WIFoIWAziOtEA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3"
}
},
"node_modules/@tiptap/extension-dropcursor": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.31.3.tgz",
"integrity": "sha512-NWomSfu5CSC7VacnMSDzKT8qm66SzMfZwVPEtwY5bPpRTJgTiT1rNK0neDrrzfMN27MfylGyKWWf7Q5Qf8w/fg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.31.3"
}
},
"node_modules/@tiptap/extension-floating-menu": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.31.3.tgz",
"integrity": "sha512-rd4VJ9PGSP9Eop8ZTEwaLZcMzMXLuJKe3hUNf58rq+zANpwM+9fI+vB7g9MAc3eXwUNDxNDVACFIL2oeBqpQ3A==",
"license": "MIT",
"optional": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@floating-ui/dom": "^1.0.0",
"@tiptap/core": "3.31.3",
"@tiptap/pm": "3.31.3"
}
},
"node_modules/@tiptap/extension-gapcursor": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.31.3.tgz",
"integrity": "sha512-EBXKb1FrVStsNYCcRGtd9jmzveCvR+eqgg1rVqoONrqFK6U7bga6LN+1dMKroP1kliDVgveYkP3vRYxqw+rFqg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.31.3"
}
},
"node_modules/@tiptap/extension-hard-break": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.31.3.tgz",
"integrity": "sha512-QAdCvNO4+yW9ATwsrej11NTkDYFqPLIEQr3ARNrKOK1qaiS7A0fia2SEukb/hrkP3A6mbozhoQt2r2RGUf/DpQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3"
}
},
"node_modules/@tiptap/extension-heading": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.31.3.tgz",
"integrity": "sha512-rk5VHMAeQcg06SLauN6EGdD2jc0O2qY8QkZYPd0LxNvLblw2BxBx+lxUQSYwLAT9Ie5914gKIK2YbRyO2Ts3ig==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3"
}
},
"node_modules/@tiptap/extension-horizontal-rule": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.31.3.tgz",
"integrity": "sha512-YnHGy2KShRwvCseAmmxl9VP7R0qaj8QMp3DA6DJWZqp7r5gLGvDkAqhedxqqefqsE4Y43hjkBdjtB9Ce78LkIw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3",
"@tiptap/pm": "3.31.3"
}
},
"node_modules/@tiptap/extension-italic": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.31.3.tgz",
"integrity": "sha512-ibGvdvAPyfxBMUVNRI43eb9h2/Jka1MRG5GtnGqbcCX/2+Y/y0EOfFrPQPkuYphiWQYyu9+FzPKMB/pjuaLuKQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3"
}
},
"node_modules/@tiptap/extension-link": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.31.3.tgz",
"integrity": "sha512-986wOQzTL9Zr5lf84LCLpm+YOms8A0K39/8DVoqRfebqcOe0/eq4bnztmAlfabOd+kJY92g3AgZERFUx/w+dcw==",
"license": "MIT",
"dependencies": {
"linkifyjs": "^4.3.3"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3",
"@tiptap/pm": "3.31.3"
}
},
"node_modules/@tiptap/extension-list": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.31.3.tgz",
"integrity": "sha512-LoveGnC0FVdCV4jUNBaG1ZA+KWE07+adzV3kGy6uUYFcJEjbVUHTnPDrBOob3IwOSO3sCwIkvQb6EVYeXn/4yg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3",
"@tiptap/pm": "3.31.3"
}
},
"node_modules/@tiptap/extension-list-item": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.31.3.tgz",
"integrity": "sha512-4QlKOriJJMvg95QJTEsy9BUYPBQ6UvJyb8WURRwdUtQUkkqb8h32lg/eyQUv2FzW9IT1AdUyNZfVaxH64kRfQA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.31.3"
}
},
"node_modules/@tiptap/extension-list-keymap": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.31.3.tgz",
"integrity": "sha512-If8UOEdDZbPJU6iYTvLtH6DOp2KBy6BKxg9UELL1AevVetGHEF/7lW8hP50Gn1tHMVpPRqmhSzVrJRpEJJgb/Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.31.3"
}
},
"node_modules/@tiptap/extension-ordered-list": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.31.3.tgz",
"integrity": "sha512-mp3g11NgA/PYu8rj7J7Ez3l4qBy6WfTSmHIG4PZvEGG5w2oUAIkgb9DU7nPPzjmeme27oazFYZw+AtZA0+u4tw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.31.3"
}
},
"node_modules/@tiptap/extension-paragraph": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.31.3.tgz",
"integrity": "sha512-+iPku7wJfy5hbNDNLX8dveFtYVsZMmh7vztjuq8hT3mipSC4IByDehDbU8fzUCjfXiEzmI7mQn7c8LGmHULuzA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3"
}
},
"node_modules/@tiptap/extension-strike": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.31.3.tgz",
"integrity": "sha512-G29bhKttYwcKHT+BI6emWVFol3RO/gUXxQVcmr/iT8LXXy7j8J6HFUnsKM+Kg5YlP1rxMRgsa65dbrQVahZi0A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3"
}
},
"node_modules/@tiptap/extension-text": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.31.3.tgz",
"integrity": "sha512-gdsWtF+taeaCu6V+5Ct10fGo0ACUy1GnYtbb+mcathBt8OqbT+Ws60p/yEmKesBDz2Hn+B5IcWy6+2BBZl5ZTg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3"
}
},
"node_modules/@tiptap/extension-text-style": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.31.3.tgz",
"integrity": "sha512-wgjWWrjZwRZHiaDTQPX2am1y/4ePgRgGWF/2MOfSb7g4d5229p4aVtbT1JT7wu9z8OC482pEqcTlhdvgftvW7A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3"
}
},
"node_modules/@tiptap/extension-underline": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.31.3.tgz",
"integrity": "sha512-HghdJaOwRqYzsAxqSyNyb+IWyOMcdCl8IoiBETA9BZCJAqdXzFLcuWp7CqCqJPam9dgkWosSoHqTCAO4nTBfpw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3"
}
},
"node_modules/@tiptap/extensions": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.31.3.tgz",
"integrity": "sha512-8sJNPGGUe8f3aDojcOW5cfVL7I5NrBbE0UWxG08qoi9Tea6qWbvQJsCR9tsrOapr/DaLr3kpbGZ9s1gEEfcNcA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.31.3",
"@tiptap/pm": "3.31.3"
}
},
"node_modules/@tiptap/pm": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.31.3.tgz",
"integrity": "sha512-sZime0SWsz/k62W2WvHx5Ig7G2h7kVhrrmnqy+wEgIHfDwEfOlelRjaWCiBCFlF7dxGUntJusCh9FxlLhni0Ag==",
"license": "MIT",
"dependencies": {
"prosemirror-changeset": "^2.4.1",
"prosemirror-commands": "^1.7.1",
"prosemirror-dropcursor": "^1.8.2",
"prosemirror-gapcursor": "^1.4.1",
"prosemirror-history": "^1.5.0",
"prosemirror-inputrules": "^1.5.1",
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.11",
"prosemirror-schema-list": "^1.5.1",
"prosemirror-state": "^1.4.4",
"prosemirror-tables": "^1.8.5",
"prosemirror-transform": "^1.12.0",
"prosemirror-view": "^1.42.3"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tiptap/react": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.31.3.tgz",
"integrity": "sha512-QiwQqvaLFLm5EMFu5tg7nAgXJxCUiUTLD8EsK+TqVV5P4bqOoMOCM39khbhXTJyahCuYpiFWx5YOSDtC/JiPtg==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"fast-equals": "^5.3.3",
"use-sync-external-store": "^1.4.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"optionalDependencies": {
"@tiptap/extension-bubble-menu": "^3.31.3",
"@tiptap/extension-floating-menu": "^3.31.3"
},
"peerDependencies": {
"@tiptap/core": "3.31.3",
"@tiptap/pm": "3.31.3",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tiptap/starter-kit": {
"version": "3.31.3",
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.31.3.tgz",
"integrity": "sha512-WKof9RewdmGHvWJ1wn0/HVNG2mV+HOgVRyJkKekuM9fgr6BZAAH/xZsWE1eon+94JnQ+KtK2ThydXQM/qc6b2A==",
"license": "MIT",
"dependencies": {
"@tiptap/core": "3.31.3",
"@tiptap/extension-blockquote": "3.31.3",
"@tiptap/extension-bold": "3.31.3",
"@tiptap/extension-bullet-list": "3.31.3",
"@tiptap/extension-code": "3.31.3",
"@tiptap/extension-code-block": "3.31.3",
"@tiptap/extension-document": "3.31.3",
"@tiptap/extension-dropcursor": "3.31.3",
"@tiptap/extension-gapcursor": "3.31.3",
"@tiptap/extension-hard-break": "3.31.3",
"@tiptap/extension-heading": "3.31.3",
"@tiptap/extension-horizontal-rule": "3.31.3",
"@tiptap/extension-italic": "3.31.3",
"@tiptap/extension-link": "3.31.3",
"@tiptap/extension-list": "3.31.3",
"@tiptap/extension-list-item": "3.31.3",
"@tiptap/extension-list-keymap": "3.31.3",
"@tiptap/extension-ordered-list": "3.31.3",
"@tiptap/extension-paragraph": "3.31.3",
"@tiptap/extension-strike": "3.31.3",
"@tiptap/extension-text": "3.31.3",
"@tiptap/extension-underline": "3.31.3",
"@tiptap/extensions": "3.31.3",
"@tiptap/pm": "3.31.3"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@types/react": {
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
"integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.3.0"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": { "node_modules/@vitejs/plugin-react": {
"version": "6.1.1", "version": "6.1.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz",
@@ -351,6 +852,13 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT",
"peer": true
},
"node_modules/detect-libc": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -361,6 +869,15 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/fast-equals": {
"version": "5.4.2",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.2.tgz",
"integrity": "sha512-Ywe6jodPTWOTL9/k0bV7gdfP8twKL5Y8I8CZ933fAY5gBekICZSUQTbyH6ut2NZCNyB05mSUwAuEqdEIaOOlDQ==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -666,6 +1183,12 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/linkifyjs": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
"integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==",
"license": "MIT"
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.19", "version": "3.3.19",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
@@ -685,6 +1208,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
} }
}, },
"node_modules/orderedmap": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
"license": "MIT"
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -734,6 +1263,145 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/prosemirror-changeset": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.2.tgz",
"integrity": "sha512-ViYrjMSg3YFiXwIhKaluu+/mi3Yrxt6AR8ri14ulTaGcZtXO1CThl7A2gv79qx5fQnOw8woKwyBU2u+9PVCm3w==",
"license": "MIT",
"dependencies": {
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-commands": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz",
"integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.10.2"
}
},
"node_modules/prosemirror-dropcursor": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.3.tgz",
"integrity": "sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0",
"prosemirror-view": "^1.1.0"
}
},
"node_modules/prosemirror-gapcursor": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.0.0",
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-view": "^1.0.0"
}
},
"node_modules/prosemirror-history": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.2.2",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.31.0",
"rope-sequence": "^1.3.0"
}
},
"node_modules/prosemirror-inputrules": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-keymap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"w3c-keyname": "^2.2.0"
}
},
"node_modules/prosemirror-model": {
"version": "1.25.11",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.11.tgz",
"integrity": "sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==",
"license": "MIT",
"dependencies": {
"orderedmap": "^2.0.0"
}
},
"node_modules/prosemirror-schema-list": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.7.3"
}
},
"node_modules/prosemirror-state": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.27.0"
}
},
"node_modules/prosemirror-tables": {
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.4",
"prosemirror-state": "^1.4.4",
"prosemirror-transform": "^1.10.5",
"prosemirror-view": "^1.41.4"
}
},
"node_modules/prosemirror-transform": {
"version": "1.12.1",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.1.tgz",
"integrity": "sha512-t4F5615FycnCqsX7ShTUs8+jfnwcf46kuFRvSl/3qFx2QTZ4DjgowesLHQXcFP7G//TjesqZG3WtgL7vdyVJyA==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.21.0"
}
},
"node_modules/prosemirror-view": {
"version": "1.42.3",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.3.tgz",
"integrity": "sha512-oTN7EtH+CpwxU9NrwEYWd0UZ4JUx7l048l5A2Xppm4p/60isZYLnth9QVQmC3VRIvdrIWCxwZSd+Uz791G31/w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.25.8",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/react": { "node_modules/react": {
"version": "19.3.0", "version": "19.3.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
@@ -827,6 +1495,12 @@
"@rolldown/binding-win32-x64-msvc": "1.2.8" "@rolldown/binding-win32-x64-msvc": "1.2.8"
} }
}, },
"node_modules/rope-sequence": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
"license": "MIT"
},
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.28.0", "version": "0.28.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
@@ -866,6 +1540,15 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/use-sync-external-store": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz",
"integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "8.3.0", "version": "8.3.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz",
@@ -943,6 +1626,12 @@
"optional": true "optional": true
} }
} }
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
} }
} }
} }
+4
View File
@@ -9,6 +9,10 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@tiptap/extension-text-style": "^3.31.3",
"@tiptap/pm": "^3.31.3",
"@tiptap/react": "^3.31.3",
"@tiptap/starter-kit": "^3.31.3",
"react": "^19.3.0", "react": "^19.3.0",
"react-dom": "^19.3.0", "react-dom": "^19.3.0",
"react-router-dom": "^7.18.3" "react-router-dom": "^7.18.3"
+2 -1
View File
@@ -7,6 +7,7 @@ import BlogPage from "./pages/BlogPage";
import BlogPostPage from "./pages/BlogPostPage"; import BlogPostPage from "./pages/BlogPostPage";
import ContactPage from "./pages/ContactPage"; import ContactPage from "./pages/ContactPage";
import ExperiencePage from "./pages/ExperiencePage"; import ExperiencePage from "./pages/ExperiencePage";
import JournalAdminPage from "./pages/JournalAdminPage";
import NotFoundPage from "./pages/NotFoundPage"; import NotFoundPage from "./pages/NotFoundPage";
import SkillsPage from "./pages/SkillsPage"; import SkillsPage from "./pages/SkillsPage";
@@ -45,6 +46,7 @@ export default function App() {
<Route path="/experience" element={<ExperiencePage />} /> <Route path="/experience" element={<ExperiencePage />} />
<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/:slug" element={<BlogPostPage />} /> <Route path="/blog/:slug" element={<BlogPostPage />} />
<Route <Route
path="/contact" path="/contact"
@@ -55,4 +57,3 @@ export default function App() {
</Layout> </Layout>
); );
} }
+19 -2
View File
@@ -17,7 +17,9 @@ async function request(path, options = {}) {
} catch { } catch {
// Keep the friendly fallback when a proxy or server returns non-JSON. // Keep the friendly fallback when a proxy or server returns non-JSON.
} }
throw new Error(detail); const error = new Error(detail);
error.status = response.status;
throw error;
} }
return response.json(); return response.json();
@@ -28,9 +30,24 @@ export const getExperience = (signal) => request("/api/experience", { signal });
export const getSkills = (signal) => request("/api/skills", { signal }); export const getSkills = (signal) => request("/api/skills", { signal });
export const getPosts = (signal) => request("/api/posts", { signal }); export const getPosts = (signal) => request("/api/posts", { signal });
export const getPost = (slug, signal) => request(`/api/posts/${slug}`, { signal }); export const getPost = (slug, signal) => request(`/api/posts/${slug}`, { signal });
export const loginAdmin = (password) =>
request("/api/auth/login", {
method: "POST",
body: JSON.stringify({ password }),
});
export const getAdminSession = (token, signal) =>
request("/api/auth/session", {
signal,
headers: { Authorization: `Bearer ${token}` },
});
export const createPost = (payload, token) =>
request("/api/posts", {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify(payload),
});
export const sendContactMessage = (payload) => export const sendContactMessage = (payload) =>
request("/api/contact", { request("/api/contact", {
method: "POST", method: "POST",
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
+28 -1
View File
@@ -27,10 +27,38 @@ const paths = {
<rect x="3" y="3" width="18" height="18" rx="4" /> <rect x="3" y="3" width="18" height="18" rx="4" />
</> </>
), ),
instagram: (
<>
<rect x="3" y="3" width="18" height="18" rx="5" />
<circle cx="12" cy="12" r="4" />
<path d="M17.5 6.5h.01" />
</>
),
lock: (
<>
<rect x="4" y="10" width="16" height="11" rx="3" />
<path d="M8 10V7a4 4 0 0 1 8 0v3M12 14v3" />
</>
),
logout: <path d="M10 5H5a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h5m4-4 3-3-3-3m3 3H8" />,
menu: <path d="M4 7h16M4 12h16M4 17h16" />, menu: <path d="M4 7h16M4 12h16M4 17h16" />,
plus: <path d="M12 5v14M5 12h14" />,
search: <path d="m20 20-4.35-4.35m2.35-5.15a7.5 7.5 0 1 1-15 0 7.5 7.5 0 0 1 15 0Z" />, search: <path d="m20 20-4.35-4.35m2.35-5.15a7.5 7.5 0 1 1-15 0 7.5 7.5 0 0 1 15 0Z" />,
send: <path d="m21 3-7.5 18-3.8-7.7L2 9.5 21 3Zm-11.3 10.3L14 9" />, send: <path d="m21 3-7.5 18-3.8-7.7L2 9.5 21 3Zm-11.3 10.3L14 9" />,
spark: <path d="M12 2c.6 5.4 3.6 8.4 9 9-5.4.6-8.4 3.6-9 9-.6-5.4-3.6-8.4-9-9 5.4-.6 8.4-3.6 9-9Z" />, spark: <path d="M12 2c.6 5.4 3.6 8.4 9 9-5.4.6-8.4 3.6-9 9-.6-5.4-3.6-8.4-9-9 5.4-.6 8.4-3.6 9-9Z" />,
spotify: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M7.5 9.5c3.5-1 7.3-.6 10 1M8.3 12.5c2.8-.7 6-.4 8.4.8M9 15.3c2.2-.5 4.6-.2 6.6.8" />
</>
),
steam: (
<>
<circle cx="15.5" cy="8.5" r="3.5" />
<circle cx="7" cy="16.5" r="2.5" />
<path d="m9 15 3.7-2.4 2.8-.6M4.8 15.3 2.5 14" />
</>
),
}; };
export default function Icon({ name, size = 20, className = "" }) { export default function Icon({ name, size = 20, className = "" }) {
@@ -51,4 +79,3 @@ export default function Icon({ name, size = 20, className = "" }) {
</svg> </svg>
); );
} }
+12 -4
View File
@@ -51,11 +51,15 @@ export default function BlogPage() {
useEffect(() => { useEffect(() => {
const controller = new AbortController(); const controller = new AbortController();
getPosts(controller.signal) getPosts(controller.signal)
.then(setPosts) .then((result) => {
if (!controller.signal.aborted) setPosts(result);
})
.catch((requestError) => { .catch((requestError) => {
if (requestError.name !== "AbortError") setError(requestError.message); if (requestError.name !== "AbortError") setError(requestError.message);
}) })
.finally(() => setLoading(false)); .finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort(); return () => controller.abort();
}, []); }, []);
@@ -80,7 +84,12 @@ export default function BlogPage() {
eyebrow="Journal" eyebrow="Journal"
title="Notes from the build." title="Notes from the build."
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={<p className="issue-count">{posts.length || 10}<span>field notes</span></p>} aside={(
<div className="journal-heading-aside">
<p className="issue-count">{posts.length || 10}<span>field notes</span></p>
<Link className="write-link" to="/blog/manage"><Icon name="plus" size={15} /> Write</Link>
</div>
)}
/> />
<div className="journal-tools reveal"> <div className="journal-tools reveal">
@@ -129,4 +138,3 @@ export default function BlogPage() {
</section> </section>
); );
} }
+11 -3
View File
@@ -21,12 +21,17 @@ export default function BlogPostPage() {
useEffect(() => { useEffect(() => {
const controller = new AbortController(); const controller = new AbortController();
setLoading(true); setLoading(true);
setError("");
getPost(slug, controller.signal) getPost(slug, controller.signal)
.then(setPost) .then((result) => {
if (!controller.signal.aborted) setPost(result);
})
.catch((requestError) => { .catch((requestError) => {
if (requestError.name !== "AbortError") setError(requestError.message); if (requestError.name !== "AbortError") setError(requestError.message);
}) })
.finally(() => setLoading(false)); .finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort(); return () => controller.abort();
}, [slug]); }, [slug]);
@@ -38,6 +43,10 @@ export default function BlogPostPage() {
return <div className="container content-page"><ErrorState message={error} /></div>; return <div className="container content-page"><ErrorState message={error} /></div>;
} }
if (!post) {
return <div className="container content-page"><ErrorState message="This journal note could not be opened." /></div>;
}
return ( return (
<article className="article-page"> <article className="article-page">
<header className={`article-hero article-hero--${post.accent}`}> <header className={`article-hero article-hero--${post.accent}`}>
@@ -72,4 +81,3 @@ export default function BlogPostPage() {
</article> </article>
); );
} }
+22 -1
View File
@@ -32,6 +32,7 @@ export default function ContactPage({ profile, error }) {
} }
const socials = profile?.socials ?? []; const socials = profile?.socials ?? [];
const interests = profile?.interests ?? [];
return ( return (
<section className="content-page container contact-page"> <section className="content-page container contact-page">
@@ -120,6 +121,27 @@ export default function ContactPage({ profile, error }) {
<p>Im especially interested in full-stack product work, applied AI, and systems that make demanding workflows feel calmer.</p> <p>Im especially interested in full-stack product work, applied AI, and systems that make demanding workflows feel calmer.</p>
</div> </div>
<div className="culture-card">
<p className="eyebrow">Beyond the build</p>
<h2><strong>I love music</strong> and nearly always have something playing on Spotify.</h2>
<p>Off the clock, I share a little life on Instagram and occasionally disappear into a game on Steam.</p>
<div className="culture-links">
{interests.map((interest) => (
<a
className={`culture-link culture-link--${interest.kind}`}
href={interest.href}
key={interest.kind}
rel="noreferrer"
target="_blank"
>
<span className="culture-link__icon"><Icon name={interest.kind} /></span>
<span><small>{interest.note}</small><strong>{interest.label}</strong></span>
<Icon name="arrowUpRight" size={18} />
</a>
))}
</div>
</div>
<div className="social-list"> <div className="social-list">
<p className="eyebrow">Find me here</p> <p className="eyebrow">Find me here</p>
{socials.map((social) => ( {socials.map((social) => (
@@ -139,4 +161,3 @@ export default function ContactPage({ profile, error }) {
</section> </section>
); );
} }
+210
View File
@@ -0,0 +1,210 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { createPost, getAdminSession, loginAdmin } from "../api";
import Icon from "../components/Icon";
import PageHeader from "../components/PageHeader";
import { LoadingState } from "../components/Status";
const TOKEN_KEY = "alex-journal-admin";
const today = new Date().toISOString().slice(0, 10);
const initialPost = {
title: "",
excerpt: "",
published_at: today,
read_time: 5,
tags: "AI, Engineering",
accent: "mint",
section_heading: "The idea",
body: "",
};
export default function JournalAdminPage() {
const navigate = useNavigate();
const [token, setToken] = useState(() => sessionStorage.getItem(TOKEN_KEY) ?? "");
const [checking, setChecking] = useState(Boolean(token));
const [password, setPassword] = useState("");
const [post, setPost] = useState(initialPost);
const [status, setStatus] = useState({ type: "idle", message: "" });
useEffect(() => {
if (!token) {
setChecking(false);
return undefined;
}
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." });
}
})
.finally(() => {
if (!controller.signal.aborted) setChecking(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: "" });
}
function updatePost(event) {
setPost((current) => ({ ...current, [event.target.name]: event.target.value }));
}
async function publish(event) {
event.preventDefault();
const paragraphs = post.body
.split(/\n\s*\n/)
.map((paragraph) => paragraph.trim())
.filter(Boolean);
const payload = {
title: post.title,
excerpt: post.excerpt,
published_at: post.published_at,
read_time: Number(post.read_time),
tags: post.tags.split(",").map((tag) => tag.trim()).filter(Boolean),
accent: post.accent,
content: [{ heading: post.section_heading, paragraphs }],
};
setStatus({ type: "sending", message: "Publishing…" });
try {
const created = 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 });
}
}
return (
<section className="content-page container publisher-page">
<PageHeader
eyebrow="Journal studio"
title="Publish a field note."
description="A small private writing room for adding new articles to the JSON journal."
aside={token ? <span className="publisher-badge"><span /> Authenticated</span> : null}
/>
{checking && <LoadingState label="Checking your session" />}
{!checking && !token && (
<form className="publisher-login reveal" onSubmit={signIn}>
<span className="publisher-login__icon"><Icon name="lock" size={24} /></span>
<div>
<p className="eyebrow">Private access</p>
<h2>Sign in to write</h2>
<p>The publisher uses one server-side password. It is never stored in the browser.</p>
</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>
)}
{!checking && token && (
<form className="publisher-form reveal" onSubmit={publish}>
<div className="publisher-toolbar">
<div>
<p className="eyebrow">New article</p>
<p>Paragraphs are separated by a blank line.</p>
</div>
<button className="text-button" onClick={signOut} type="button"><Icon name="logout" size={16} /> Sign out</button>
</div>
<label className="publisher-field publisher-field--wide">
<span>Title</span>
<input minLength="5" name="title" onChange={updatePost} placeholder="A clear, useful title" required value={post.title} />
</label>
<label className="publisher-field publisher-field--wide">
<span>Excerpt</span>
<textarea maxLength="360" minLength="20" name="excerpt" onChange={updatePost} placeholder="A concise introduction for the journal index." required rows="3" value={post.excerpt} />
</label>
<div className="publisher-fields-row">
<label className="publisher-field">
<span>Publish date</span>
<input name="published_at" onChange={updatePost} required type="date" value={post.published_at} />
</label>
<label className="publisher-field">
<span>Reading time</span>
<input max="60" min="1" name="read_time" onChange={updatePost} required type="number" value={post.read_time} />
</label>
<label className="publisher-field">
<span>Color</span>
<select name="accent" onChange={updatePost} value={post.accent}>
<option value="mint">Mint</option>
<option value="blue">Blue</option>
<option value="lavender">Lavender</option>
<option value="peach">Peach</option>
<option value="yellow">Yellow</option>
</select>
</label>
</div>
<label className="publisher-field publisher-field--wide">
<span>Topics</span>
<input name="tags" onChange={updatePost} placeholder="AI, React, Systems" required value={post.tags} />
<small>Separate up to six topics with commas.</small>
</label>
<label className="publisher-field publisher-field--wide">
<span>Section heading</span>
<input minLength="3" name="section_heading" onChange={updatePost} required value={post.section_heading} />
</label>
<label className="publisher-field publisher-field--wide">
<span>Article</span>
<textarea minLength="40" name="body" onChange={updatePost} placeholder="Write the article here…" required rows="15" value={post.body} />
</label>
<div className="publisher-submit">
<button className="button button--primary" disabled={status.type === "sending"} type="submit">
<Icon name="plus" size={18} /> {status.type === "sending" ? "Publishing…" : "Publish article"}
</button>
{status.message && <p className={`form-status form-status--${status.type}`} role="status">{status.message}</p>}
</div>
</form>
)}
</section>
);
}
+323 -14
View File
@@ -25,7 +25,7 @@
--container: 1160px; --container: 1160px;
--gutter: clamp(24px, 8vw, 150px); --gutter: clamp(24px, 8vw, 150px);
color: var(--ink); color: var(--ink);
font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-family: "Roboto Flex", Roboto, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-synthesis: none; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
} }
@@ -171,7 +171,7 @@ button {
align-items: center; align-items: center;
gap: 3px; gap: 3px;
padding: 5px; padding: 5px;
border: 1px solid rgba(54, 78, 70, 0.08); border: 0;
border-radius: 18px; border-radius: 18px;
background: rgba(255, 255, 255, 0.67); background: rgba(255, 255, 255, 0.67);
box-shadow: 0 8px 28px rgba(46, 65, 58, 0.05); box-shadow: 0 8px 28px rgba(46, 65, 58, 0.05);
@@ -650,7 +650,7 @@ button {
.timeline-card { .timeline-card {
overflow: hidden; overflow: hidden;
border: 1px solid var(--line); border: 0;
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
background: rgba(255, 255, 255, 0.72); background: rgba(255, 255, 255, 0.72);
box-shadow: 0 10px 38px rgba(46, 65, 58, 0.04); box-shadow: 0 10px 38px rgba(46, 65, 58, 0.04);
@@ -728,7 +728,7 @@ button {
height: 38px; height: 38px;
flex: 0 0 auto; flex: 0 0 auto;
place-items: center; place-items: center;
border: 1px solid var(--line); border: 0;
border-radius: 13px; border-radius: 13px;
background: rgba(247, 248, 244, 0.78); background: rgba(247, 248, 244, 0.78);
transition: transform 200ms ease, background 200ms ease; transition: transform 200ms ease, background 200ms ease;
@@ -809,7 +809,7 @@ button {
justify-content: space-between; justify-content: space-between;
gap: 10px; gap: 10px;
padding: 12px 13px; padding: 12px 13px;
border: 1px solid var(--line); border: 0;
border-radius: 14px; border-radius: 14px;
background: #fafbf8; background: #fafbf8;
transition: background 160ms ease, transform 160ms ease; transition: background 160ms ease, transform 160ms ease;
@@ -874,7 +874,7 @@ button {
gap: 30px; gap: 30px;
margin-bottom: 22px; margin-bottom: 22px;
padding: 25px 28px; padding: 25px 28px;
border: 1px solid var(--line); border: 0;
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
background: rgba(255, 255, 255, 0.58); background: rgba(255, 255, 255, 0.58);
} }
@@ -919,7 +919,7 @@ button {
grid-column: span 2; grid-column: span 2;
min-height: 285px; min-height: 285px;
padding: 24px; padding: 24px;
border: 1px solid rgba(54, 78, 70, 0.07); border: 0;
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
animation-delay: var(--delay); animation-delay: var(--delay);
transition: transform 200ms ease, box-shadow 200ms ease; transition: transform 200ms ease, box-shadow 200ms ease;
@@ -999,7 +999,7 @@ button {
.issue-count span { .issue-count span {
color: var(--ink-faint); color: var(--ink-faint);
font-family: Inter, ui-sans-serif, sans-serif; font-family: "Roboto Flex", Roboto, ui-sans-serif, sans-serif;
font-size: 10px; font-size: 10px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.1em; letter-spacing: 0.1em;
@@ -1073,7 +1073,7 @@ button {
.filter-row button:hover, .filter-row button:hover,
.filter-row button.is-active { .filter-row button.is-active {
border-color: var(--line); border-color: transparent;
color: var(--ink); color: var(--ink);
background: rgba(255, 255, 255, 0.72); background: rgba(255, 255, 255, 0.72);
} }
@@ -1086,7 +1086,7 @@ button {
.post-card { .post-card {
overflow: hidden; overflow: hidden;
border: 1px solid rgba(54, 78, 70, 0.08); border: 0;
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
background: rgba(255, 255, 255, 0.74); background: rgba(255, 255, 255, 0.74);
box-shadow: 0 8px 28px rgba(46, 65, 58, 0.04); box-shadow: 0 8px 28px rgba(46, 65, 58, 0.04);
@@ -1231,8 +1231,9 @@ button {
height: 34px; height: 34px;
flex: 0 0 auto; flex: 0 0 auto;
place-items: center; place-items: center;
border: 1px solid var(--line); border: 0;
border-radius: 12px; border-radius: 12px;
background: rgba(225, 238, 233, 0.65);
transition: color 160ms ease, background 160ms ease; transition: color 160ms ease, background 160ms ease;
} }
@@ -1407,7 +1408,7 @@ button {
justify-items: center; justify-items: center;
margin-top: 70px; margin-top: 70px;
padding: 36px; padding: 36px;
border: 1px solid var(--line); border: 0;
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
background: var(--surface); background: var(--surface);
text-align: center; text-align: center;
@@ -1458,7 +1459,7 @@ button {
display: grid; display: grid;
gap: 19px; gap: 19px;
padding: clamp(25px, 4vw, 40px); padding: clamp(25px, 4vw, 40px);
border: 1px solid var(--line); border: 0;
border-radius: var(--radius-xl); border-radius: var(--radius-xl);
background: rgba(255, 255, 255, 0.78); background: rgba(255, 255, 255, 0.78);
box-shadow: var(--shadow-soft); box-shadow: var(--shadow-soft);
@@ -1551,7 +1552,7 @@ button {
.contact-note, .contact-note,
.social-list { .social-list {
padding: 25px; padding: 25px;
border: 1px solid var(--line); border: 0;
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
} }
@@ -1692,6 +1693,283 @@ button {
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
.journal-heading-aside {
display: flex;
align-items: center;
gap: 18px;
}
.write-link,
.text-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
border: 0;
color: var(--sage-deep);
background: var(--mint);
font-size: 11px;
font-weight: 780;
cursor: pointer;
}
.write-link {
min-height: 38px;
padding-inline: 14px;
border-radius: 13px;
box-shadow: 0 8px 22px rgba(47, 81, 71, 0.08);
}
.culture-card {
padding: 25px;
border-radius: var(--radius-lg);
background: linear-gradient(145deg, var(--lavender), #f4f0f5);
box-shadow: 0 12px 34px rgba(75, 67, 95, 0.06);
}
.culture-card .eyebrow {
margin-bottom: 13px;
color: var(--lavender-deep);
}
.culture-card h2 {
margin: 0;
color: var(--ink);
font-family: "Roboto Flex", Roboto, ui-sans-serif, sans-serif;
font-size: 18px;
font-weight: 520;
letter-spacing: -0.025em;
line-height: 1.3;
}
.culture-card h2 strong {
font-weight: 850;
}
.culture-card > p:not(.eyebrow) {
margin: 11px 0 20px;
color: var(--ink-soft);
font-size: 12px;
line-height: 1.65;
}
.culture-links {
display: grid;
gap: 9px;
}
.culture-link {
display: grid;
min-height: 58px;
grid-template-columns: 38px minmax(0, 1fr) auto;
align-items: center;
gap: 11px;
padding: 10px 12px;
border-radius: 16px;
color: var(--ink);
box-shadow: 0 9px 24px rgba(46, 50, 58, 0.07);
transition: transform 180ms ease, box-shadow 180ms ease;
}
.culture-link:hover {
box-shadow: 0 13px 29px rgba(46, 50, 58, 0.11);
transform: translateY(-2px);
}
.culture-link--spotify { background: #dff3e5; }
.culture-link--instagram { background: #f6e3ea; }
.culture-link--steam { background: #e0ebf3; }
.culture-link__icon {
display: grid;
width: 38px;
height: 38px;
place-items: center;
border-radius: 13px;
background: rgba(255, 255, 255, 0.7);
}
.culture-link small,
.culture-link strong {
display: block;
}
.culture-link small {
margin-bottom: 2px;
color: var(--ink-faint);
font-size: 8px;
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
}
.culture-link strong {
font-size: 12px;
font-style: italic;
font-weight: 850;
}
.publisher-badge {
display: inline-flex;
align-items: center;
gap: 9px;
padding: 10px 13px;
border-radius: 999px;
color: #3b725e;
background: var(--mint);
font-size: 10px;
font-weight: 750;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.publisher-badge span {
width: 7px;
height: 7px;
border-radius: 50%;
background: #5c947c;
}
.publisher-login {
display: grid;
max-width: 620px;
gap: 22px;
margin: -22px auto 30px;
padding: clamp(28px, 5vw, 48px);
border-radius: var(--radius-xl);
background: rgba(255, 255, 255, 0.8);
box-shadow: var(--shadow);
}
.publisher-login__icon {
display: grid;
width: 52px;
height: 52px;
place-items: center;
border-radius: 17px;
color: var(--sage-deep);
background: var(--mint);
}
.publisher-login .eyebrow,
.publisher-toolbar .eyebrow {
margin-bottom: 9px;
}
.publisher-login h2 {
margin: 0;
font-family: Georgia, "Times New Roman", serif;
font-size: 34px;
font-weight: 400;
letter-spacing: -0.04em;
}
.publisher-login > div > p:last-child,
.publisher-toolbar > div > p:last-child {
margin: 8px 0 0;
color: var(--ink-soft);
font-size: 12px;
line-height: 1.6;
}
.publisher-login label,
.publisher-field {
display: grid;
gap: 8px;
}
.publisher-login label > span,
.publisher-field > span {
color: var(--ink-soft);
font-size: 10px;
font-weight: 760;
letter-spacing: 0.07em;
text-transform: uppercase;
}
.publisher-login input,
.publisher-field input,
.publisher-field textarea,
.publisher-field select {
width: 100%;
border: 1px solid rgba(54, 78, 70, 0.13);
border-radius: 15px;
outline: 0;
color: var(--ink);
background: #fbfcfa;
font-size: 13px;
transition: border 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.publisher-login input,
.publisher-field input,
.publisher-field select {
min-height: 50px;
padding-inline: 15px;
}
.publisher-field textarea {
padding: 15px;
line-height: 1.7;
resize: vertical;
}
.publisher-login input:focus,
.publisher-field input:focus,
.publisher-field textarea:focus,
.publisher-field select:focus {
border-color: rgba(47, 81, 71, 0.43);
background: white;
box-shadow: 0 0 0 4px rgba(47, 81, 71, 0.07);
}
.publisher-form {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 21px;
margin-top: -25px;
padding: clamp(28px, 5vw, 48px);
border-radius: var(--radius-xl);
background: rgba(255, 255, 255, 0.8);
box-shadow: var(--shadow-soft);
}
.publisher-toolbar,
.publisher-field--wide,
.publisher-submit {
grid-column: 1 / -1;
}
.publisher-toolbar,
.publisher-submit {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.text-button {
padding: 10px 13px;
border-radius: 12px;
}
.publisher-fields-row {
display: grid;
grid-column: 1 / -1;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
}
.publisher-field small {
color: var(--ink-faint);
font-size: 10px;
}
.publisher-submit {
justify-content: flex-start;
padding-top: 4px;
}
@media (max-width: 1120px) { @media (max-width: 1120px) {
:root { :root {
--gutter: clamp(24px, 5vw, 70px); --gutter: clamp(24px, 5vw, 70px);
@@ -1782,6 +2060,10 @@ button {
.contact-aside { .contact-aside {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
} }
.contact-aside .social-list {
grid-column: 1 / -1;
}
} }
@media (max-width: 720px) { @media (max-width: 720px) {
@@ -1940,6 +2222,14 @@ button {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.publisher-fields-row {
grid-template-columns: 1fr;
}
.publisher-toolbar {
align-items: flex-start;
}
.form-footer { .form-footer {
align-items: stretch; align-items: stretch;
flex-direction: column; flex-direction: column;
@@ -1957,6 +2247,25 @@ button {
font-size: 9px; font-size: 9px;
} }
.journal-heading-aside {
flex-wrap: wrap;
}
.publisher-login,
.publisher-form {
margin-top: -10px;
padding: 24px;
}
.publisher-submit {
align-items: stretch;
flex-direction: column;
}
.publisher-submit .button {
width: 100%;
}
.about-page { .about-page {
gap: 40px; gap: 40px;
padding-block: 50px 70px; padding-block: 50px 70px;