mirror of
https://github.com/hoornet/vega.git
synced 2026-04-24 06:40:01 -07:00
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:
21
package-lock.json
generated
21
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
191
src/components/article/ArticleEditor.tsx
Normal file
191
src/components/article/ArticleEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user