mirror of
https://github.com/hoornet/vega.git
synced 2026-05-08 05:09:12 -07:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
218
src/components/article/MarkdownToolbar.tsx
Normal file
218
src/components/article/MarkdownToolbar.tsx
Normal 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 = "";
|
||||
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, ``);
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user