Processed some UI issues on journal
This commit is contained in:
@@ -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
@@ -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",
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// Starter articles use section arrays; the editor uses Tiptap documents.
|
||||
export function editableDocument(content) {
|
||||
if (!Array.isArray(content)) return content;
|
||||
return { type: "doc", content: content.flatMap((section) => [
|
||||
...(section.heading ? [{ type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: section.heading }] }] : []),
|
||||
...section.paragraphs.map((text) => ({ type: "paragraph", ...(text ? { content: [{ type: "text", text }] } : {}) })),
|
||||
]) };
|
||||
}
|
||||
|
||||
export function articlePlainText(node) {
|
||||
if (!node) return "";
|
||||
return [node.text ?? "", ...(node.content ?? []).map(articlePlainText)].filter(Boolean).join(" ").trim();
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { getAdminSession, loginAdmin } from "../api";
|
||||
import Modal from "./Modal";
|
||||
import Icon from "./Icon";
|
||||
|
||||
const TOKEN_KEY = "alex-journal-admin";
|
||||
const AdminContext = createContext(null);
|
||||
export const useAdmin = () => useContext(AdminContext);
|
||||
|
||||
export default function AdminProvider({ children }) {
|
||||
const [token, setToken] = useState(() => sessionStorage.getItem(TOKEN_KEY) ?? "");
|
||||
const [checking, setChecking] = useState(Boolean(token));
|
||||
const [role, setRole] = useState(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [sessionRevision, setSessionRevision] = useState(0);
|
||||
const [checkAttempt, setCheckAttempt] = useState(0);
|
||||
const tokenRef = useRef(token);
|
||||
const verifiedToken = useRef("");
|
||||
|
||||
const signOut = useCallback(() => {
|
||||
tokenRef.current = "";
|
||||
verifiedToken.current = "";
|
||||
sessionStorage.removeItem(TOKEN_KEY);
|
||||
setToken("");
|
||||
setRole(null);
|
||||
setChecking(false);
|
||||
setPassword("");
|
||||
setOpen(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || verifiedToken.current === token) return;
|
||||
const controller = new AbortController();
|
||||
setChecking(true);
|
||||
getAdminSession(token, controller.signal)
|
||||
.then((session) => {
|
||||
if (!controller.signal.aborted && tokenRef.current === token) {
|
||||
verifiedToken.current = token;
|
||||
setRole(session.role);
|
||||
setError("");
|
||||
setSessionRevision((current) => current + 1);
|
||||
}
|
||||
})
|
||||
.catch((requestError) => {
|
||||
if (!controller.signal.aborted && tokenRef.current === token) {
|
||||
if (requestError.status === 401) signOut();
|
||||
setError(requestError.status === 401 ? requestError.message : "Could not check your session. Retry when your connection is back, or sign in again.");
|
||||
}
|
||||
})
|
||||
.finally(() => { if (!controller.signal.aborted) setChecking(false); });
|
||||
return () => controller.abort();
|
||||
}, [token, checkAttempt, signOut]);
|
||||
|
||||
useEffect(() => {
|
||||
const retry = () => { if (tokenRef.current && !verifiedToken.current) setCheckAttempt((current) => current + 1); };
|
||||
window.addEventListener("online", retry);
|
||||
window.addEventListener("focus", retry);
|
||||
return () => { window.removeEventListener("online", retry); window.removeEventListener("focus", retry); };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const expire = (event) => {
|
||||
const expiredToken = event?.detail?.token ?? token;
|
||||
if (!expiredToken || expiredToken !== tokenRef.current) return;
|
||||
signOut();
|
||||
setError("Your session ended. Sign in again to manage articles.");
|
||||
};
|
||||
window.addEventListener("journal-session-expired", expire);
|
||||
const expiresAt = Number(token.split(".")[0]) * 1000;
|
||||
const timer = token ? setTimeout(expire, Math.max(0, expiresAt - Date.now())) : null;
|
||||
return () => {
|
||||
window.removeEventListener("journal-session-expired", expire);
|
||||
if (timer !== null) clearTimeout(timer);
|
||||
};
|
||||
}, [token, signOut]);
|
||||
|
||||
async function signIn(event) {
|
||||
event.preventDefault();
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const session = await loginAdmin(password);
|
||||
sessionStorage.setItem(TOKEN_KEY, session.access_token);
|
||||
tokenRef.current = session.access_token;
|
||||
verifiedToken.current = session.access_token;
|
||||
setToken(session.access_token);
|
||||
setRole(session.role);
|
||||
setChecking(false);
|
||||
setSessionRevision((current) => current + 1);
|
||||
setPassword("");
|
||||
setOpen(false);
|
||||
} catch (requestError) {
|
||||
setError(requestError.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const isAdmin = role === "admin" && Boolean(token) && !checking;
|
||||
const close = () => { setOpen(false); setPassword(""); };
|
||||
return <AdminContext.Provider value={{ token, checking, isAdmin, sessionRevision, signOut, openSignIn: () => setOpen(true) }}>
|
||||
{children}
|
||||
{open && <Modal title={isAdmin ? "Admin account" : "Admin sign in"} onClose={close} busy={busy}>
|
||||
{isAdmin ? <div className="admin-account">
|
||||
<span className="publisher-badge"><span /> Admin</span>
|
||||
<p>You can create, edit, and delete journal articles.</p>
|
||||
<Link className="button button--primary" to="/blog/manage" onClick={close}><Icon name="plus" size={18} /> New article</Link>
|
||||
<Link className="button" to="/blog" onClick={close}>Manage articles</Link>
|
||||
<button className="text-button" type="button" onClick={signOut}><Icon name="logout" size={18} /> Sign out</button>
|
||||
</div> : <form className="admin-signin" onSubmit={signIn}>
|
||||
<p>Enter the admin password to manage journal articles.</p>
|
||||
<label className="publisher-field">
|
||||
<span>Admin password</span>
|
||||
<input autoFocus autoComplete="current-password" type="password" required maxLength={200}
|
||||
value={password} onChange={(event) => setPassword(event.target.value)} />
|
||||
</label>
|
||||
{error && <p className="form-status form-status--error" role="alert">{error}</p>}
|
||||
{token && !checking && <button className="text-button" type="button" onClick={() => setCheckAttempt((current) => current + 1)}>Retry session check</button>}
|
||||
<button className="button button--primary" type="submit" disabled={busy || checking}>{busy ? "Signing in…" : "Sign in"}</button>
|
||||
</form>}
|
||||
</Modal>}
|
||||
</AdminContext.Provider>;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { deleteArticle } from "../api";
|
||||
import { useAdmin } from "./AdminSession";
|
||||
import Icon from "./Icon";
|
||||
import Modal from "./Modal";
|
||||
|
||||
export default function ArticleAdminActions({ post, onDeleted }) {
|
||||
const { isAdmin, token } = useAdmin();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
if (!isAdmin) return null;
|
||||
|
||||
async function remove() {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await deleteArticle(post.slug, token);
|
||||
setConfirming(false);
|
||||
onDeleted(post.slug);
|
||||
} catch (requestError) {
|
||||
setError(requestError.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return <div className="article-admin-actions">
|
||||
<Link className="text-button" to={`/blog/${post.slug}/edit`}><Icon name="edit" size={16} /> Edit</Link>
|
||||
<button className="text-button text-button--danger" type="button" onClick={() => { setError(""); setConfirming(true); }}>
|
||||
<Icon name="trash" size={16} /> Delete
|
||||
</button>
|
||||
{confirming && <Modal title="Delete article?" onClose={() => setConfirming(false)} busy={busy}>
|
||||
<p>“{post.title}” will be removed from the journal. This cannot be undone.</p>
|
||||
{error && <p className="form-status form-status--error" role="alert">{error}</p>}
|
||||
<div className="dialog-actions">
|
||||
<button autoFocus className="button" type="button" disabled={busy} onClick={() => setConfirming(false)}>Cancel</button>
|
||||
<button className="button button--danger" type="button" disabled={busy} onClick={remove}>{busy ? "Deleting…" : "Delete article"}</button>
|
||||
</div>
|
||||
</Modal>}
|
||||
</div>;
|
||||
}
|
||||
@@ -1,40 +1,184 @@
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { mediaUrl } from "../api";
|
||||
import 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 = [] }) {
|
||||
|
||||
@@ -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" />,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useEffect, useId, useRef } from "react";
|
||||
import Icon from "./Icon";
|
||||
|
||||
export default function Modal({ title, children, onClose, busy = false }) {
|
||||
const dialog = useRef(null);
|
||||
const titleId = useId();
|
||||
useEffect(() => {
|
||||
const element = dialog.current;
|
||||
element.showModal();
|
||||
return () => element.close();
|
||||
}, []);
|
||||
|
||||
return <dialog ref={dialog} className="admin-dialog" aria-labelledby={titleId}
|
||||
onCancel={(event) => { event.preventDefault(); if (!busy) onClose(); }}>
|
||||
<div className="admin-dialog__heading">
|
||||
<h2 id={titleId}>{title}</h2>
|
||||
<button type="button" className="icon-button" aria-label="Close dialog" disabled={busy} onClick={onClose}><Icon name="close" /></button>
|
||||
</div>
|
||||
{children}
|
||||
</dialog>;
|
||||
}
|
||||
@@ -7,15 +7,15 @@ export function LoadingState({ label = "Loading" }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorState({ message }) {
|
||||
export function ErrorState({ message, onRetry, retrying = false }) {
|
||||
return (
|
||||
<div className="state-card state-card--error" role="alert">
|
||||
<span className="state-icon">!</span>
|
||||
<div>
|
||||
<strong>That didn’t load.</strong>
|
||||
<p>{message}</p>
|
||||
{onRetry && <button className="button" type="button" disabled={retrying} onClick={onRetry}>{retrying ? "Retrying..." : "Try again"}</button>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export const mainTopics = [
|
||||
"AI", "React", "FastAPI", "Supabase", "Python", "Cloud",
|
||||
"DevOps", "Product", "Reliability", "Security", "Music", "Life",
|
||||
];
|
||||
|
||||
export const topicKey = (topic) => topic.trim().toLowerCase();
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { getPost, getPosts } from "./api";
|
||||
import { useAdmin } from "./components/AdminSession";
|
||||
|
||||
// Article reads are public. Sign-in only triggers a fresh read, never gates it.
|
||||
export default function useJournalResource(slug = null) {
|
||||
const { sessionRevision } = useAdmin();
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [state, setState] = useState({ key: slug, data: null, loading: true, error: "" });
|
||||
const reload = useCallback(() => setAttempt((current) => current + 1), []);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setState((current) => ({ key: slug, data: current.key === slug ? current.data : null, loading: true, error: "" }));
|
||||
const request = slug === null ? getPosts(controller.signal) : getPost(slug, controller.signal);
|
||||
request.then((data) => {
|
||||
if (!controller.signal.aborted) setState({ key: slug, data, loading: false, error: "" });
|
||||
}).catch((error) => {
|
||||
if (!controller.signal.aborted) setState((current) => ({ ...current, loading: false, error: error.message }));
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [slug, sessionRevision, attempt]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.error) return;
|
||||
const visible = () => { if (document.visibilityState === "visible") reload(); };
|
||||
window.addEventListener("online", reload);
|
||||
window.addEventListener("focus", reload);
|
||||
document.addEventListener("visibilitychange", visible);
|
||||
return () => {
|
||||
window.removeEventListener("online", reload);
|
||||
window.removeEventListener("focus", reload);
|
||||
document.removeEventListener("visibilitychange", visible);
|
||||
};
|
||||
}, [state.error, reload]);
|
||||
|
||||
const setData = useCallback((update) => {
|
||||
setState((current) => ({ ...current, data: typeof update === "function" ? update(current.data) : update }));
|
||||
}, []);
|
||||
return { ...state, loading: state.key !== slug || state.loading, data: state.key === slug ? state.data : null, reload, setData };
|
||||
}
|
||||
Reference in New Issue
Block a user