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

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

View File

@@ -1,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
src/stores/drafts.ts Normal file
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);
},
}));