Add long-form article editor (NIP-23)

- ArticleEditor with title, markdown body, summary, cover image, tags
- write/preview toggle with markdown rendering via marked
- Auto-save draft to localStorage
- Publish as kind 30023 with NIP-23 tags (d, title, published_at, etc.)
- 'write article' button in sidebar when logged in
- Article preview prose styles in CSS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jure
2026-03-08 19:04:43 +01:00
parent 366731f9d7
commit bf1d68bb93
9 changed files with 291 additions and 3 deletions

21
package-lock.json generated
View File

@@ -12,6 +12,7 @@
"@tailwindcss/vite": "^4.2.1",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"marked": "^17.0.4",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"tailwindcss": "^4.2.1",
@@ -19,6 +20,7 @@
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@types/marked": "^5.0.2",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
@@ -1924,6 +1926,13 @@
"@types/unist": "*"
}
},
"node_modules/@types/marked": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@types/marked/-/marked-5.0.2.tgz",
"integrity": "sha512-OucS4KMHhFzhz27KxmWg7J+kIYqyqoW5kdIEI319hqARQQUTqhao3M/F+uFnDXD0Rg72iDDZxZNxq5gvctmLlg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/mdast": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
@@ -2727,6 +2736,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/marked": {
"version": "17.0.4",
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.4.tgz",
"integrity": "sha512-NOmVMM+KAokHMvjWmC5N/ZOvgmSWuqJB8FoYI019j4ogb/PeRMKoKIjReZ2w3376kkA8dSJIP8uD993Kxc0iRQ==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/mdast-util-to-hast": {
"version": "13.2.1",
"resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",

View File

@@ -14,6 +14,7 @@
"@tailwindcss/vite": "^4.2.1",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"marked": "^17.0.4",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"tailwindcss": "^4.2.1",
@@ -21,6 +22,7 @@
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@types/marked": "^5.0.2",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",

View File

@@ -4,6 +4,7 @@ import { RelaysView } from "./components/shared/RelaysView";
import { SettingsView } from "./components/shared/SettingsView";
import { ProfileView } from "./components/profile/ProfileView";
import { ThreadView } from "./components/thread/ThreadView";
import { ArticleEditor } from "./components/article/ArticleEditor";
import { useUIStore } from "./stores/ui";
function App() {
@@ -18,6 +19,7 @@ function App() {
{currentView === "settings" && <SettingsView />}
{currentView === "profile" && <ProfileView />}
{currentView === "thread" && <ThreadView />}
{currentView === "article-editor" && <ArticleEditor />}
</main>
</div>
);

View File

@@ -0,0 +1,191 @@
import { useState, useEffect } from "react";
import { marked } from "marked";
import { publishArticle } from "../../lib/nostr";
import { useUIStore } from "../../stores/ui";
const DRAFT_KEY = "wrystr_article_draft";
function loadDraft() {
try { return JSON.parse(localStorage.getItem(DRAFT_KEY) || "null"); }
catch { return null; }
}
function saveDraft(data: object) {
localStorage.setItem(DRAFT_KEY, JSON.stringify(data));
}
function clearDraft() {
localStorage.removeItem(DRAFT_KEY);
}
export function ArticleEditor() {
const { goBack } = useUIStore();
const draft = loadDraft();
const [title, setTitle] = useState(draft?.title || "");
const [content, setContent] = useState(draft?.content || "");
const [summary, setSummary] = useState(draft?.summary || "");
const [image, setImage] = useState(draft?.image || "");
const [tags, setTags] = useState(draft?.tags || "");
const [mode, setMode] = useState<"write" | "preview">("write");
const [showMeta, setShowMeta] = useState(false);
const [publishing, setPublishing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [published, setPublished] = useState(false);
// Auto-save draft
useEffect(() => {
const t = setTimeout(() => {
saveDraft({ title, content, summary, image, tags });
}, 1000);
return () => clearTimeout(t);
}, [title, content, summary, image, tags]);
const renderedHtml = marked(content || "*Nothing to preview yet.*") as string;
const wordCount = content.trim() ? content.trim().split(/\s+/).length : 0;
const canPublish = title.trim().length > 0 && content.trim().length > 0;
const handlePublish = async () => {
if (!canPublish || publishing) return;
setPublishing(true);
setError(null);
try {
await publishArticle({
title: title.trim(),
content: content.trim(),
summary: summary.trim() || undefined,
image: image.trim() || undefined,
tags: tags.split(",").map((t) => t.trim()).filter(Boolean),
});
clearDraft();
setPublished(true);
setTimeout(goBack, 1500);
} catch (err) {
setError(`Failed to publish: ${err}`);
} finally {
setPublishing(false);
}
};
return (
<div className="h-full flex flex-col">
{/* Header */}
<header className="border-b border-border px-4 py-2.5 flex items-center justify-between shrink-0">
<div className="flex items-center gap-3">
<button onClick={goBack} className="text-text-dim hover:text-text text-[11px] transition-colors">
back
</button>
<span className="text-text-dim text-[10px]">{wordCount > 0 ? `${wordCount} words` : "New article"}</span>
{draft && !published && (
<span className="text-text-dim text-[10px]">· draft saved</span>
)}
</div>
<div className="flex items-center gap-2">
{/* Write / Preview toggle */}
<div className="flex border border-border text-[11px]">
<button
onClick={() => setMode("write")}
className={`px-3 py-1 transition-colors ${mode === "write" ? "bg-accent/10 text-accent" : "text-text-muted hover:text-text"}`}
>
write
</button>
<button
onClick={() => setMode("preview")}
className={`px-3 py-1 transition-colors ${mode === "preview" ? "bg-accent/10 text-accent" : "text-text-muted hover:text-text"}`}
>
preview
</button>
</div>
<button
onClick={() => setShowMeta((v) => !v)}
className={`px-3 py-1 text-[11px] border border-border transition-colors ${showMeta ? "text-accent border-accent/40" : "text-text-muted hover:text-text"}`}
>
meta
</button>
<button
onClick={handlePublish}
disabled={!canPublish || publishing || published}
className="px-4 py-1 text-[11px] bg-accent hover:bg-accent-hover text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
{published ? "published ✓" : publishing ? "publishing…" : "publish"}
</button>
</div>
</header>
{/* Meta panel */}
{showMeta && (
<div className="border-b border-border px-6 py-3 bg-bg-raised grid grid-cols-2 gap-3 shrink-0">
<div>
<label className="text-text-dim text-[10px] block mb-1">Summary</label>
<textarea
value={summary}
onChange={(e) => setSummary(e.target.value)}
placeholder="A short description…"
rows={2}
className="w-full bg-bg border border-border px-2 py-1.5 text-text text-[12px] resize-none focus:outline-none focus:border-accent/50"
/>
</div>
<div className="flex flex-col gap-2">
<div>
<label className="text-text-dim text-[10px] block mb-1">Cover image URL</label>
<input
value={image}
onChange={(e) => setImage(e.target.value)}
placeholder="https://…"
className="w-full bg-bg border border-border px-2 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent/50"
/>
</div>
<div>
<label className="text-text-dim text-[10px] block mb-1">Tags (comma-separated)</label>
<input
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="bitcoin, nostr, essay"
className="w-full bg-bg border border-border px-2 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent/50"
/>
</div>
</div>
</div>
)}
{error && (
<div className="px-6 py-2 text-danger text-[12px] bg-danger/5 border-b border-border shrink-0">
{error}
</div>
)}
{/* Editor */}
<div className="flex-1 overflow-hidden flex flex-col article-editor">
{/* Title */}
<div className="px-6 pt-6 pb-2 shrink-0">
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Title"
className="w-full bg-transparent text-text text-2xl font-bold placeholder:text-text-dim focus:outline-none"
/>
</div>
{/* Content area */}
<div className="flex-1 overflow-y-auto px-6 pb-6">
{mode === "write" ? (
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Write your article in Markdown…"
className="w-full h-full min-h-[400px] bg-transparent text-text text-[14px] leading-relaxed placeholder:text-text-dim resize-none focus:outline-none font-mono"
/>
) : (
<div
className="article-preview text-[14px]"
dangerouslySetInnerHTML={{ __html: renderedHtml }}
/>
)}
</div>
</div>
</div>
);
}

View File

@@ -12,7 +12,7 @@ const NAV_ITEMS = [
] as const;
export function Sidebar() {
const { currentView, setView, sidebarCollapsed, toggleSidebar } = useUIStore();
const { currentView, setView, sidebarCollapsed, toggleSidebar, openThread, goBack } = useUIStore();
const { connected, notes } = useFeedStore();
const { loggedIn, profile, npub, logout } = useUserStore();
const [showLogin, setShowLogin] = useState(false);
@@ -43,6 +43,19 @@ export function Sidebar() {
{/* Nav */}
<nav className="flex-1 overflow-y-auto py-2">
{loggedIn && !sidebarCollapsed && (
<button
onClick={() => setView("article-editor")}
className={`w-full text-left px-3 py-1.5 flex items-center gap-2 text-[12px] transition-colors mb-1 ${
currentView === "article-editor"
? "text-accent bg-accent/8"
: "text-text-muted hover:text-text hover:bg-bg-hover"
}`}
>
<span className="w-4 text-center text-[14px]"></span>
<span>write article</span>
</button>
)}
{NAV_ITEMS.map((item) => (
<button
key={item.id}

View File

@@ -38,6 +38,34 @@ body {
overflow: hidden;
}
/* Allow text selection in article editor */
.article-editor textarea,
.article-editor input {
-webkit-user-select: text;
user-select: text;
}
/* Article preview prose styles */
.article-preview {
-webkit-user-select: text;
user-select: text;
}
.article-preview h1 { font-size: 1.6em; font-weight: bold; margin: 1.2em 0 0.5em; }
.article-preview h2 { font-size: 1.3em; font-weight: bold; margin: 1.2em 0 0.5em; }
.article-preview h3 { font-size: 1.1em; font-weight: bold; margin: 1em 0 0.4em; }
.article-preview p { margin: 0.8em 0; line-height: 1.75; }
.article-preview ul { list-style: disc; padding-left: 1.5em; margin: 0.8em 0; }
.article-preview ol { list-style: decimal; padding-left: 1.5em; margin: 0.8em 0; }
.article-preview li { margin: 0.3em 0; line-height: 1.7; }
.article-preview blockquote { border-left: 3px solid var(--color-accent); padding-left: 1em; color: var(--color-text-muted); margin: 1em 0; }
.article-preview code { font-family: var(--font-mono); background: var(--color-bg-raised); padding: 0.1em 0.4em; font-size: 0.9em; border-radius: 2px; }
.article-preview pre { background: var(--color-bg-raised); border: 1px solid var(--color-border); padding: 1em; overflow-x: auto; margin: 1em 0; }
.article-preview pre code { background: none; padding: 0; }
.article-preview a { color: var(--color-accent); text-decoration: underline; text-underline-offset: 2px; }
.article-preview hr { border: none; border-top: 1px solid var(--color-border); margin: 1.5em 0; }
.article-preview strong { color: var(--color-text); font-weight: 600; }
.article-preview img { max-width: 100%; border-radius: 2px; margin: 0.5em 0; }
/* Scrollbar — thin, minimal */
::-webkit-scrollbar {
width: 6px;

View File

@@ -61,6 +61,37 @@ export async function fetchGlobalFeed(limit: number = 50): Promise<NDKEvent[]> {
return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0));
}
export async function publishArticle(opts: {
title: string;
content: string;
summary?: string;
image?: string;
tags?: string[];
}): Promise<void> {
const instance = getNDK();
if (!instance.signer) throw new Error("Not logged in");
const slug = opts.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 60) + "-" + Date.now();
const event = new NDKEvent(instance);
event.kind = 30023;
event.content = opts.content;
event.tags = [
["d", slug],
["title", opts.title],
["published_at", String(Math.floor(Date.now() / 1000))],
];
if (opts.summary) event.tags.push(["summary", opts.summary]);
if (opts.image) event.tags.push(["image", opts.image]);
if (opts.tags) opts.tags.forEach((t) => event.tags.push(["t", t]));
await event.publish();
}
export async function publishReaction(eventId: string, eventPubkey: string, reaction = "+"): Promise<void> {
const instance = getNDK();
if (!instance.signer) throw new Error("Not logged in");

View File

@@ -1 +1 @@
export { getNDK, connectToRelays, fetchGlobalFeed, fetchFollowFeed, fetchReplies, publishNote, publishReaction, publishReply, fetchUserNotes, fetchProfile } from "./client";
export { getNDK, connectToRelays, fetchGlobalFeed, fetchFollowFeed, fetchReplies, publishNote, publishArticle, publishReaction, publishReply, fetchUserNotes, fetchProfile } from "./client";

View File

@@ -2,7 +2,7 @@ import { create } from "zustand";
import { NDKEvent } from "@nostr-dev-kit/ndk";
type View = "feed" | "relays" | "settings" | "profile" | "thread";
type View = "feed" | "relays" | "settings" | "profile" | "thread" | "article-editor";
interface UIState {
currentView: View;