Processed some UI issues on journal

This commit is contained in:
StormRunner06106
2026-09-12 22:10:44 -07:00
parent 737b56e97e
commit 206bbbc5ed
27 changed files with 1245 additions and 327 deletions
+4 -2
View File
@@ -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 (
<Layout profile={profile}>
<AdminProvider><Layout profile={profile}>
<ScrollToTop />
<Suspense fallback={<div className="container content-page"><LoadingState label="Opening page" /></div>}>
<Routes>
@@ -50,6 +51,7 @@ export default function App() {
<Route path="/skills" element={<SkillsPage />} />
<Route path="/blog" element={<BlogPage />} />
<Route path="/blog/manage" element={<JournalAdminPage />} />
<Route path="/blog/:slug/edit" element={<JournalAdminPage />} />
<Route path="/blog/:slug" element={<BlogPostPage />} />
<Route
path="/contact"
@@ -58,6 +60,6 @@ export default function App() {
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Suspense>
</Layout>
</Layout></AdminProvider>
);
}
+99 -26
View File
@@ -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",
+13
View File
@@ -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();
}
+128
View File
@@ -0,0 +1,128 @@
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { getAdminSession, loginAdmin } from "../api";
import Modal from "./Modal";
import Icon from "./Icon";
const TOKEN_KEY = "alex-journal-admin";
const AdminContext = createContext(null);
export const useAdmin = () => useContext(AdminContext);
export default function AdminProvider({ children }) {
const [token, setToken] = useState(() => sessionStorage.getItem(TOKEN_KEY) ?? "");
const [checking, setChecking] = useState(Boolean(token));
const [role, setRole] = useState(null);
const [open, setOpen] = useState(false);
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [sessionRevision, setSessionRevision] = useState(0);
const [checkAttempt, setCheckAttempt] = useState(0);
const tokenRef = useRef(token);
const verifiedToken = useRef("");
const signOut = useCallback(() => {
tokenRef.current = "";
verifiedToken.current = "";
sessionStorage.removeItem(TOKEN_KEY);
setToken("");
setRole(null);
setChecking(false);
setPassword("");
setOpen(false);
}, []);
useEffect(() => {
if (!token || verifiedToken.current === token) return;
const controller = new AbortController();
setChecking(true);
getAdminSession(token, controller.signal)
.then((session) => {
if (!controller.signal.aborted && tokenRef.current === token) {
verifiedToken.current = token;
setRole(session.role);
setError("");
setSessionRevision((current) => current + 1);
}
})
.catch((requestError) => {
if (!controller.signal.aborted && tokenRef.current === token) {
if (requestError.status === 401) signOut();
setError(requestError.status === 401 ? requestError.message : "Could not check your session. Retry when your connection is back, or sign in again.");
}
})
.finally(() => { if (!controller.signal.aborted) setChecking(false); });
return () => controller.abort();
}, [token, checkAttempt, signOut]);
useEffect(() => {
const retry = () => { if (tokenRef.current && !verifiedToken.current) setCheckAttempt((current) => current + 1); };
window.addEventListener("online", retry);
window.addEventListener("focus", retry);
return () => { window.removeEventListener("online", retry); window.removeEventListener("focus", retry); };
}, []);
useEffect(() => {
const expire = (event) => {
const expiredToken = event?.detail?.token ?? token;
if (!expiredToken || expiredToken !== tokenRef.current) return;
signOut();
setError("Your session ended. Sign in again to manage articles.");
};
window.addEventListener("journal-session-expired", expire);
const expiresAt = Number(token.split(".")[0]) * 1000;
const timer = token ? setTimeout(expire, Math.max(0, expiresAt - Date.now())) : null;
return () => {
window.removeEventListener("journal-session-expired", expire);
if (timer !== null) clearTimeout(timer);
};
}, [token, signOut]);
async function signIn(event) {
event.preventDefault();
if (busy) return;
setBusy(true);
setError("");
try {
const session = await loginAdmin(password);
sessionStorage.setItem(TOKEN_KEY, session.access_token);
tokenRef.current = session.access_token;
verifiedToken.current = session.access_token;
setToken(session.access_token);
setRole(session.role);
setChecking(false);
setSessionRevision((current) => current + 1);
setPassword("");
setOpen(false);
} catch (requestError) {
setError(requestError.message);
} finally {
setBusy(false);
}
}
const isAdmin = role === "admin" && Boolean(token) && !checking;
const close = () => { setOpen(false); setPassword(""); };
return <AdminContext.Provider value={{ token, checking, isAdmin, sessionRevision, signOut, openSignIn: () => setOpen(true) }}>
{children}
{open && <Modal title={isAdmin ? "Admin account" : "Admin sign in"} onClose={close} busy={busy}>
{isAdmin ? <div className="admin-account">
<span className="publisher-badge"><span /> Admin</span>
<p>You can create, edit, and delete journal articles.</p>
<Link className="button button--primary" to="/blog/manage" onClick={close}><Icon name="plus" size={18} /> New article</Link>
<Link className="button" to="/blog" onClick={close}>Manage articles</Link>
<button className="text-button" type="button" onClick={signOut}><Icon name="logout" size={18} /> Sign out</button>
</div> : <form className="admin-signin" onSubmit={signIn}>
<p>Enter the admin password to manage journal articles.</p>
<label className="publisher-field">
<span>Admin password</span>
<input autoFocus autoComplete="current-password" type="password" required maxLength={200}
value={password} onChange={(event) => setPassword(event.target.value)} />
</label>
{error && <p className="form-status form-status--error" role="alert">{error}</p>}
{token && !checking && <button className="text-button" type="button" onClick={() => setCheckAttempt((current) => current + 1)}>Retry session check</button>}
<button className="button button--primary" type="submit" disabled={busy || checking}>{busy ? "Signing in…" : "Sign in"}</button>
</form>}
</Modal>}
</AdminContext.Provider>;
}
@@ -0,0 +1,44 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import { deleteArticle } from "../api";
import { useAdmin } from "./AdminSession";
import Icon from "./Icon";
import Modal from "./Modal";
export default function ArticleAdminActions({ post, onDeleted }) {
const { isAdmin, token } = useAdmin();
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
if (!isAdmin) return null;
async function remove() {
if (busy) return;
setBusy(true);
setError("");
try {
await deleteArticle(post.slug, token);
setConfirming(false);
onDeleted(post.slug);
} catch (requestError) {
setError(requestError.message);
} finally {
setBusy(false);
}
}
return <div className="article-admin-actions">
<Link className="text-button" to={`/blog/${post.slug}/edit`}><Icon name="edit" size={16} /> Edit</Link>
<button className="text-button text-button--danger" type="button" onClick={() => { setError(""); setConfirming(true); }}>
<Icon name="trash" size={16} /> Delete
</button>
{confirming && <Modal title="Delete article?" onClose={() => setConfirming(false)} busy={busy}>
<p>{post.title} will be removed from the journal. This cannot be undone.</p>
{error && <p className="form-status form-status--error" role="alert">{error}</p>}
<div className="dialog-actions">
<button autoFocus className="button" type="button" disabled={busy} onClick={() => setConfirming(false)}>Cancel</button>
<button className="button button--danger" type="button" disabled={busy} onClick={remove}>{busy ? "Deleting…" : "Delete article"}</button>
</div>
</Modal>}
</div>;
}
+175 -31
View File
@@ -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 (
<fieldset className="article-uploads" disabled={busy}>
<legend>Photos & files</legend>
<label className="publisher-field">
<span>Banner image</span>
<small>Shown on the journal thumbnail and above the article title. JPEG, PNG, WebP or GIF, up to 8 MB.</small>
<input type="file" accept="image/jpeg,image/png,image/webp,image/gif" onChange={(event) => {
onUpload(Array.from(event.target.files), "banner");
event.target.value = "";
}} />
</label>
{banner && <div className="upload-preview">
<img src={mediaUrl(banner)} alt="Banner preview" />
<span>{banner.name}</span>
<button type="button" className="text-button" onClick={onRemoveBanner}>Remove banner</button>
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 <div className={`upload-dropzone ${dragging && !disabled ? "is-dragging" : ""} ${disabled ? "is-disabled" : ""}`}
onDragEnter={(event) => { event.preventDefault(); depth.current += 1; setDragging(true); }}
onDragOver={(event) => { event.preventDefault(); event.dataTransfer.dropEffect = disabled ? "none" : "copy"; }}
onDragLeave={(event) => { event.preventDefault(); depth.current -= 1; if (depth.current <= 0) setDragging(false); }}
onDrop={(event) => {
event.preventDefault(); depth.current = 0; setDragging(false);
if (!disabled) onFiles(Array.from(event.dataTransfer.files), purpose);
}}>
<span className="upload-dropzone__icon"><Icon name={banner ? "photo" : "upload"} size={25} /></span>
<strong>{banner ? hasBanner ? "Drop a new banner to replace it" : "Give your article a cover" : "Add something worth sharing"}</strong>
<p>{banner ? "Drag an image here, or choose one below." : "Drop photos, documents, or other files here."}</p>
<button type="button" className="upload-browse" disabled={disabled} aria-describedby={hintId} onClick={() => input.current.click()}>
<Icon name="plus" size={16} /> {banner ? hasBanner ? "Replace banner" : "Choose banner" : "Browse files"}
</button>
<small id={hintId}>{banner ? "JPG, PNG, WebP or GIF · Up to 8 MB" : "Up to 10 files · 20 MB each"}</small>
<input ref={input} className="upload-input" type="file" tabIndex={-1}
aria-label={banner ? "Banner image" : "Additional photos & files"}
accept={banner ? "image/jpeg,image/png,image/webp,image/gif" : undefined}
multiple={!banner} disabled={disabled} onChange={(event) => {
onFiles(Array.from(event.target.files), purpose);
event.target.value = "";
}} />
</div>;
}
export default function ArticleUploads({ banner, attachments, busy, title, subtitle, onUpload, onPendingChange, onRemoveBanner, onRemoveAttachment, onMoveAttachment }) {
const [tasks, setTasks] = useState([]);
const [messages, setMessages] = useState([]);
const queue = useRef([]);
const running = useRef(false);
const mounted = useRef(true);
const previews = useRef(new Set());
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
queue.current.forEach((task) => task.controller?.abort());
previews.current.forEach((url) => URL.revokeObjectURL(url));
previews.current.clear();
onPendingChange(false);
};
}, [onPendingChange]);
function updateQueue(next) {
queue.current = next;
if (mounted.current) { setTasks(next); onPendingChange(next.length > 0); }
}
function forget(id) {
const task = queue.current.find((item) => item.id === id);
if (task?.preview) { URL.revokeObjectURL(task.preview); previews.current.delete(task.preview); }
updateQueue(queue.current.filter((item) => item.id !== id));
}
function change(id, values) {
updateQueue(queue.current.map((task) => task.id === id ? { ...task, ...values } : task));
}
async function processQueue() {
if (running.current) return;
running.current = true;
try {
let task;
while (mounted.current && (task = queue.current.find((item) => item.status === "queued"))) {
const controller = new AbortController();
const id = task.id;
change(id, { status: "uploading", controller, progress: 0 });
try {
await onUpload(task.file, task.purpose, {
signal: controller.signal,
onProgress: (progress) => { if (mounted.current) change(id, { progress }); },
});
if (mounted.current) forget(id);
} catch (error) {
if (mounted.current && error.name !== "AbortError") change(id, { status: "error", error: error.message });
}
}
} finally {
running.current = false;
}
}
function addFiles(files, purpose) {
if (busy || !files.length) return;
const errors = [];
if (purpose === "banner" && (files.length > 1 || queue.current.some((task) => task.purpose === "banner"))) {
setMessages(["Choose one banner at a time. Finish or remove the current banner upload first."]);
return;
}
const additions = [];
const existing = [...attachments, ...queue.current.filter((task) => task.purpose === "attachment").map((task) => task.file)];
for (const file of files) {
const limit = (purpose === "banner" ? 8 : 20) * 1024 * 1024;
if (!file.size) { errors.push(`${file.name}: this file is empty.`); continue; }
if (file.size > limit) { errors.push(`${file.name}: exceeds the ${purpose === "banner" ? 8 : 20} MB limit.`); continue; }
if (purpose === "banner" && !isImage(file)) { errors.push(`${file.name}: choose a JPG, PNG, WebP, or GIF image.`); continue; }
if (purpose === "attachment" && existing.some((item) => item.name === file.name && item.size === file.size)) {
errors.push(`${file.name}: already added.`); continue;
}
if (purpose === "attachment" && existing.length >= 10) { errors.push(`${file.name}: all 10 attachment slots are filled.`); continue; }
const preview = isImage(file) ? URL.createObjectURL(file) : null;
if (preview) previews.current.add(preview);
additions.push({ id: crypto.randomUUID(), file, purpose, preview, status: "queued", progress: 0 });
if (purpose === "attachment") existing.push(file);
}
setMessages(errors);
if (additions.length) {
updateQueue([...queue.current, ...additions]);
processQueue();
}
}
function cancel(task) { task.controller?.abort(); forget(task.id); }
const bannerTask = tasks.find((task) => task.purpose === "banner");
const heroImage = bannerTask?.preview ?? (banner ? mediaUrl(banner) : null);
const attachmentCount = attachments.length + tasks.filter((task) => task.purpose === "attachment").length;
return <fieldset className="article-uploads" disabled={busy}>
<legend>Photos & files</legend>
<div className="upload-section">
<div className="upload-section__heading"><div><h3>Banner image</h3><p>Your journal thumbnail and article cover.</p></div><span className="upload-label">Optional</span></div>
{heroImage && <div className="upload-hero-preview">
<img src={heroImage} alt="Banner preview" />
<div><span>{bannerTask ? "Preview · Upload pending" : "Hero preview"}</span><strong>{title || "Your article title"}</strong><p>{subtitle || "Your subtitle appears here, over the cover image."}</p></div>
</div>}
<label className="publisher-field">
<span>Additional photos & files</span>
<small>Displayed below the article text, in upload order. Up to 10 files, 20 MB each.</small>
<input type="file" multiple onChange={(event) => {
onUpload(Array.from(event.target.files), "attachment");
event.target.value = "";
}} />
</label>
{banner && <div className="upload-saved-banner"><span><Icon name="check" size={15} /> {banner.name} <small>{fileSize(banner.size)}</small></span>
<button type="button" className="text-button" disabled={Boolean(bannerTask)} onClick={onRemoveBanner}>Remove banner</button></div>}
<Dropzone purpose="banner" hasBanner={Boolean(banner)} disabled={busy || Boolean(bannerTask)} onFiles={addFiles} />
<small className="upload-tip">Wide images work best. The cover is cropped to fill the hero and thumbnail.</small>
</div>
<div className="upload-section">
<div className="upload-section__heading"><div><h3>Additional photos & files</h3><p>Shown below your article, in this order.</p></div><span className="upload-label">{attachmentCount}/10</span></div>
<Dropzone purpose="attachment" disabled={busy || attachmentCount >= 10} onFiles={addFiles} />
{attachments.length > 0 && <ul className="upload-list">
{attachments.map((file) => <li key={file.url}>
{file.media_type.startsWith("image/") && <img src={mediaUrl(file)} alt="" />}
<span>{file.name} <small>({Math.ceil(file.size / 1024)} KB)</small></span>
<button type="button" className="text-button" aria-label={`Remove ${file.name}`} onClick={() => onRemoveAttachment(file.url)}>Remove</button>
{attachments.map((file, index) => <li key={file.url}>
<span className="upload-file-icon">{file.media_type.startsWith("image/") ? <img src={mediaUrl(file)} alt="" /> : <Icon name="file" size={24} />}</span>
<div className="upload-file-info"><strong>{file.name}</strong><small>{fileSize(file.size)} · <span className="upload-ready">Ready</span></small></div>
<div className="upload-file-actions">
<button type="button" className="upload-icon-button" disabled={index === 0} aria-label={`Move ${file.name} up`} title="Move up" onClick={() => onMoveAttachment(file.url, -1)}><Icon name="up" size={17} /></button>
<button type="button" className="upload-icon-button" disabled={index === attachments.length - 1} aria-label={`Move ${file.name} down`} title="Move down" onClick={() => onMoveAttachment(file.url, 1)}><Icon name="down" size={17} /></button>
<button type="button" className="upload-icon-button" aria-label={`Remove ${file.name}`} title="Remove file" onClick={() => onRemoveAttachment(file.url)}><Icon name="close" size={17} /></button>
</div>
</li>)}
</ul>}
{busy && <p role="status">Uploading</p>}
</fieldset>
);
</div>
{messages.length > 0 && <div className="upload-feedback" role="alert"><strong>Some files could not be added</strong><ul>{messages.map((message, index) => <li key={index}>{message}</li>)}</ul><button type="button" className="text-button" onClick={() => setMessages([])}>Dismiss</button></div>}
{tasks.length > 0 && <div className="upload-queue">
<h3>Upload queue</h3>
<ul className="upload-list">{tasks.map((task) => <li key={task.id} className={task.status === "error" ? "has-error" : ""}>
<span className="upload-file-icon">{task.preview ? <img src={task.preview} alt="" /> : <Icon name="file" size={24} />}</span>
<div className="upload-file-info"><strong>{task.file.name}</strong><small>{fileSize(task.file.size)} · {task.purpose === "banner" ? "Banner" : "Attachment"}</small>
{task.status === "error" ? <p role="alert">{task.error}</p> : <>
<small>{task.status === "queued" ? "Waiting to upload" : task.progress === 100 ? "Processing file…" : `Uploading ${task.progress}%`}</small>
<progress max="100" value={task.progress} aria-label={`Uploading ${task.file.name}`} />
</>}
</div>
<div className="upload-file-actions">
{task.status === "error" && <button type="button" className="text-button" aria-label={`Retry ${task.file.name}`} onClick={() => { change(task.id, { status: "queued", error: "" }); processQueue(); }}>Retry</button>}
<button type="button" className="upload-icon-button" aria-label={`${task.status === "error" ? "Remove" : "Cancel"} upload ${task.file.name}`} onClick={() => cancel(task)}><Icon name="close" size={18} /></button>
</div>
</li>)}</ul>
<p className="upload-tip" role="status">{tasks.some((task) => task.status === "error") ? "Retry or remove failed uploads before publishing." : "You can keep writing while your files upload."}</p>
</div>}
</fieldset>;
}
export function ArticleAttachments({ attachments = [] }) {
+8
View File
@@ -1,4 +1,12 @@
const paths = {
upload: <path d="M12 16V3m-5 5 5-5 5 5M4 16v5h16v-5" />,
photo: <><rect x="3" y="3" width="18" height="18" rx="3" /><circle cx="8" cy="8" r="1.5" /><path d="m3 17 6-6 4 4 3-3 5 5" /></>,
file: <><path d="M14 2H5v20h14V7l-5-5Zm0 0v6h5M8 13h8M8 17h5" /></>,
up: <path d="m6 14 6-6 6 6" />,
down: <path d="m6 10 6 6 6-6" />,
user: <><circle cx="12" cy="8" r="4" /><path d="M4 21v-2a8 8 0 0 1 16 0v2" /></>,
edit: <><path d="m15 4 5 5M4 20l5-1L21 7a2 2 0 0 0-5-5L4 14v6Z" /></>,
trash: <><path d="M3 6h18M9 6V3h6v3M5 6l1 15h12l1-15M10 10v7M14 10v7" /></>,
arrow: <path d="M5 12h14m-5-5 5 5-5 5" />,
arrowLeft: <path d="m11 17-5-5 5-5m-5 5h13" />,
arrowUpRight: <path d="M7 17 17 7M8 7h9v9" />,
+47 -1
View File
@@ -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 }) {
<header className="site-header">
<div className="container header-inner">
<NavLink className="brand" to="/" aria-label={`${name} home`}>
<span className="brand-mark">AH</span>
<img className="brand-mark brand-photo" src="/alex-avatar-96.webp" width="42" height="42" alt="" />
<span className="brand-name">{name}</span>
</NavLink>
<div className="header-actions">
<nav className={`nav-shell ${menuOpen ? "is-open" : ""}`} aria-label="Main navigation">
{navItems.map((item) => (
<NavLink
@@ -37,6 +40,13 @@ function Header({ name }) {
))}
</nav>
<button type="button" className={`icon-button header-signin ${isAdmin ? "is-admin" : ""}`}
aria-label={isAdmin ? "Admin account" : "Admin sign in"} title={isAdmin ? "Admin account" : "Admin sign in"}
aria-haspopup="dialog" disabled={checking} onClick={openSignIn}>
<Icon name={isAdmin ? "user" : "lock"} />
{isAdmin && <span className="admin-indicator" />}
</button>
<button
aria-expanded={menuOpen}
aria-label={menuOpen ? "Close navigation" : "Open navigation"}
@@ -46,6 +56,7 @@ function Header({ name }) {
>
<Icon name={menuOpen ? "close" : "menu"} />
</button>
</div>
</div>
</header>
);
@@ -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 (
<div className="app-shell">
<a className="skip-link" href="#main-content">Skip to content</a>
+21
View File
@@ -0,0 +1,21 @@
import { useEffect, useId, useRef } from "react";
import Icon from "./Icon";
export default function Modal({ title, children, onClose, busy = false }) {
const dialog = useRef(null);
const titleId = useId();
useEffect(() => {
const element = dialog.current;
element.showModal();
return () => element.close();
}, []);
return <dialog ref={dialog} className="admin-dialog" aria-labelledby={titleId}
onCancel={(event) => { event.preventDefault(); if (!busy) onClose(); }}>
<div className="admin-dialog__heading">
<h2 id={titleId}>{title}</h2>
<button type="button" className="icon-button" aria-label="Close dialog" disabled={busy} onClick={onClose}><Icon name="close" /></button>
</div>
{children}
</dialog>;
}
+2 -2
View File
@@ -7,15 +7,15 @@ export function LoadingState({ label = "Loading" }) {
);
}
export function ErrorState({ message }) {
export function ErrorState({ message, onRetry, retrying = false }) {
return (
<div className="state-card state-card--error" role="alert">
<span className="state-icon">!</span>
<div>
<strong>That didnt load.</strong>
<p>{message}</p>
{onRetry && <button className="button" type="button" disabled={retrying} onClick={onRetry}>{retrying ? "Retrying..." : "Try again"}</button>}
</div>
</div>
);
}
+51 -38
View File
@@ -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 (
<article className={`post-card post-card--${post.accent} ${featured ? "post-card--featured" : ""} reveal`}>
<Link to={`/blog/${post.slug}`} aria-label={`Read ${post.title}`}>
@@ -39,42 +43,34 @@ function PostCard({ post, featured }) {
</div>
</div>
</Link>
<ArticleAdminActions post={post} onDeleted={onDeleted} />
</article>
);
}
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={(
<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>
<p className="issue-count">{posts.length}<span>field notes</span></p>
{isAdmin && <Link className="write-link" to="/blog/manage"><Icon name="plus" size={15} /> New article</Link>}
</div>
)}
/>
@@ -105,27 +101,44 @@ export default function BlogPage() {
value={query}
/>
</label>
<div className="filter-row" aria-label="Filter articles by topic">
<div className="journal-filters">
<div className="filter-row filter-row--wrap" role="group" aria-label="Filter articles by topic">
{tags.map((tag) => (
<button
className={activeTag === tag ? "is-active" : ""}
key={tag}
onClick={() => setActiveTag(tag)}
aria-pressed={activeTag === tag}
key={tag ?? "all-filter"}
onClick={() => { setActiveTag(tag); setShowOthers(false); }}
type="button"
>
{tag}
{tag ?? "All"}
</button>
))}
<button type="button" className={`others-filter ${showOthers || customActive ? "is-active" : ""}`}
aria-expanded={showOthers} aria-controls="journal-custom-topics" onClick={() => setShowOthers((current) => !current)}>
Others{customActive && <span> · {activeTag}</span>} <Icon name="chevron" size={14} />
</button>
</div>
<div id="journal-custom-topics" className="custom-topic-filters" hidden={!showOthers}>
<p>Custom topics <span>{customTopics.length}</span></p>
{customTopics.length ? <div className="filter-row filter-row--wrap" role="group" aria-label="Filter by custom topic">
{customTopics.map((tag) => <button key={topicKey(tag)} type="button"
className={topicKey(activeTag ?? "") === topicKey(tag) ? "is-active" : ""}
aria-pressed={topicKey(activeTag ?? "") === topicKey(tag)} onClick={() => setActiveTag(tag)}>{tag}</button>)}
</div> : <small>{loading ? "Loading topics..." : "No custom topics yet."}</small>}
</div>
</div>
</div>
{loading && <LoadingState label="Fetching field notes" />}
{error && <ErrorState message={error} />}
{loading && !data && <LoadingState label="Fetching field notes" />}
{error && <ErrorState message={error} onRetry={reload} />}
{loading && data && <p role="status">Refreshing articles...</p>}
{!loading && !error && filteredPosts.length > 0 && (
{filteredPosts.length > 0 && (
<div className="posts-grid">
{filteredPosts.map((post, index) => (
<PostCard featured={index === 0 && !query && activeTag === "All"} key={post.slug} post={post} />
<PostCard featured={index === 0 && !query && activeTag === null} key={post.slug} post={post}
onDeleted={(slug) => setPosts((current) => current.filter((item) => item.slug !== slug))} />
))}
</div>
)}
+11 -26
View File
@@ -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 <div className="container content-page"><LoadingState label="Opening the note" /></div>;
}
if (error) {
return <div className="container content-page"><ErrorState message={error} /></div>;
if (error && !post) {
return <div className="container content-page"><ErrorState message={error} onRetry={reload} /></div>;
}
if (!post) {
@@ -51,11 +34,13 @@ export default function BlogPostPage() {
return (
<article className="article-page">
{error && <div className="article-container"><ErrorState message={error} onRetry={reload} /></div>}
<header className={`article-hero article-hero--${post.accent}`}>
{post.banner && <img className="article-banner" src={mediaUrl(post.banner)} alt="" />}
<div className="article-hero__shape" aria-hidden="true"><Icon name="spark" size={50} /></div>
<div className="article-container reveal">
<Link className="back-link" to="/blog"><Icon name="arrowLeft" size={17} /> Back to journal</Link>
<ArticleAdminActions post={post} onDeleted={() => navigate("/blog")} />
<div className="article-tags">
{post.tags.map((tag) => <span key={tag}>{tag}</span>)}
</div>
+3
View File
@@ -139,6 +139,8 @@ export default function ContactPage({ profile, error }) {
</div>
<div className="culture-card culture-card--wide reveal">
<img className="culture-photo" src="/alex_music.jpg" alt="Alex playing guitar" width="206" height="206" loading="lazy" />
<div className="culture-content">
<div className="culture-intro">
<p className="eyebrow">Beyond the build</p>
<h2><strong>I love music</strong> and nearly always have something playing on Spotify.</h2>
@@ -159,6 +161,7 @@ export default function ContactPage({ profile, error }) {
</a>
))}
</div>
</div>
</div>
</section>
);
+116 -136
View File
@@ -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 <ArticleForm key={slug ?? "new"} slug={slug} />;
}
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 (
<section className="content-page container publisher-page">
<PageHeader
eyebrow="Journal studio"
title="Publish a field note."
description="A focused writing room with rich-text editing and Supabase-ready storage."
aside={token ? <span className="publisher-badge"><span /> Authenticated</span> : 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 ? <span className="publisher-badge"><span /> Admin</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 && !isAdmin && (
<div className="publisher-login">
<h2>Sign in to manage articles</h2>
<p>Use your admin password to create or edit a journal article.</p>
<button className="button button--primary" type="button" onClick={openSignIn}><Icon name="lock" size={18} /> Sign in</button>
</div>
)}
{isAdmin && loading && <LoadingState label="Loading article" />}
{isAdmin && loadError && <ErrorState message={loadError} />}
{!checking && token && (
{isAdmin && !loading && !loadError && (
<form className="publisher-form reveal" onSubmit={publish}>
<div className="publisher-toolbar">
<div>
<p className="eyebrow">New article</p>
<p className="eyebrow">{slug ? "Edit article" : "New article"}</p>
<p>Write, format, choose the topics, and publish from one clean workspace.</p>
</div>
<button className="text-button" onClick={signOut} type="button"><Icon name="logout" size={16} /> Sign out</button>
@@ -268,22 +227,41 @@ export default function JournalAdminPage() {
{post.tags.includes(topic) ? "✓ " : "+ "}{topic}
</button>
))}
{post.tags.filter((topic) => !suggestedTopics.includes(topic)).map((topic) => (
<span className="topic-chip is-active" key={topic}>
<span><Icon name="check" size={13} /> {topic}</span>
<button className="topic-chip__remove" type="button" aria-label={`Remove topic ${topic}`}
title={`Remove ${topic}`} onClick={() => removeCustomTopic(topic)}><Icon name="close" size={14} /></button>
</span>
))}
</div>
<div className="custom-topic">
<div className="custom-topic__input">
<label className="sr-only" htmlFor="custom-topic">Custom topic</label>
<input
ref={topicInput}
id="custom-topic"
aria-describedby="topic-shortcut topic-feedback"
aria-keyshortcuts="Enter"
enterKeyHint="done"
maxLength="40"
onChange={(event) => 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}
/>
<button onClick={addCustomTopic} type="button">Add topic</button>
<kbd aria-hidden="true">Enter </kbd>
</div>
<button onClick={addCustomTopic} disabled={!customTopic.trim()} type="button">Add topic</button>
</div>
<p className="topic-hint" id="topic-shortcut">Type a topic and press <kbd>Enter</kbd> to add and select it. Use × on a custom topic to remove it.</p>
<p className="topic-feedback" id="topic-feedback" role="status">{topicMessage}</p>
</fieldset>
<div className="publisher-field publisher-field--wide">
@@ -293,13 +271,15 @@ export default function JournalAdminPage() {
</div>
<p className="section-help">Use + Section to add a heading and a new section. Each section gets a divider and an automatic uppercase drop cap on its opening paragraph.</p>
<ArticleUploads banner={banner} attachments={attachments} busy={uploading || status.type === "sending"}
onUpload={uploadFiles} onRemoveBanner={() => setBanner(null)}
<ArticleUploads banner={banner} attachments={attachments} busy={status.type === "sending"}
title={post.title} subtitle={post.excerpt} onPendingChange={setUploading} onMoveAttachment={moveAttachment}
onUpload={uploadFile} onRemoveBanner={() => setBanner(null)}
onRemoveAttachment={(url) => setAttachments((current) => current.filter((file) => file.url !== url))} />
<div className="publisher-submit">
<button className="button button--primary" disabled={uploading || status.type === "sending"} type="submit">
<Icon name="plus" size={18} /> {status.type === "sending" ? "Publishing…" : "Publish article"}
<Link className="text-button" to={slug ? `/blog/${slug}` : "/blog"}>Cancel</Link>
<button className="button button--primary" disabled={!isAdmin || uploading || status.type === "sending"} type="submit">
<Icon name={slug ? "check" : "plus"} size={18} /> {status.type === "sending" ? "Saving..." : slug ? "Save changes" : "Publish article"}
</button>
{status.message && <p className={`form-status form-status--${status.type}`} role="status">{status.message}</p>}
</div>
+249 -16
View File
@@ -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;
}
}
+6
View File
@@ -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();
+41
View File
@@ -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 };
}