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
+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>