Processed some UI issues on journal
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user