Supabase was integrated to store articles, article editor is standardized editor using react component

This commit is contained in:
StormRunner06106
2026-09-12 01:39:07 -07:00
parent 14d1dbbb07
commit 3ed741d6d2
18 changed files with 1031 additions and 131 deletions
+7 -5
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { getPost } from "../api";
import Icon from "../components/Icon";
import { RichTextArticle } from "../components/RichTextEditor";
import { ErrorState, LoadingState } from "../components/Status";
function formatDate(date) {
@@ -58,20 +59,21 @@ export default function BlogPostPage() {
</div>
<h1>{post.title}</h1>
<p>{post.excerpt}</p>
<div className="article-byline">
<span className="mini-avatar">AH</span>
<span><strong>Alex Herlan</strong><small>{formatDate(post.published_at)} · {post.read_time} min read</small></span>
<div className="article-byline article-byline--simple">
<Icon name="calendar" size={16} />
<span>{formatDate(post.published_at)}</span>
<span>{post.read_time} min read</span>
</div>
</div>
</header>
<div className="article-container article-content">
{post.content.map((section) => (
{Array.isArray(post.content) ? post.content.map((section) => (
<section key={section.heading}>
<h2>{section.heading}</h2>
{section.paragraphs.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
</section>
))}
)) : <RichTextArticle content={post.content} />}
<div className="article-end">
<Icon name="spark" />
<p>Thanks for reading.</p>
+23 -21
View File
@@ -121,27 +121,6 @@ export default function ContactPage({ profile, error }) {
<p>Im especially interested in full-stack product work, applied AI, and systems that make demanding workflows feel calmer.</p>
</div>
<div className="culture-card">
<p className="eyebrow">Beyond the build</p>
<h2><strong>I love music</strong> and nearly always have something playing on Spotify.</h2>
<p>Off the clock, I share a little life on Instagram and occasionally disappear into a game on Steam.</p>
<div className="culture-links">
{interests.map((interest) => (
<a
className={`culture-link culture-link--${interest.kind}`}
href={interest.href}
key={interest.kind}
rel="noreferrer"
target="_blank"
>
<span className="culture-link__icon"><Icon name={interest.kind} /></span>
<span><small>{interest.note}</small><strong>{interest.label}</strong></span>
<Icon name="arrowUpRight" size={18} />
</a>
))}
</div>
</div>
<div className="social-list">
<p className="eyebrow">Find me here</p>
{socials.map((social) => (
@@ -158,6 +137,29 @@ export default function ContactPage({ profile, error }) {
</div>
</aside>
</div>
<div className="culture-card culture-card--wide reveal">
<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>
<p>Off the clock, I share a little life on Instagram and occasionally disappear into a game on Steam.</p>
</div>
<div className="culture-links">
{interests.map((interest) => (
<a
className={`culture-link culture-link--${interest.kind}`}
href={interest.href}
key={interest.kind}
rel="noreferrer"
target="_blank"
>
<span className="culture-link__icon"><Icon name={interest.kind} /></span>
<span><small>{interest.note}</small><strong>{interest.label}</strong></span>
<Icon name="arrowUpRight" size={18} />
</a>
))}
</div>
</div>
</section>
);
}
+90 -28
View File
@@ -3,20 +3,32 @@ import { useNavigate } from "react-router-dom";
import { createPost, getAdminSession, loginAdmin } from "../api";
import Icon from "../components/Icon";
import PageHeader from "../components/PageHeader";
import RichTextEditor from "../components/RichTextEditor";
import { LoadingState } from "../components/Status";
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: "",
excerpt: "",
published_at: today,
read_time: 5,
tags: "AI, Engineering",
tags: ["AI"],
accent: "mint",
section_heading: "The idea",
body: "",
};
export default function JournalAdminPage() {
@@ -25,6 +37,9 @@ export default function JournalAdminPage() {
const [checking, setChecking] = useState(Boolean(token));
const [password, setPassword] = useState("");
const [post, setPost] = useState(initialPost);
const [article, setArticle] = useState(emptyDocument);
const [articleText, setArticleText] = useState("");
const [customTopic, setCustomTopic] = useState("");
const [status, setStatus] = useState({ type: "idle", message: "" });
useEffect(() => {
@@ -72,20 +87,45 @@ export default function JournalAdminPage() {
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] };
});
}
function addCustomTopic() {
const topic = customTopic.trim();
if (!topic || post.tags.includes(topic)) return;
if (post.tags.length >= 6) {
setStatus({ type: "error", message: "Choose up to six topics." });
return;
}
setPost((current) => ({ ...current, tags: [...current.tags, topic] }));
setCustomTopic("");
}
async function publish(event) {
event.preventDefault();
const paragraphs = post.body
.split(/\n\s*\n/)
.map((paragraph) => paragraph.trim())
.filter(Boolean);
if (!post.tags.length) {
setStatus({ type: "error", message: "Choose at least one topic." });
return;
}
if (articleText.length < 40) {
setStatus({ type: "error", message: "Write at least 40 characters before publishing." });
return;
}
const payload = {
title: post.title,
excerpt: post.excerpt,
published_at: post.published_at,
...post,
read_time: Number(post.read_time),
tags: post.tags.split(",").map((tag) => tag.trim()).filter(Boolean),
accent: post.accent,
content: [{ heading: post.section_heading, paragraphs }],
content: article,
};
setStatus({ type: "sending", message: "Publishing…" });
@@ -107,7 +147,7 @@ export default function JournalAdminPage() {
<PageHeader
eyebrow="Journal studio"
title="Publish a field note."
description="A small private writing room for adding new articles to the JSON journal."
description="A focused writing room with rich-text editing and Supabase-ready storage."
aside={token ? <span className="publisher-badge"><span /> Authenticated</span> : null}
/>
@@ -145,7 +185,7 @@ export default function JournalAdminPage() {
<div className="publisher-toolbar">
<div>
<p className="eyebrow">New article</p>
<p>Paragraphs are separated by a blank line.</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>
</div>
@@ -181,21 +221,43 @@ export default function JournalAdminPage() {
</label>
</div>
<label className="publisher-field publisher-field--wide">
<span>Topics</span>
<input name="tags" onChange={updatePost} placeholder="AI, React, Systems" required value={post.tags} />
<small>Separate up to six topics with commas.</small>
</label>
<fieldset className="topic-picker">
<legend>Topics <span>{post.tags.length}/6 selected</span></legend>
<div className="topic-options">
{suggestedTopics.map((topic) => (
<button
aria-pressed={post.tags.includes(topic)}
className={post.tags.includes(topic) ? "is-active" : ""}
key={topic}
onClick={() => toggleTopic(topic)}
type="button"
>
{post.tags.includes(topic) ? "✓ " : "+ "}{topic}
</button>
))}
</div>
<div className="custom-topic">
<input
maxLength="40"
onChange={(event) => setCustomTopic(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
addCustomTopic();
}
}}
placeholder="Add another topic"
value={customTopic}
/>
<button onClick={addCustomTopic} type="button">Add topic</button>
</div>
</fieldset>
<label className="publisher-field publisher-field--wide">
<span>Section heading</span>
<input minLength="3" name="section_heading" onChange={updatePost} required value={post.section_heading} />
</label>
<label className="publisher-field publisher-field--wide">
<div className="publisher-field publisher-field--wide">
<span>Article</span>
<textarea minLength="40" name="body" onChange={updatePost} placeholder="Write the article here…" required rows="15" value={post.body} />
</label>
<RichTextEditor onChange={(content, text) => { setArticle(content); setArticleText(text); }} />
<small>{articleText.length} characters · Use headings to break longer pieces into sections.</small>
</div>
<div className="publisher-submit">
<button className="button button--primary" disabled={status.type === "sending"} type="submit">