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
+179 -36
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>
);
}
+218
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;
}
}
+71 -12
View File
@@ -2,15 +2,21 @@ import { useEffect, useState } from "react";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { useBookmarkStore } from "../../stores/bookmark";
import { useUserStore } from "../../stores/user";
import { fetchNoteById } from "../../lib/nostr";
import { fetchNoteById, fetchByAddr } from "../../lib/nostr";
import { NoteCard } from "../feed/NoteCard";
import { ArticleCard } from "../article/ArticleCard";
import { SkeletonNoteList } from "../shared/Skeleton";
type BookmarkTab = "notes" | "articles";
export function BookmarkView() {
const { bookmarkedIds, fetchBookmarks } = useBookmarkStore();
const { bookmarkedIds, bookmarkedArticleAddrs, fetchBookmarks } = useBookmarkStore();
const { pubkey } = useUserStore();
const [tab, setTab] = useState<BookmarkTab>("notes");
const [notes, setNotes] = useState<NDKEvent[]>([]);
const [loading, setLoading] = useState(false);
const [articles, setArticles] = useState<NDKEvent[]>([]);
const [loadingNotes, setLoadingNotes] = useState(false);
const [loadingArticles, setLoadingArticles] = useState(false);
useEffect(() => {
if (pubkey) fetchBookmarks(pubkey);
@@ -24,8 +30,16 @@ export function BookmarkView() {
loadNotes();
}, [bookmarkedIds]);
useEffect(() => {
if (bookmarkedArticleAddrs.length === 0) {
setArticles([]);
return;
}
loadArticles();
}, [bookmarkedArticleAddrs]);
const loadNotes = async () => {
setLoading(true);
setLoadingNotes(true);
try {
const results = await Promise.all(
bookmarkedIds.map((id) => fetchNoteById(id))
@@ -36,36 +50,81 @@ export function BookmarkView() {
.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))
);
} finally {
setLoading(false);
setLoadingNotes(false);
}
};
const loadArticles = async () => {
setLoadingArticles(true);
try {
const results = await Promise.all(
bookmarkedArticleAddrs.map((addr) => fetchByAddr(addr))
);
setArticles(
results
.filter((e): e is NDKEvent => e !== null)
.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))
);
} finally {
setLoadingArticles(false);
}
};
const totalCount = bookmarkedIds.length + bookmarkedArticleAddrs.length;
const loading = tab === "notes" ? loadingNotes : loadingArticles;
const items = tab === "notes" ? notes : articles;
return (
<div className="h-full flex flex-col">
<header className="border-b border-border px-4 py-2.5 shrink-0">
<div className="flex items-center justify-between">
<h2 className="text-text text-[13px] font-medium">Bookmarks</h2>
<span className="text-text-dim text-[11px]">{bookmarkedIds.length} saved</span>
<div className="flex items-center gap-3">
<h2 className="text-text text-[13px] font-medium">Bookmarks</h2>
<div className="flex border border-border text-[11px]">
<button
onClick={() => setTab("notes")}
className={`px-3 py-0.5 transition-colors ${tab === "notes" ? "bg-accent/10 text-accent" : "text-text-muted hover:text-text"}`}
>
Notes
</button>
<button
onClick={() => setTab("articles")}
className={`px-3 py-0.5 transition-colors ${tab === "articles" ? "bg-accent/10 text-accent" : "text-text-muted hover:text-text"}`}
>
Articles
</button>
</div>
</div>
<span className="text-text-dim text-[11px]">{totalCount} saved</span>
</div>
</header>
<div className="flex-1 overflow-y-auto">
{loading && notes.length === 0 && (
{loading && items.length === 0 && (
<SkeletonNoteList count={3} />
)}
{!loading && notes.length === 0 && (
{!loading && items.length === 0 && (
<div className="px-4 py-12 text-center space-y-2">
<p className="text-text-dim text-[13px]">No bookmarks yet.</p>
<p className="text-text-dim text-[13px]">
{tab === "notes" ? "No bookmarked notes." : "No bookmarked articles."}
</p>
<p className="text-text-dim text-[11px] opacity-60">
Use the <span className="text-accent">save</span> button on any note to bookmark it here.
{tab === "notes"
? <>Use the <span className="text-accent">save</span> button on any note to bookmark it here.</>
: <>Use the <span className="text-accent">save</span> button on any article to add it to your reading list.</>
}
</p>
</div>
)}
{notes.map((event) => (
{tab === "notes" && notes.map((event) => (
<NoteCard key={event.id} event={event} />
))}
{tab === "articles" && articles.map((event) => (
<ArticleCard key={event.id} event={event} />
))}
</div>
</div>
);
+19 -5
View File
@@ -1,11 +1,11 @@
import { useState, useRef, useEffect } from "react";
import { publishNote } from "../../lib/nostr";
import { uploadImage } from "../../lib/upload";
import { uploadImage, uploadBytes } from "../../lib/upload";
import { useUserStore } from "../../stores/user";
import { useFeedStore } from "../../stores/feed";
import { shortenPubkey } from "../../lib/utils";
import { open } from "@tauri-apps/plugin-dialog";
import { invoke } from "@tauri-apps/api/core";
import { readFile } from "@tauri-apps/plugin-fs";
const COMPOSE_DRAFT_KEY = "wrystr_compose_draft";
@@ -70,12 +70,21 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () =
}
};
// Upload a file by path using the Rust backend (bypasses WebView FormData issues)
// Upload a file by path using TS upload with NIP-98 auth
const handleNativeUpload = async (filePath: string) => {
setUploading(true);
setError(null);
try {
const url = await invoke<string>("upload_file", { path: filePath });
const bytes = await readFile(filePath);
const fileName = filePath.split(/[\\/]/).pop() || "file";
const ext = fileName.split(".").pop()?.toLowerCase() || "";
const mimeMap: Record<string, string> = {
jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif",
webp: "image/webp", svg: "image/svg+xml", mp4: "video/mp4", webm: "video/webm",
mov: "video/quicktime", ogg: "video/ogg", m4v: "video/mp4",
};
const mimeType = mimeMap[ext] || "application/octet-stream";
const url = await uploadBytes(new Uint8Array(bytes), fileName, mimeType);
insertUrl(url);
} catch (err) {
setError(`Upload failed: ${err}`);
@@ -217,7 +226,12 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () =
<div className="flex items-center justify-between mt-1">
<span className={`text-[10px] ${overLimit ? "text-danger" : "text-text-dim"}`}>
{uploading ? "uploading image…" : charCount > 0 ? `${charCount}/280` : ""}
{uploading ? (
<span className="inline-flex items-center gap-1">
<span className="w-3 h-3 border border-accent border-t-transparent rounded-full animate-spin" />
uploading
</span>
) : charCount > 0 ? `${charCount}/280` : ""}
{!uploading && charCount > 0 && localStorage.getItem(COMPOSE_DRAFT_KEY) && (
<span className="ml-1 text-text-dim">(draft)</span>
)}
+11 -1
View File
@@ -2,6 +2,7 @@ import { useUIStore } from "../../stores/ui";
import { useFeedStore } from "../../stores/feed";
import { useUserStore } from "../../stores/user";
import { useNotificationsStore } from "../../stores/notifications";
import { useDraftStore } from "../../stores/drafts";
import { getNDK } from "../../lib/nostr";
import { AccountSwitcher } from "./AccountSwitcher";
import pkg from "../../../package.json";
@@ -24,6 +25,7 @@ export function Sidebar() {
const { connected } = useFeedStore();
const { loggedIn } = useUserStore();
const { unreadCount: notifUnread, dmUnreadCount } = useNotificationsStore();
const draftCount = useDraftStore((s) => s.drafts.length);
const c = sidebarCollapsed;
@@ -75,8 +77,16 @@ export function Sidebar() {
: "text-text-muted hover:text-text hover:bg-bg-hover"
}`}
>
<span className="w-4 text-center text-[14px]"></span>
<span className="relative w-4 text-center text-[14px]">
{c && draftCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 rounded-full bg-accent" />
)}
</span>
{!c && <span>write article</span>}
{!c && draftCount > 0 && (
<span className="ml-auto text-[10px] bg-accent/20 text-accent px-1 rounded-sm">{draftCount}</span>
)}
</button>
)}
+46
View File
@@ -616,6 +616,52 @@ export async function publishBookmarkList(eventIds: string[]): Promise<void> {
await event.publish();
}
export async function fetchBookmarkListFull(pubkey: string): Promise<{ eventIds: string[]; articleAddrs: string[] }> {
const instance = getNDK();
const filter: NDKFilter = { kinds: [10003 as NDKKind], authors: [pubkey], limit: 1 };
const events = await instance.fetchEvents(filter, {
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
});
if (events.size === 0) return { eventIds: [], articleAddrs: [] };
const event = Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))[0];
const eventIds = event.tags.filter((t) => t[0] === "e" && t[1]).map((t) => t[1]);
const articleAddrs = event.tags.filter((t) => t[0] === "a" && t[1]).map((t) => t[1]);
return { eventIds, articleAddrs };
}
export async function publishBookmarkListFull(eventIds: string[], articleAddrs: string[]): Promise<void> {
const instance = getNDK();
if (!instance.signer) return;
const event = new NDKEvent(instance);
event.kind = 10003 as NDKKind;
event.content = "";
event.tags = [
...eventIds.map((id) => ["e", id]),
...articleAddrs.map((addr) => ["a", addr]),
];
await event.publish();
}
export async function fetchByAddr(addr: string): Promise<NDKEvent | null> {
const instance = getNDK();
// addr format: "30023:<pubkey>:<d-tag>"
const parts = addr.split(":");
if (parts.length < 3) return null;
const kind = parseInt(parts[0]);
const pubkey = parts[1];
const dTag = parts.slice(2).join(":");
const filter: NDKFilter = {
kinds: [kind as NDKKind],
authors: [pubkey],
"#d": [dTag],
limit: 1,
};
const events = await instance.fetchEvents(filter, {
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
});
return Array.from(events)[0] ?? null;
}
export async function fetchMuteList(pubkey: string): Promise<string[]> {
const instance = getNDK();
const filter: NDKFilter = { kinds: [10000 as NDKKind], authors: [pubkey], limit: 1 };
+1 -1
View File
@@ -1,2 +1,2 @@
export { getNDK, connectToRelays, fetchGlobalFeed, fetchFollowFeed, fetchReplies, publishNote, publishArticle, publishProfile, publishReaction, publishRepost, publishQuote, publishReply, publishContactList, fetchBatchEngagement, fetchReactionCount, fetchReplyCount, fetchZapCount, fetchNoteById, fetchUserNotes, fetchProfile, fetchArticle, fetchAuthorArticles, fetchArticleFeed, searchArticles, fetchZapsReceived, fetchZapsSent, fetchDMConversations, fetchDMThread, sendDM, decryptDM, fetchBookmarkList, publishBookmarkList, fetchMuteList, publishMuteList, getStoredRelayUrls, addRelay, removeRelay, searchNotes, searchUsers, fetchUserRelayList, publishRelayList, fetchUserNotesNIP65, fetchFollowSuggestions, fetchMentions } from "./client";
export { getNDK, connectToRelays, fetchGlobalFeed, fetchFollowFeed, fetchReplies, publishNote, publishArticle, publishProfile, publishReaction, publishRepost, publishQuote, publishReply, publishContactList, fetchBatchEngagement, fetchReactionCount, fetchReplyCount, fetchZapCount, fetchNoteById, fetchUserNotes, fetchProfile, fetchArticle, fetchAuthorArticles, fetchArticleFeed, searchArticles, fetchZapsReceived, fetchZapsSent, fetchDMConversations, fetchDMThread, sendDM, decryptDM, fetchBookmarkList, publishBookmarkList, fetchBookmarkListFull, publishBookmarkListFull, fetchByAddr, fetchMuteList, publishMuteList, getStoredRelayUrls, addRelay, removeRelay, searchNotes, searchUsers, fetchUserRelayList, publishRelayList, fetchUserNotesNIP65, fetchFollowSuggestions, fetchMentions } from "./client";
export type { UserRelayList } from "./client";
+72 -16
View File
@@ -1,4 +1,33 @@
import { fetch } from "@tauri-apps/plugin-http";
import { getNDK } from "./nostr";
import { NDKEvent } from "@nostr-dev-kit/ndk";
const UPLOAD_SERVICES = [
"https://nostr.build/api/v2/upload/files",
"https://void.cat/upload",
"https://nostrimg.com/api/upload",
];
/**
* Create a NIP-98 HTTP Auth event (kind 27235) for a given URL and method.
* Returns a base64-encoded signed event for the Authorization header.
*/
async function createNip98AuthHeader(url: string, method: string): Promise<string> {
const ndk = getNDK();
if (!ndk.signer) throw new Error("Not logged in — cannot sign NIP-98 auth");
const event = new NDKEvent(ndk);
event.kind = 27235;
event.created_at = Math.floor(Date.now() / 1000);
event.content = "";
event.tags = [
["u", url],
["method", method.toUpperCase()],
];
await event.sign();
const encoded = btoa(JSON.stringify(event.rawEvent()));
return `Nostr ${encoded}`;
}
/**
* Upload an image file to nostr.build and return the hosted URL.
@@ -9,33 +38,60 @@ import { fetch } from "@tauri-apps/plugin-http";
* and build a proper Blob with the correct MIME type.
*/
export async function uploadImage(file: File): Promise<string> {
// Read file bytes — ensures clipboard-pasted images are properly serialized
const bytes = new Uint8Array(await file.arrayBuffer());
return uploadBytes(bytes, file.name || "image.png", file.type || "image/png");
}
/**
* Upload raw bytes to nostr.build. Used by the native file picker path
* where we already have a Uint8Array from tauri-plugin-fs.
* Upload raw bytes with NIP-98 auth. Tries nostr.build first, then fallbacks.
*/
export async function uploadBytes(bytes: Uint8Array, fileName: string, mimeType: string): Promise<string> {
const blob = new Blob([bytes], { type: mimeType });
const errors: string[] = [];
const form = new FormData();
form.append("file", blob, fileName);
for (const serviceUrl of UPLOAD_SERVICES) {
try {
const form = new FormData();
form.append("file", blob, fileName);
const resp = await fetch("https://nostr.build/api/v2/upload/files", {
method: "POST",
body: form,
});
const headers: Record<string, string> = {};
try {
headers["Authorization"] = await createNip98AuthHeader(serviceUrl, "POST");
} catch {
// If not logged in, try without auth (some services allow anonymous)
}
if (!resp.ok) {
throw new Error(`Upload failed (HTTP ${resp.status})`);
const resp = await fetch(serviceUrl, {
method: "POST",
body: form,
headers,
});
if (!resp.ok) {
errors.push(`${serviceUrl}: HTTP ${resp.status}`);
continue;
}
const data = await resp.json();
// nostr.build response format
if (data.status === "success" && data.data?.[0]?.url) {
return data.data[0].url as string;
}
// void.cat response format
if (data.file?.url) {
return data.file.url as string;
}
// nostrimg.com response format
if (data.url) {
return data.url as string;
}
errors.push(`${serviceUrl}: no URL in response`);
} catch (err) {
errors.push(`${serviceUrl}: ${err}`);
}
}
const data = await resp.json();
if (data.status === "success" && data.data?.[0]?.url) {
return data.data[0].url as string;
}
throw new Error(data.message || "Upload failed — no URL returned");
throw new Error(`All upload services failed:\n${errors.join("\n")}`);
}
+61 -11
View File
@@ -1,7 +1,8 @@
import { create } from "zustand";
import { fetchBookmarkList, publishBookmarkList } from "../lib/nostr";
import { fetchBookmarkList, fetchBookmarkListFull, publishBookmarkListFull } from "../lib/nostr";
const STORAGE_KEY = "wrystr_bookmarks";
const ARTICLE_STORAGE_KEY = "wrystr_bookmarks_articles";
function loadLocal(): string[] {
try {
@@ -15,47 +16,96 @@ function saveLocal(ids: string[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(ids));
}
function loadArticleAddrs(): string[] {
try {
return JSON.parse(localStorage.getItem(ARTICLE_STORAGE_KEY) ?? "[]");
} catch {
return [];
}
}
function saveArticleAddrs(addrs: string[]) {
localStorage.setItem(ARTICLE_STORAGE_KEY, JSON.stringify(addrs));
}
interface BookmarkState {
bookmarkedIds: string[];
bookmarkedArticleAddrs: string[]; // "30023:<pubkey>:<d-tag>" format
fetchBookmarks: (pubkey: string) => Promise<void>;
addBookmark: (eventId: string) => Promise<void>;
removeBookmark: (eventId: string) => Promise<void>;
isBookmarked: (eventId: string) => boolean;
addArticleBookmark: (addr: string) => Promise<void>;
removeArticleBookmark: (addr: string) => Promise<void>;
isArticleBookmarked: (addr: string) => boolean;
}
export const useBookmarkStore = create<BookmarkState>((set, get) => ({
bookmarkedIds: loadLocal(),
bookmarkedArticleAddrs: loadArticleAddrs(),
fetchBookmarks: async (pubkey: string) => {
try {
const ids = await fetchBookmarkList(pubkey);
if (ids.length === 0) return;
const local = get().bookmarkedIds;
const merged = Array.from(new Set([...ids, ...local]));
set({ bookmarkedIds: merged });
saveLocal(merged);
const { eventIds, articleAddrs } = await fetchBookmarkListFull(pubkey);
const localIds = get().bookmarkedIds;
const localAddrs = get().bookmarkedArticleAddrs;
const mergedIds = Array.from(new Set([...eventIds, ...localIds]));
const mergedAddrs = Array.from(new Set([...articleAddrs, ...localAddrs]));
set({ bookmarkedIds: mergedIds, bookmarkedArticleAddrs: mergedAddrs });
saveLocal(mergedIds);
saveArticleAddrs(mergedAddrs);
} catch {
// Non-critical — local bookmarks still work
// Fallback to old format
try {
const ids = await fetchBookmarkList(pubkey);
if (ids.length === 0) return;
const local = get().bookmarkedIds;
const merged = Array.from(new Set([...ids, ...local]));
set({ bookmarkedIds: merged });
saveLocal(merged);
} catch { /* ignore */ }
}
},
addBookmark: async (eventId: string) => {
const { bookmarkedIds } = get();
const { bookmarkedIds, bookmarkedArticleAddrs } = get();
if (bookmarkedIds.includes(eventId)) return;
const updated = [...bookmarkedIds, eventId];
set({ bookmarkedIds: updated });
saveLocal(updated);
publishBookmarkList(updated).catch(() => {});
publishBookmarkListFull(updated, bookmarkedArticleAddrs).catch(() => {});
},
removeBookmark: async (eventId: string) => {
const { bookmarkedArticleAddrs } = get();
const updated = get().bookmarkedIds.filter((id) => id !== eventId);
set({ bookmarkedIds: updated });
saveLocal(updated);
publishBookmarkList(updated).catch(() => {});
publishBookmarkListFull(updated, bookmarkedArticleAddrs).catch(() => {});
},
isBookmarked: (eventId: string) => {
return get().bookmarkedIds.includes(eventId);
},
addArticleBookmark: async (addr: string) => {
const { bookmarkedIds, bookmarkedArticleAddrs } = get();
if (bookmarkedArticleAddrs.includes(addr)) return;
const updated = [...bookmarkedArticleAddrs, addr];
set({ bookmarkedArticleAddrs: updated });
saveArticleAddrs(updated);
publishBookmarkListFull(bookmarkedIds, updated).catch(() => {});
},
removeArticleBookmark: async (addr: string) => {
const { bookmarkedIds } = get();
const updated = get().bookmarkedArticleAddrs.filter((a) => a !== addr);
set({ bookmarkedArticleAddrs: updated });
saveArticleAddrs(updated);
publishBookmarkListFull(bookmarkedIds, updated).catch(() => {});
},
isArticleBookmarked: (addr: string) => {
return get().bookmarkedArticleAddrs.includes(addr);
},
}));
+122
View File
@@ -0,0 +1,122 @@
import { create } from "zustand";
const STORAGE_KEY = "wrystr_article_drafts";
const ACTIVE_KEY = "wrystr_active_draft";
const OLD_DRAFT_KEY = "wrystr_article_draft";
export interface ArticleDraft {
id: string;
title: string;
content: string;
summary: string;
image: string;
tags: string;
createdAt: number;
updatedAt: number;
}
function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}
function loadDrafts(): ArticleDraft[] {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) return JSON.parse(stored);
} catch { /* ignore */ }
// Auto-migrate old single-draft format
try {
const old = localStorage.getItem(OLD_DRAFT_KEY);
if (old) {
const data = JSON.parse(old);
if (data && (data.title || data.content)) {
const migrated: ArticleDraft = {
id: generateId(),
title: data.title || "",
content: data.content || "",
summary: data.summary || "",
image: data.image || "",
tags: data.tags || "",
createdAt: Date.now(),
updatedAt: Date.now(),
};
localStorage.setItem(STORAGE_KEY, JSON.stringify([migrated]));
localStorage.removeItem(OLD_DRAFT_KEY);
return [migrated];
}
}
} catch { /* ignore */ }
return [];
}
function saveDrafts(drafts: ArticleDraft[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(drafts));
}
function loadActiveDraftId(): string | null {
return localStorage.getItem(ACTIVE_KEY) || null;
}
function saveActiveDraftId(id: string | null) {
if (id) {
localStorage.setItem(ACTIVE_KEY, id);
} else {
localStorage.removeItem(ACTIVE_KEY);
}
}
interface DraftState {
drafts: ArticleDraft[];
activeDraftId: string | null;
createDraft: () => string;
updateDraft: (id: string, fields: Partial<Pick<ArticleDraft, "title" | "content" | "summary" | "image" | "tags">>) => void;
deleteDraft: (id: string) => void;
setActiveDraft: (id: string | null) => void;
}
export const useDraftStore = create<DraftState>((set, get) => ({
drafts: loadDrafts(),
activeDraftId: loadActiveDraftId(),
createDraft: () => {
const id = generateId();
const draft: ArticleDraft = {
id,
title: "",
content: "",
summary: "",
image: "",
tags: "",
createdAt: Date.now(),
updatedAt: Date.now(),
};
const updated = [draft, ...get().drafts];
set({ drafts: updated, activeDraftId: id });
saveDrafts(updated);
saveActiveDraftId(id);
return id;
},
updateDraft: (id, fields) => {
const updated = get().drafts.map((d) =>
d.id === id ? { ...d, ...fields, updatedAt: Date.now() } : d
);
set({ drafts: updated });
saveDrafts(updated);
},
deleteDraft: (id) => {
const updated = get().drafts.filter((d) => d.id !== id);
const activeId = get().activeDraftId === id ? null : get().activeDraftId;
set({ drafts: updated, activeDraftId: activeId });
saveDrafts(updated);
saveActiveDraftId(activeId);
},
setActiveDraft: (id) => {
set({ activeDraftId: id });
saveActiveDraftId(id);
},
}));