Bump to v0.7.0 — writer tools, NIP-98 uploads, multi-draft, article bookmarks

- NIP-98 HTTP Auth for image uploads with fallback services (void.cat, nostrimg.com)
- Markdown toolbar (bold, italic, heading, link, image, quote, code, list) + Ctrl+B/I/K
- Multi-draft management with draft list, resume, delete, auto-migrate
- Cover image file picker in article meta panel
- Article bookmarks via NIP-51 'a' tags; Notes/Articles tabs in BookmarkView
- Removed Rust upload_file command; dropped reqwest/mime_guess deps
- Upload spinner, draft count badge, empty states
This commit is contained in:
Jure
2026-03-18 18:36:08 +01:00
parent c66885440a
commit 092553ab9b
19 changed files with 846 additions and 152 deletions

View File

@@ -1,45 +1,54 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } 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);
}
import { MarkdownToolbar, handleEditorKeyDown } from "./MarkdownToolbar";
import { useDraftStore, type ArticleDraft } from "../../stores/drafts";
import { open } from "@tauri-apps/plugin-dialog";
import { readFile } from "@tauri-apps/plugin-fs";
import { uploadBytes } from "../../lib/upload";
export function ArticleEditor() {
const { goBack } = useUIStore();
const draft = loadDraft();
const { activeDraftId, drafts, updateDraft, deleteDraft, setActiveDraft, createDraft } = useDraftStore();
const textareaRef = useRef<HTMLTextAreaElement>(null);
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 || "");
// If no active draft, show draft list
const activeDraft = activeDraftId ? drafts.find((d) => d.id === activeDraftId) : null;
const [title, setTitle] = useState(activeDraft?.title || "");
const [content, setContent] = useState(activeDraft?.content || "");
const [summary, setSummary] = useState(activeDraft?.summary || "");
const [image, setImage] = useState(activeDraft?.image || "");
const [tags, setTags] = useState(activeDraft?.tags || "");
const [mode, setMode] = useState<"write" | "preview">("write");
const [showMeta, setShowMeta] = useState(false);
const [publishing, setPublishing] = useState(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [published, setPublished] = useState(false);
// Auto-save draft
// Sync state when active draft changes
useEffect(() => {
if (activeDraft) {
setTitle(activeDraft.title);
setContent(activeDraft.content);
setSummary(activeDraft.summary);
setImage(activeDraft.image);
setTags(activeDraft.tags);
setPublished(false);
setError(null);
}
}, [activeDraftId]);
// Auto-save to draft store
useEffect(() => {
if (!activeDraftId) return;
const t = setTimeout(() => {
saveDraft({ title, content, summary, image, tags });
updateDraft(activeDraftId, { title, content, summary, image, tags });
}, 1000);
return () => clearTimeout(t);
}, [title, content, summary, image, tags]);
}, [title, content, summary, image, tags, activeDraftId]);
const renderedHtml = marked(content || "*Nothing to preview yet.*") as string;
const wordCount = content.trim() ? content.trim().split(/\s+/).length : 0;
@@ -57,7 +66,7 @@ export function ArticleEditor() {
image: image.trim() || undefined,
tags: tags.split(",").map((t: string) => t.trim()).filter(Boolean),
});
clearDraft();
if (activeDraftId) deleteDraft(activeDraftId);
setPublished(true);
setTimeout(goBack, 1500);
} catch (err) {
@@ -67,18 +76,61 @@ export function ArticleEditor() {
}
};
const handleNewDraft = () => {
const id = createDraft();
setActiveDraft(id);
};
const handleCoverImagePick = async () => {
try {
const selected = await open({
multiple: false,
filters: [{ name: "Images", extensions: ["jpg", "jpeg", "png", "gif", "webp"] }],
});
if (!selected) return;
setUploading(true);
setError(null);
try {
const filePath = typeof selected === "string" ? selected : selected;
const bytes = await readFile(filePath);
const fileName = filePath.split(/[\\/]/).pop() || "cover.jpg";
const ext = fileName.split(".").pop()?.toLowerCase() || "jpg";
const mimeMap: Record<string, string> = {
jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif", webp: "image/webp",
};
const url = await uploadBytes(new Uint8Array(bytes), fileName, mimeMap[ext] || "image/jpeg");
setImage(url);
} finally {
setUploading(false);
}
} catch (err) {
setError(`Cover upload failed: ${err}`);
}
};
// If no active draft, show the draft list
if (!activeDraftId) {
return <DraftListView onNewDraft={handleNewDraft} />;
}
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 onClick={() => setActiveDraft(null)} className="text-text-dim hover:text-text text-[11px] transition-colors">
drafts
</button>
<span className="text-text-dim text-[10px]">{wordCount > 0 ? `${wordCount} words` : "New article"}</span>
{draft && !published && (
{activeDraft && !published && (
<span className="text-text-dim text-[10px]">· draft saved</span>
)}
{uploading && (
<span className="inline-flex items-center gap-1 text-text-dim text-[10px]">
<span className="w-3 h-3 border border-accent border-t-transparent rounded-full animate-spin" />
uploading
</span>
)}
</div>
<div className="flex items-center gap-2">
@@ -130,13 +182,23 @@ export function ArticleEditor() {
</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"
/>
<label className="text-text-dim text-[10px] block mb-1">Cover image</label>
<div className="flex gap-1">
<input
value={image}
onChange={(e) => setImage(e.target.value)}
placeholder="https://…"
className="flex-1 bg-bg border border-border px-2 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent/50"
/>
<button
onClick={handleCoverImagePick}
disabled={uploading}
title="Upload cover image"
className="px-2 py-1.5 text-[11px] border border-border text-text-muted hover:text-text hover:bg-bg-hover transition-colors disabled:opacity-30"
>
{uploading ? "…" : "↑"}
</button>
</div>
</div>
<div>
<label className="text-text-dim text-[10px] block mb-1">Tags (comma-separated)</label>
@@ -169,12 +231,25 @@ export function ArticleEditor() {
/>
</div>
{/* Markdown toolbar */}
{mode === "write" && (
<MarkdownToolbar
textareaRef={textareaRef}
content={content}
setContent={setContent}
setUploading={setUploading}
setError={setError}
/>
)}
{/* Content area */}
<div className="flex-1 overflow-y-auto px-6 pb-6">
{mode === "write" ? (
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={(e) => handleEditorKeyDown(e, textareaRef, content, setContent)}
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"
/>
@@ -189,3 +264,71 @@ export function ArticleEditor() {
</div>
);
}
/** Draft list view — shown when no active draft is selected */
function DraftListView({ onNewDraft }: { onNewDraft: () => void }) {
const { goBack } = useUIStore();
const { drafts, deleteDraft, setActiveDraft } = useDraftStore();
return (
<div className="h-full flex flex-col">
<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>
<h2 className="text-text text-[13px] font-medium">Drafts</h2>
<span className="text-text-dim text-[11px]">{drafts.length} {drafts.length === 1 ? "draft" : "drafts"}</span>
</div>
<button
onClick={onNewDraft}
className="px-3 py-1 text-[11px] bg-accent hover:bg-accent-hover text-white transition-colors"
>
new draft
</button>
</header>
<div className="flex-1 overflow-y-auto">
{drafts.length === 0 && (
<div className="px-4 py-12 text-center space-y-2">
<p className="text-text-dim text-[13px]">No drafts yet.</p>
<p className="text-text-dim text-[11px] opacity-60">
Click "new draft" to start writing an article.
</p>
</div>
)}
{drafts.map((draft: ArticleDraft) => {
const wordCount = draft.content.trim() ? draft.content.trim().split(/\s+/).length : 0;
const updated = new Date(draft.updatedAt).toLocaleDateString(undefined, {
month: "short", day: "numeric", hour: "2-digit", minute: "2-digit",
});
return (
<div
key={draft.id}
className="border-b border-border px-4 py-3 hover:bg-bg-hover transition-colors cursor-pointer flex items-center justify-between"
onClick={() => setActiveDraft(draft.id)}
>
<div className="min-w-0 flex-1">
<h3 className="text-text text-[13px] font-medium truncate">
{draft.title || "Untitled"}
</h3>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-text-dim text-[11px]">{wordCount} words</span>
<span className="text-text-dim text-[10px]">{updated}</span>
</div>
</div>
<button
onClick={(e) => { e.stopPropagation(); deleteDraft(draft.id); }}
className="text-text-dim hover:text-danger text-[11px] transition-colors px-2"
title="Delete draft"
>
×
</button>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,218 @@
import { open } from "@tauri-apps/plugin-dialog";
import { readFile } from "@tauri-apps/plugin-fs";
import { uploadBytes } from "../../lib/upload";
type MarkdownAction = "bold" | "italic" | "heading" | "link" | "image" | "quote" | "code" | "list";
interface ToolbarProps {
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
content: string;
setContent: (value: string) => void;
setUploading?: (value: boolean) => void;
setError?: (value: string | null) => void;
}
function applyMarkdown(
textarea: HTMLTextAreaElement,
action: MarkdownAction,
content: string,
setContent: (value: string) => void,
insertText?: string,
) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selected = content.slice(start, end);
let before = "";
let after = "";
let replacement = "";
let cursorOffset = 0;
switch (action) {
case "bold":
before = "**";
after = "**";
replacement = selected || "bold text";
cursorOffset = selected ? 0 : 9; // select "bold text"
break;
case "italic":
before = "*";
after = "*";
replacement = selected || "italic text";
cursorOffset = selected ? 0 : 11;
break;
case "heading":
before = "## ";
after = "";
replacement = selected || "Heading";
break;
case "link":
if (selected) {
before = "[";
after = "](url)";
replacement = selected;
} else {
before = "[";
after = "](url)";
replacement = "link text";
}
break;
case "image":
if (insertText) {
before = "";
after = "";
replacement = insertText;
} else {
before = "![";
after = "](url)";
replacement = selected || "alt text";
}
break;
case "quote":
before = "> ";
after = "";
replacement = selected || "quote";
break;
case "code":
if (selected.includes("\n")) {
before = "```\n";
after = "\n```";
replacement = selected;
} else {
before = "`";
after = "`";
replacement = selected || "code";
}
break;
case "list":
if (selected) {
replacement = selected
.split("\n")
.map((line) => `- ${line}`)
.join("\n");
} else {
before = "- ";
after = "";
replacement = "item";
}
break;
}
const newContent =
content.slice(0, start) + before + replacement + after + content.slice(end);
setContent(newContent);
// Restore focus and selection
requestAnimationFrame(() => {
textarea.focus();
const newCursorPos = start + before.length + replacement.length + after.length;
if (!selected && cursorOffset === 0) {
// Select the placeholder text
textarea.selectionStart = start + before.length;
textarea.selectionEnd = start + before.length + replacement.length;
} else {
textarea.selectionStart = textarea.selectionEnd = newCursorPos;
}
});
}
const TOOLS: { action: MarkdownAction; label: string; title: string }[] = [
{ action: "bold", label: "B", title: "Bold (Ctrl+B)" },
{ action: "italic", label: "I", title: "Italic (Ctrl+I)" },
{ action: "heading", label: "H", title: "Heading" },
{ action: "link", label: "🔗", title: "Link (Ctrl+K)" },
{ action: "image", label: "🖼", title: "Image" },
{ action: "quote", label: "❝", title: "Quote" },
{ action: "code", label: "</>", title: "Code" },
{ action: "list", label: "☰", title: "List" },
];
export function MarkdownToolbar({ textareaRef, content, setContent, setUploading, setError }: ToolbarProps) {
const handleClick = (action: MarkdownAction) => {
if (action === "image") {
handleImageUpload();
return;
}
const textarea = textareaRef.current;
if (!textarea) return;
applyMarkdown(textarea, action, content, setContent);
};
const handleImageUpload = async () => {
try {
const selected = await open({
multiple: false,
filters: [
{ name: "Images", extensions: ["jpg", "jpeg", "png", "gif", "webp", "svg"] },
],
});
if (!selected) return;
const filePath = typeof selected === "string" ? selected : selected;
setUploading?.(true);
setError?.(null);
try {
const bytes = await readFile(filePath);
const fileName = filePath.split(/[\\/]/).pop() || "image.png";
const ext = fileName.split(".").pop()?.toLowerCase() || "png";
const mimeMap: Record<string, string> = {
jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif",
webp: "image/webp", svg: "image/svg+xml",
};
const url = await uploadBytes(new Uint8Array(bytes), fileName, mimeMap[ext] || "image/png");
const textarea = textareaRef.current;
if (textarea) {
applyMarkdown(textarea, "image", content, setContent, `![${fileName}](${url})`);
}
} finally {
setUploading?.(false);
}
} catch (err) {
setError?.(`Image upload failed: ${err}`);
}
};
return (
<div className="flex items-center gap-0.5 border-b border-border px-2 py-1 bg-bg-raised shrink-0">
{TOOLS.map(({ action, label, title }) => (
<button
key={action}
onClick={() => handleClick(action)}
title={title}
className="px-2 py-0.5 text-[12px] text-text-muted hover:text-text hover:bg-bg-hover transition-colors rounded-sm"
style={action === "bold" ? { fontWeight: "bold" } : action === "italic" ? { fontStyle: "italic" } : undefined}
>
{label}
</button>
))}
</div>
);
}
/** Keyboard shortcut handler for the article editor textarea */
export function handleEditorKeyDown(
e: React.KeyboardEvent<HTMLTextAreaElement>,
textareaRef: React.RefObject<HTMLTextAreaElement | null>,
content: string,
setContent: (value: string) => void,
): boolean {
if (!(e.ctrlKey || e.metaKey)) return false;
const textarea = textareaRef.current;
if (!textarea) return false;
switch (e.key.toLowerCase()) {
case "b":
e.preventDefault();
applyMarkdown(textarea, "bold", content, setContent);
return true;
case "i":
e.preventDefault();
applyMarkdown(textarea, "italic", content, setContent);
return true;
case "k":
e.preventDefault();
applyMarkdown(textarea, "link", content, setContent);
return true;
default:
return false;
}
}