mirror of
https://github.com/hoornet/vega.git
synced 2026-07-01 22:39:02 -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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);
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user