mirror of
https://github.com/hoornet/vega.git
synced 2026-04-24 06:40:01 -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:
18
.github/workflows/release.yml
vendored
18
.github/workflows/release.yml
vendored
@@ -69,12 +69,18 @@ jobs:
|
|||||||
|
|
||||||
> **Windows note:** The installer is not yet code-signed. Windows SmartScreen will show an "Unknown publisher" warning — click "More info → Run anyway" to install.
|
> **Windows note:** The installer is not yet code-signed. Windows SmartScreen will show an "Unknown publisher" warning — click "More info → Run anyway" to install.
|
||||||
|
|
||||||
### New in v0.6.1 — Media Upload & Feed Polish
|
### New in v0.7.0 — Writer Tools & Upload Fix
|
||||||
- **Native file picker** — "+" button in compose box opens OS file browser to attach images/videos; uploaded via Rust backend (bypasses WebView limitations)
|
- **NIP-98 HTTP Auth uploads** — image uploads now authenticate via signed kind 27235 events; fallback to void.cat and nostrimg.com if nostr.build fails
|
||||||
- **File path paste** — pasting a local file path (e.g. `/home/user/photo.jpg`) auto-reads and uploads the file instead of inserting the path as text
|
- **Markdown toolbar** — bold, italic, heading, link, image, quote, code, list buttons above the article editor; keyboard shortcuts Ctrl+B/I/K
|
||||||
- **Mention name resolution** — @mentions now show profile display names instead of raw bech32 strings
|
- **Multi-draft management** — create multiple article drafts; draft list with word count, timestamps, delete; auto-migrates old single-draft
|
||||||
- **Connection stability** — relay status indicator uses a 15-second grace period before showing offline; auto-reconnects when all relays drop
|
- **Cover image file picker** — upload button next to URL input in article meta panel
|
||||||
- **Upload reliability** — image uploads use correct nostr.build v2 API field name; Rust-side multipart upload for native file picks
|
- **Article bookmarks** — NIP-51 `a` tag support; Notes/Articles tab toggle in bookmark view
|
||||||
|
- **Upload moved to TypeScript** — removed Rust upload command; dropped `reqwest`/`mime_guess` deps; lighter binary
|
||||||
|
- **Upload spinner** — animated spinner during image uploads in compose and editor
|
||||||
|
- **Draft count badge** — sidebar shows how many drafts you have
|
||||||
|
|
||||||
|
### Previous: v0.6.1 — Media Upload & Feed Polish
|
||||||
|
- Native file picker, file path paste upload, mention name resolution, connection stability
|
||||||
|
|
||||||
### Previous: v0.6.0 — Long-form Article Experience
|
### Previous: v0.6.0 — Long-form Article Experience
|
||||||
- **Article discovery feed** — dedicated "Articles" view in sidebar with Latest and Following tabs; browse kind 30023 articles from all relays or just followed authors
|
- **Article discovery feed** — dedicated "Articles" view in sidebar with Latest and Following tabs; browse kind 30023 articles from all relays or just followed authors
|
||||||
|
|||||||
12
CLAUDE.md
12
CLAUDE.md
@@ -45,7 +45,7 @@ CI triggers on the tag and builds all three platforms (Ubuntu, Windows, macOS AR
|
|||||||
**Frontend** (`src/`): React 19 + TypeScript + Vite + Tailwind CSS 4
|
**Frontend** (`src/`): React 19 + TypeScript + Vite + Tailwind CSS 4
|
||||||
|
|
||||||
- `src/App.tsx` — root component; shows `OnboardingFlow` for new users, then view routing via UI store
|
- `src/App.tsx` — root component; shows `OnboardingFlow` for new users, then view routing via UI store
|
||||||
- `src/stores/` — Zustand stores per domain: `feed.ts`, `user.ts`, `ui.ts`, `lightning.ts`
|
- `src/stores/` — Zustand stores per domain: `feed.ts`, `user.ts`, `ui.ts`, `lightning.ts`, `drafts.ts`
|
||||||
- `src/lib/nostr/` — NDK wrapper (`client.ts` + `index.ts`); all Nostr calls go through here
|
- `src/lib/nostr/` — NDK wrapper (`client.ts` + `index.ts`); all Nostr calls go through here
|
||||||
- `src/lib/lightning/` — NWC client (`nwc.ts`); Lightning payment logic
|
- `src/lib/lightning/` — NWC client (`nwc.ts`); Lightning payment logic
|
||||||
- `src/hooks/` — `useProfile.ts`, `useReactionCount.ts`
|
- `src/hooks/` — `useProfile.ts`, `useReactionCount.ts`
|
||||||
@@ -53,7 +53,7 @@ CI triggers on the tag and builds all three platforms (Ubuntu, Windows, macOS AR
|
|||||||
- `src/components/profile/` — ProfileView (own + others, edit form)
|
- `src/components/profile/` — ProfileView (own + others, edit form)
|
||||||
- `src/components/thread/` — ThreadView
|
- `src/components/thread/` — ThreadView
|
||||||
- `src/components/search/` — SearchView (NIP-50, hashtag, people, articles)
|
- `src/components/search/` — SearchView (NIP-50, hashtag, people, articles)
|
||||||
- `src/components/article/` — ArticleEditor, ArticleView, ArticleFeed, ArticleCard (NIP-23)
|
- `src/components/article/` — ArticleEditor, ArticleView, ArticleFeed, ArticleCard, MarkdownToolbar (NIP-23)
|
||||||
- `src/components/bookmark/` — BookmarkView
|
- `src/components/bookmark/` — BookmarkView
|
||||||
- `src/components/zap/` — ZapModal
|
- `src/components/zap/` — ZapModal
|
||||||
- `src/components/onboarding/` — OnboardingFlow (welcome, create key, backup, login)
|
- `src/components/onboarding/` — OnboardingFlow (welcome, create key, backup, login)
|
||||||
@@ -66,6 +66,7 @@ CI triggers on the tag and builds all three platforms (Ubuntu, Windows, macOS AR
|
|||||||
- Rust commands must return `Result<T, String>`
|
- Rust commands must return `Result<T, String>`
|
||||||
- OS keychain via `keyring` crate — `store_nsec`, `load_nsec`, `delete_nsec` commands
|
- OS keychain via `keyring` crate — `store_nsec`, `load_nsec`, `delete_nsec` commands
|
||||||
- SQLite note/profile cache via `rusqlite`
|
- SQLite note/profile cache via `rusqlite`
|
||||||
|
- File uploads handled entirely in TypeScript with NIP-98 auth (Rust upload_file removed in v0.7.0)
|
||||||
- Future: lightning node integration
|
- Future: lightning node integration
|
||||||
|
|
||||||
## Key Conventions (from AGENTS.md)
|
## Key Conventions (from AGENTS.md)
|
||||||
@@ -82,7 +83,7 @@ CI triggers on the tag and builds all three platforms (Ubuntu, Windows, macOS AR
|
|||||||
|
|
||||||
- **P1 (core):** NIP-01, 02, 03, 10, 11, 19, 21, 25, 27, 50
|
- **P1 (core):** NIP-01, 02, 03, 10, 11, 19, 21, 25, 27, 50
|
||||||
- **P2 (monetization):** NIP-47 (NWC/Lightning), NIP-57 (zaps), NIP-65 (relay lists)
|
- **P2 (monetization):** NIP-47 (NWC/Lightning), NIP-57 (zaps), NIP-65 (relay lists)
|
||||||
- **P3 (advanced):** NIP-04/44 (DMs), NIP-23 (articles), NIP-96 (file storage)
|
- **P3 (advanced):** NIP-04/44 (DMs), NIP-23 (articles), NIP-96 (file storage), NIP-98 (HTTP Auth — implemented for uploads)
|
||||||
|
|
||||||
## Current State
|
## Current State
|
||||||
|
|
||||||
@@ -92,11 +93,12 @@ CI triggers on the tag and builds all three platforms (Ubuntu, Windows, macOS AR
|
|||||||
- Reactions (NIP-25) with live network counts
|
- Reactions (NIP-25) with live network counts
|
||||||
- Follow/unfollow (NIP-02), contact list publishing
|
- Follow/unfollow (NIP-02), contact list publishing
|
||||||
- Profile view + edit (kind 0) with Notes/Articles tab toggle
|
- Profile view + edit (kind 0) with Notes/Articles tab toggle
|
||||||
- Long-form article editor (NIP-23) with draft auto-save
|
- Long-form article editor (NIP-23) with **markdown toolbar** (bold, italic, heading, link, image, quote, code, list), **keyboard shortcuts** (Ctrl+B/I/K), **multi-draft management**, **cover image file picker**
|
||||||
- **Article discovery feed** — dedicated "Articles" view in sidebar; Latest/Following tabs
|
- **Article discovery feed** — dedicated "Articles" view in sidebar; Latest/Following tabs
|
||||||
- **Article reader** — markdown rendering, reading time, bookmark, like, zap
|
- **Article reader** — markdown rendering, reading time, bookmark, like, zap
|
||||||
- **Article search** — NIP-50 + hashtag search for kind 30023 articles
|
- **Article search** — NIP-50 + hashtag search for kind 30023 articles
|
||||||
- **Article cards** — reusable component with title, summary, author, cover thumbnail, reading time, tags
|
- **Article cards** — reusable component with title, summary, author, cover thumbnail, reading time, tags
|
||||||
|
- **NIP-98 HTTP Auth** for image uploads with fallback services (nostr.build, void.cat, nostrimg.com)
|
||||||
- Zaps: NWC wallet connect (NIP-47) + NIP-57 via NDKZapper
|
- Zaps: NWC wallet connect (NIP-47) + NIP-57 via NDKZapper
|
||||||
- Search: NIP-50 full-text, hashtag (#t filter), people, articles
|
- Search: NIP-50 full-text, hashtag (#t filter), people, articles
|
||||||
- Settings: relay add/remove (persisted to localStorage), NWC URI, npub copy
|
- Settings: relay add/remove (persisted to localStorage), NWC URI, npub copy
|
||||||
@@ -106,7 +108,7 @@ CI triggers on the tag and builds all three platforms (Ubuntu, Windows, macOS AR
|
|||||||
- Direct messages (NIP-04 + NIP-17 gift wrap)
|
- Direct messages (NIP-04 + NIP-17 gift wrap)
|
||||||
- NIP-65 outbox model
|
- NIP-65 outbox model
|
||||||
- Image lightbox (click to expand, arrow key navigation)
|
- Image lightbox (click to expand, arrow key navigation)
|
||||||
- Bookmark list (NIP-51 kind 10003) with sidebar nav
|
- Bookmark list (NIP-51 kind 10003) with sidebar nav, **Notes/Articles tabs**, article `a` tag support
|
||||||
- Follow suggestions / discovery (follows-of-follows algorithm)
|
- Follow suggestions / discovery (follows-of-follows algorithm)
|
||||||
- Language/script feed filter (Unicode script detection + NIP-32 tags)
|
- Language/script feed filter (Unicode script detection + NIP-32 tags)
|
||||||
- Skeleton loading states, view fade transitions
|
- Skeleton loading states, view fade transitions
|
||||||
|
|||||||
2
PKGBUILD
2
PKGBUILD
@@ -1,6 +1,6 @@
|
|||||||
# Maintainer: hoornet <hoornet@users.noreply.github.com>
|
# Maintainer: hoornet <hoornet@users.noreply.github.com>
|
||||||
pkgname=wrystr
|
pkgname=wrystr
|
||||||
pkgver=0.6.1
|
pkgver=0.7.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Cross-platform Nostr desktop client with Lightning integration"
|
pkgdesc="Cross-platform Nostr desktop client with Lightning integration"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||
@@ -44,15 +44,15 @@ sudo dnf install gstreamer1-plugins-base gstreamer1-plugins-good gstreamer1-liba
|
|||||||
- Global and following feeds with live relay connection
|
- Global and following feeds with live relay connection
|
||||||
- **Language/script feed filter** — filter by writing system (Latin, CJK, Cyrillic, Arabic, Korean, etc.) via dropdown in feed header; uses Unicode detection + NIP-32 language tags
|
- **Language/script feed filter** — filter by writing system (Latin, CJK, Cyrillic, Arabic, Korean, etc.) via dropdown in feed header; uses Unicode detection + NIP-32 language tags
|
||||||
- Compose notes, inline replies, full thread view
|
- Compose notes, inline replies, full thread view
|
||||||
- **Image paste in compose** — paste an image from clipboard → auto-uploads and inserts the URL
|
- **Image upload with NIP-98 auth** — paste from clipboard, drag-drop, or use the file picker; uploads authenticated via NIP-98 HTTP Auth with fallback services
|
||||||
- **Image lightbox** — click any image to view full-screen; Escape to close, arrow keys to navigate multi-image posts
|
- **Image lightbox** — click any image to view full-screen; Escape to close, arrow keys to navigate multi-image posts
|
||||||
- **Feed reply context** — replies show "↩ replying to @name"; click to jump to the parent thread
|
- **Feed reply context** — replies show "↩ replying to @name"; click to jump to the parent thread
|
||||||
- Reactions (NIP-25) with live network counts
|
- Reactions (NIP-25) with live network counts
|
||||||
- Follow / unfollow (NIP-02) with contact list publishing
|
- Follow / unfollow (NIP-02) with contact list publishing
|
||||||
- **Quote & Repost** (NIP-18) — one-click repost or quote with compose modal
|
- **Quote & Repost** (NIP-18) — one-click repost or quote with compose modal
|
||||||
- **Bookmarks** (NIP-51 kind 10003) — save/unsave notes with one click; dedicated Bookmarks view in sidebar; synced to relays
|
- **Bookmarks** (NIP-51 kind 10003) — save/unsave notes and articles; **Notes/Articles tabs** in bookmark view; article bookmarks use `a` tags for parameterized replaceable events; synced to relays
|
||||||
- **Mute users** (NIP-51) — muted list synced to relays, filtered from feed
|
- **Mute users** (NIP-51) — muted list synced to relays, filtered from feed
|
||||||
- **Long-form article experience** (NIP-23) — write articles with title, tags, cover image, auto-save; **dedicated article feed** with Latest/Following tabs; **article search** by keyword or hashtag; **article reader** with reading time, bookmark, like, and zap; **profile Articles tab** to browse any author's long-form posts
|
- **Long-form article experience** (NIP-23) — **markdown toolbar** (bold, italic, heading, link, image, quote, code, list) with keyboard shortcuts (Ctrl+B/I/K); **multi-draft management** with draft list, resume, delete; **cover image file picker**; dedicated article feed with Latest/Following tabs; article search by keyword or hashtag; article reader with reading time, bookmark, like, and zap; profile Articles tab
|
||||||
- **Quoted note inline preview** — `nostr:note1…` / `nostr:nevent1…` renders as an inline card
|
- **Quoted note inline preview** — `nostr:note1…` / `nostr:nevent1…` renders as an inline card
|
||||||
- Note rendering: images, video, mentions, hashtags, njump.me link interception
|
- Note rendering: images, video, mentions, hashtags, njump.me link interception
|
||||||
- **Direct Messages** (NIP-04) — conversation list, thread view, per-message decryption; unread badge in sidebar
|
- **Direct Messages** (NIP-04) — conversation list, thread view, per-message decryption; unread badge in sidebar
|
||||||
@@ -109,9 +109,9 @@ npm run tauri build # production binary
|
|||||||
See [ROADMAP.md](./ROADMAP.md) for the full prioritised next steps.
|
See [ROADMAP.md](./ROADMAP.md) for the full prioritised next steps.
|
||||||
|
|
||||||
Up next:
|
Up next:
|
||||||
|
- Relay health checker
|
||||||
- Web of Trust scoring
|
- Web of Trust scoring
|
||||||
- NIP-46 remote signer support
|
- NIP-46 remote signer support
|
||||||
- Reading history / reading list
|
|
||||||
- Custom feeds / lists
|
- Custom feeds / lists
|
||||||
|
|
||||||
## Support
|
## Support
|
||||||
|
|||||||
22
ROADMAP.md
22
ROADMAP.md
@@ -66,13 +66,17 @@ Bugs found during testing are fixed before Phase N+1 starts. A release is cut be
|
|||||||
- Could power: feed ranking, spam filtering, people search, follow suggestions
|
- Could power: feed ranking, spam filtering, people search, follow suggestions
|
||||||
- Needs dedicated design session
|
- Needs dedicated design session
|
||||||
|
|
||||||
### Long-form features (NIP-23 depth) — partially shipped in v0.6.0
|
### Long-form features (NIP-23 depth) — mostly shipped (v0.6.0 + v0.7.0)
|
||||||
- ✓ Discovery: dedicated article feed with Latest/Following tabs
|
- ✓ Discovery: dedicated article feed with Latest/Following tabs
|
||||||
- ✓ Article search (NIP-50 + hashtag for kind 30023)
|
- ✓ Article search (NIP-50 + hashtag for kind 30023)
|
||||||
- ✓ Profile Articles tab — browse any author's long-form posts
|
- ✓ Profile Articles tab — browse any author's long-form posts
|
||||||
- ✓ Reading time estimate, bookmark/like/zap on article reader
|
- ✓ Reading time estimate, bookmark/like/zap on article reader
|
||||||
- Remaining: reading history, table of contents, trending articles
|
- ✓ Markdown toolbar with keyboard shortcuts (Ctrl+B/I/K)
|
||||||
- Editor improvements: markdown toolbar, image upload, tag suggestions
|
- ✓ NIP-98 image upload with fallback services
|
||||||
|
- ✓ Multi-draft management (create, resume, delete)
|
||||||
|
- ✓ Cover image file picker upload
|
||||||
|
- ✓ Article bookmarks (NIP-51 `a` tags) with Notes/Articles tabs
|
||||||
|
- Remaining: reading history, table of contents, trending articles, tag suggestions
|
||||||
- Cross-posting to other platforms
|
- Cross-posting to other platforms
|
||||||
|
|
||||||
### NIP-46 remote signer
|
### NIP-46 remote signer
|
||||||
@@ -88,6 +92,18 @@ Bugs found during testing are fixed before Phase N+1 starts. A release is cut be
|
|||||||
|
|
||||||
## What's already shipped
|
## What's already shipped
|
||||||
|
|
||||||
|
### v0.7.0 — Writer Tools & Upload Fix
|
||||||
|
- **NIP-98 HTTP Auth uploads** — image uploads now authenticate via signed kind 27235 events; fallback to void.cat and nostrimg.com if nostr.build fails
|
||||||
|
- **Markdown toolbar** — bold, italic, heading, link, image, quote, code, list buttons above the article editor textarea
|
||||||
|
- **Editor keyboard shortcuts** — Ctrl+B bold, Ctrl+I italic, Ctrl+K link
|
||||||
|
- **Multi-draft management** — create multiple article drafts; draft list view with word count, timestamps, delete; auto-migrates old single-draft localStorage
|
||||||
|
- **Cover image file picker** — upload button next to URL input in article meta panel
|
||||||
|
- **Article bookmarks** — bookmarks now support NIP-51 `a` tags for parameterized replaceable events (kind 30023 articles); Notes/Articles tab toggle in BookmarkView
|
||||||
|
- **Upload moved to TypeScript** — removed Rust `upload_file` command; all uploads go through TS with NIP-98 auth; dropped `reqwest` and `mime_guess` Rust dependencies
|
||||||
|
- **Upload spinner** — animated spinner in compose box and article editor during image upload
|
||||||
|
- **Draft count badge** — sidebar "write article" button shows draft count
|
||||||
|
- **Empty states** — draft list, bookmark articles tab
|
||||||
|
|
||||||
### v0.6.0 — Long-form article experience
|
### v0.6.0 — Long-form article experience
|
||||||
- **Article discovery feed** — dedicated "Articles" view in sidebar with Latest and Following tabs; browse kind 30023 articles from all relays or just followed authors
|
- **Article discovery feed** — dedicated "Articles" view in sidebar with Latest and Following tabs; browse kind 30023 articles from all relays or just followed authors
|
||||||
- **Article cards** — title, summary snippet, author avatar+name, cover image thumbnail, reading time, tag chips
|
- **Article cards** — title, summary snippet, author avatar+name, cover image thumbnail, reading time, tag chips
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "wrystr",
|
"name": "wrystr",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.6.1",
|
"version": "0.7.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "wrystr"
|
name = "wrystr"
|
||||||
version = "0.6.1"
|
version = "0.7.0"
|
||||||
description = "Cross-platform Nostr desktop client with Lightning integration"
|
description = "Cross-platform Nostr desktop client with Lightning integration"
|
||||||
authors = ["hoornet"]
|
authors = ["hoornet"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
@@ -29,6 +29,4 @@ rusqlite = { version = "0.32", features = ["bundled"] }
|
|||||||
tauri-plugin-http = "2.5.7"
|
tauri-plugin-http = "2.5.7"
|
||||||
tauri-plugin-dialog = "2.6.0"
|
tauri-plugin-dialog = "2.6.0"
|
||||||
tauri-plugin-fs = "2.4.5"
|
tauri-plugin-fs = "2.4.5"
|
||||||
reqwest = { version = "0.13.2", default-features = false, features = ["multipart", "rustls"] }
|
|
||||||
mime_guess = "2.0.5"
|
|
||||||
|
|
||||||
|
|||||||
@@ -37,51 +37,6 @@ fn delete_nsec(pubkey: String) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── File upload ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
async fn upload_file(path: String) -> Result<String, String> {
|
|
||||||
let file_bytes = std::fs::read(&path).map_err(|e| format!("Failed to read file: {e}"))?;
|
|
||||||
let file_name = std::path::Path::new(&path)
|
|
||||||
.file_name()
|
|
||||||
.and_then(|n| n.to_str())
|
|
||||||
.unwrap_or("file")
|
|
||||||
.to_string();
|
|
||||||
let mime = mime_guess::from_path(&path)
|
|
||||||
.first_or_octet_stream()
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let part = reqwest::multipart::Part::bytes(file_bytes)
|
|
||||||
.file_name(file_name)
|
|
||||||
.mime_str(&mime)
|
|
||||||
.map_err(|e| format!("MIME error: {e}"))?;
|
|
||||||
let form = reqwest::multipart::Form::new().part("file", part);
|
|
||||||
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
let resp = client
|
|
||||||
.post("https://nostr.build/api/v2/upload/files")
|
|
||||||
.multipart(form)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Upload request failed: {e}"))?;
|
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
|
||||||
return Err(format!("Upload failed (HTTP {})", resp.status()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let data: serde_json::Value = resp
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to parse response: {e}"))?;
|
|
||||||
|
|
||||||
if data["status"] == "success" {
|
|
||||||
if let Some(url) = data["data"][0]["url"].as_str() {
|
|
||||||
return Ok(url.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(data["message"].as_str().unwrap_or("Upload failed — no URL returned").to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── SQLite note/profile cache ────────────────────────────────────────────────
|
// ── SQLite note/profile cache ────────────────────────────────────────────────
|
||||||
|
|
||||||
struct DbState(Mutex<Connection>);
|
struct DbState(Mutex<Connection>);
|
||||||
@@ -250,7 +205,6 @@ pub fn run() {
|
|||||||
store_nsec,
|
store_nsec,
|
||||||
load_nsec,
|
load_nsec,
|
||||||
delete_nsec,
|
delete_nsec,
|
||||||
upload_file,
|
|
||||||
db_save_notes,
|
db_save_notes,
|
||||||
db_load_feed,
|
db_load_feed,
|
||||||
db_save_profile,
|
db_save_profile,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Wrystr",
|
"productName": "Wrystr",
|
||||||
"version": "0.6.1",
|
"version": "0.7.0",
|
||||||
"identifier": "com.hoornet.wrystr",
|
"identifier": "com.hoornet.wrystr",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
@@ -1,45 +1,54 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { marked } from "marked";
|
import { marked } from "marked";
|
||||||
import { publishArticle } from "../../lib/nostr";
|
import { publishArticle } from "../../lib/nostr";
|
||||||
import { useUIStore } from "../../stores/ui";
|
import { useUIStore } from "../../stores/ui";
|
||||||
|
import { MarkdownToolbar, handleEditorKeyDown } from "./MarkdownToolbar";
|
||||||
const DRAFT_KEY = "wrystr_article_draft";
|
import { useDraftStore, type ArticleDraft } from "../../stores/drafts";
|
||||||
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
function loadDraft() {
|
import { readFile } from "@tauri-apps/plugin-fs";
|
||||||
try { return JSON.parse(localStorage.getItem(DRAFT_KEY) || "null"); }
|
import { uploadBytes } from "../../lib/upload";
|
||||||
catch { return null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveDraft(data: object) {
|
|
||||||
localStorage.setItem(DRAFT_KEY, JSON.stringify(data));
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearDraft() {
|
|
||||||
localStorage.removeItem(DRAFT_KEY);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ArticleEditor() {
|
export function ArticleEditor() {
|
||||||
const { goBack } = useUIStore();
|
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 || "");
|
// If no active draft, show draft list
|
||||||
const [content, setContent] = useState(draft?.content || "");
|
const activeDraft = activeDraftId ? drafts.find((d) => d.id === activeDraftId) : null;
|
||||||
const [summary, setSummary] = useState(draft?.summary || "");
|
|
||||||
const [image, setImage] = useState(draft?.image || "");
|
const [title, setTitle] = useState(activeDraft?.title || "");
|
||||||
const [tags, setTags] = useState(draft?.tags || "");
|
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 [mode, setMode] = useState<"write" | "preview">("write");
|
||||||
const [showMeta, setShowMeta] = useState(false);
|
const [showMeta, setShowMeta] = useState(false);
|
||||||
const [publishing, setPublishing] = useState(false);
|
const [publishing, setPublishing] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [published, setPublished] = useState(false);
|
const [published, setPublished] = useState(false);
|
||||||
|
|
||||||
// Auto-save draft
|
// Sync state when active draft changes
|
||||||
useEffect(() => {
|
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(() => {
|
const t = setTimeout(() => {
|
||||||
saveDraft({ title, content, summary, image, tags });
|
updateDraft(activeDraftId, { title, content, summary, image, tags });
|
||||||
}, 1000);
|
}, 1000);
|
||||||
return () => clearTimeout(t);
|
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 renderedHtml = marked(content || "*Nothing to preview yet.*") as string;
|
||||||
const wordCount = content.trim() ? content.trim().split(/\s+/).length : 0;
|
const wordCount = content.trim() ? content.trim().split(/\s+/).length : 0;
|
||||||
@@ -57,7 +66,7 @@ export function ArticleEditor() {
|
|||||||
image: image.trim() || undefined,
|
image: image.trim() || undefined,
|
||||||
tags: tags.split(",").map((t: string) => t.trim()).filter(Boolean),
|
tags: tags.split(",").map((t: string) => t.trim()).filter(Boolean),
|
||||||
});
|
});
|
||||||
clearDraft();
|
if (activeDraftId) deleteDraft(activeDraftId);
|
||||||
setPublished(true);
|
setPublished(true);
|
||||||
setTimeout(goBack, 1500);
|
setTimeout(goBack, 1500);
|
||||||
} catch (err) {
|
} 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 (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="border-b border-border px-4 py-2.5 flex items-center justify-between shrink-0">
|
<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">
|
<div className="flex items-center gap-3">
|
||||||
<button onClick={goBack} className="text-text-dim hover:text-text text-[11px] transition-colors">
|
<button onClick={() => setActiveDraft(null)} className="text-text-dim hover:text-text text-[11px] transition-colors">
|
||||||
← back
|
← drafts
|
||||||
</button>
|
</button>
|
||||||
<span className="text-text-dim text-[10px]">{wordCount > 0 ? `${wordCount} words` : "New article"}</span>
|
<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>
|
<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>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -130,13 +182,23 @@ export function ArticleEditor() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-text-dim text-[10px] block mb-1">Cover image URL</label>
|
<label className="text-text-dim text-[10px] block mb-1">Cover image</label>
|
||||||
|
<div className="flex gap-1">
|
||||||
<input
|
<input
|
||||||
value={image}
|
value={image}
|
||||||
onChange={(e) => setImage(e.target.value)}
|
onChange={(e) => setImage(e.target.value)}
|
||||||
placeholder="https://…"
|
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"
|
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>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-text-dim text-[10px] block mb-1">Tags (comma-separated)</label>
|
<label className="text-text-dim text-[10px] block mb-1">Tags (comma-separated)</label>
|
||||||
@@ -169,12 +231,25 @@ export function ArticleEditor() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Markdown toolbar */}
|
||||||
|
{mode === "write" && (
|
||||||
|
<MarkdownToolbar
|
||||||
|
textareaRef={textareaRef}
|
||||||
|
content={content}
|
||||||
|
setContent={setContent}
|
||||||
|
setUploading={setUploading}
|
||||||
|
setError={setError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Content area */}
|
{/* Content area */}
|
||||||
<div className="flex-1 overflow-y-auto px-6 pb-6">
|
<div className="flex-1 overflow-y-auto px-6 pb-6">
|
||||||
{mode === "write" ? (
|
{mode === "write" ? (
|
||||||
<textarea
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
value={content}
|
value={content}
|
||||||
onChange={(e) => setContent(e.target.value)}
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
onKeyDown={(e) => handleEditorKeyDown(e, textareaRef, content, setContent)}
|
||||||
placeholder="Write your article in Markdown…"
|
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"
|
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>
|
</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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,15 +2,21 @@ import { useEffect, useState } from "react";
|
|||||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||||
import { useBookmarkStore } from "../../stores/bookmark";
|
import { useBookmarkStore } from "../../stores/bookmark";
|
||||||
import { useUserStore } from "../../stores/user";
|
import { useUserStore } from "../../stores/user";
|
||||||
import { fetchNoteById } from "../../lib/nostr";
|
import { fetchNoteById, fetchByAddr } from "../../lib/nostr";
|
||||||
import { NoteCard } from "../feed/NoteCard";
|
import { NoteCard } from "../feed/NoteCard";
|
||||||
|
import { ArticleCard } from "../article/ArticleCard";
|
||||||
import { SkeletonNoteList } from "../shared/Skeleton";
|
import { SkeletonNoteList } from "../shared/Skeleton";
|
||||||
|
|
||||||
|
type BookmarkTab = "notes" | "articles";
|
||||||
|
|
||||||
export function BookmarkView() {
|
export function BookmarkView() {
|
||||||
const { bookmarkedIds, fetchBookmarks } = useBookmarkStore();
|
const { bookmarkedIds, bookmarkedArticleAddrs, fetchBookmarks } = useBookmarkStore();
|
||||||
const { pubkey } = useUserStore();
|
const { pubkey } = useUserStore();
|
||||||
|
const [tab, setTab] = useState<BookmarkTab>("notes");
|
||||||
const [notes, setNotes] = useState<NDKEvent[]>([]);
|
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(() => {
|
useEffect(() => {
|
||||||
if (pubkey) fetchBookmarks(pubkey);
|
if (pubkey) fetchBookmarks(pubkey);
|
||||||
@@ -24,8 +30,16 @@ export function BookmarkView() {
|
|||||||
loadNotes();
|
loadNotes();
|
||||||
}, [bookmarkedIds]);
|
}, [bookmarkedIds]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (bookmarkedArticleAddrs.length === 0) {
|
||||||
|
setArticles([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadArticles();
|
||||||
|
}, [bookmarkedArticleAddrs]);
|
||||||
|
|
||||||
const loadNotes = async () => {
|
const loadNotes = async () => {
|
||||||
setLoading(true);
|
setLoadingNotes(true);
|
||||||
try {
|
try {
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
bookmarkedIds.map((id) => fetchNoteById(id))
|
bookmarkedIds.map((id) => fetchNoteById(id))
|
||||||
@@ -36,36 +50,81 @@ export function BookmarkView() {
|
|||||||
.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))
|
.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))
|
||||||
);
|
);
|
||||||
} finally {
|
} 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 (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
<header className="border-b border-border px-4 py-2.5 shrink-0">
|
<header className="border-b border-border px-4 py-2.5 shrink-0">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<h2 className="text-text text-[13px] font-medium">Bookmarks</h2>
|
<h2 className="text-text text-[13px] font-medium">Bookmarks</h2>
|
||||||
<span className="text-text-dim text-[11px]">{bookmarkedIds.length} saved</span>
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{loading && notes.length === 0 && (
|
{loading && items.length === 0 && (
|
||||||
<SkeletonNoteList count={3} />
|
<SkeletonNoteList count={3} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && notes.length === 0 && (
|
{!loading && items.length === 0 && (
|
||||||
<div className="px-4 py-12 text-center space-y-2">
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{notes.map((event) => (
|
{tab === "notes" && notes.map((event) => (
|
||||||
<NoteCard key={event.id} event={event} />
|
<NoteCard key={event.id} event={event} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{tab === "articles" && articles.map((event) => (
|
||||||
|
<ArticleCard key={event.id} event={event} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { publishNote } from "../../lib/nostr";
|
import { publishNote } from "../../lib/nostr";
|
||||||
import { uploadImage } from "../../lib/upload";
|
import { uploadImage, uploadBytes } from "../../lib/upload";
|
||||||
import { useUserStore } from "../../stores/user";
|
import { useUserStore } from "../../stores/user";
|
||||||
import { useFeedStore } from "../../stores/feed";
|
import { useFeedStore } from "../../stores/feed";
|
||||||
import { shortenPubkey } from "../../lib/utils";
|
import { shortenPubkey } from "../../lib/utils";
|
||||||
import { open } from "@tauri-apps/plugin-dialog";
|
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";
|
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) => {
|
const handleNativeUpload = async (filePath: string) => {
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
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);
|
insertUrl(url);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(`Upload failed: ${err}`);
|
setError(`Upload failed: ${err}`);
|
||||||
@@ -217,7 +226,12 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () =
|
|||||||
|
|
||||||
<div className="flex items-center justify-between mt-1">
|
<div className="flex items-center justify-between mt-1">
|
||||||
<span className={`text-[10px] ${overLimit ? "text-danger" : "text-text-dim"}`}>
|
<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) && (
|
{!uploading && charCount > 0 && localStorage.getItem(COMPOSE_DRAFT_KEY) && (
|
||||||
<span className="ml-1 text-text-dim">(draft)</span>
|
<span className="ml-1 text-text-dim">(draft)</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useUIStore } from "../../stores/ui";
|
|||||||
import { useFeedStore } from "../../stores/feed";
|
import { useFeedStore } from "../../stores/feed";
|
||||||
import { useUserStore } from "../../stores/user";
|
import { useUserStore } from "../../stores/user";
|
||||||
import { useNotificationsStore } from "../../stores/notifications";
|
import { useNotificationsStore } from "../../stores/notifications";
|
||||||
|
import { useDraftStore } from "../../stores/drafts";
|
||||||
import { getNDK } from "../../lib/nostr";
|
import { getNDK } from "../../lib/nostr";
|
||||||
import { AccountSwitcher } from "./AccountSwitcher";
|
import { AccountSwitcher } from "./AccountSwitcher";
|
||||||
import pkg from "../../../package.json";
|
import pkg from "../../../package.json";
|
||||||
@@ -24,6 +25,7 @@ export function Sidebar() {
|
|||||||
const { connected } = useFeedStore();
|
const { connected } = useFeedStore();
|
||||||
const { loggedIn } = useUserStore();
|
const { loggedIn } = useUserStore();
|
||||||
const { unreadCount: notifUnread, dmUnreadCount } = useNotificationsStore();
|
const { unreadCount: notifUnread, dmUnreadCount } = useNotificationsStore();
|
||||||
|
const draftCount = useDraftStore((s) => s.drafts.length);
|
||||||
|
|
||||||
const c = sidebarCollapsed;
|
const c = sidebarCollapsed;
|
||||||
|
|
||||||
@@ -75,8 +77,16 @@ export function Sidebar() {
|
|||||||
: "text-text-muted hover:text-text hover:bg-bg-hover"
|
: "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 && <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>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -616,6 +616,52 @@ export async function publishBookmarkList(eventIds: string[]): Promise<void> {
|
|||||||
await event.publish();
|
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[]> {
|
export async function fetchMuteList(pubkey: string): Promise<string[]> {
|
||||||
const instance = getNDK();
|
const instance = getNDK();
|
||||||
const filter: NDKFilter = { kinds: [10000 as NDKKind], authors: [pubkey], limit: 1 };
|
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";
|
export type { UserRelayList } from "./client";
|
||||||
|
|||||||
@@ -1,4 +1,33 @@
|
|||||||
import { fetch } from "@tauri-apps/plugin-http";
|
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.
|
* 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.
|
* and build a proper Blob with the correct MIME type.
|
||||||
*/
|
*/
|
||||||
export async function uploadImage(file: File): Promise<string> {
|
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());
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
return uploadBytes(bytes, file.name || "image.png", file.type || "image/png");
|
return uploadBytes(bytes, file.name || "image.png", file.type || "image/png");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload raw bytes to nostr.build. Used by the native file picker path
|
* Upload raw bytes with NIP-98 auth. Tries nostr.build first, then fallbacks.
|
||||||
* where we already have a Uint8Array from tauri-plugin-fs.
|
|
||||||
*/
|
*/
|
||||||
export async function uploadBytes(bytes: Uint8Array, fileName: string, mimeType: string): Promise<string> {
|
export async function uploadBytes(bytes: Uint8Array, fileName: string, mimeType: string): Promise<string> {
|
||||||
const blob = new Blob([bytes], { type: mimeType });
|
const blob = new Blob([bytes], { type: mimeType });
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
for (const serviceUrl of UPLOAD_SERVICES) {
|
||||||
|
try {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("file", blob, fileName);
|
form.append("file", blob, fileName);
|
||||||
|
|
||||||
const resp = await fetch("https://nostr.build/api/v2/upload/files", {
|
const headers: Record<string, string> = {};
|
||||||
|
try {
|
||||||
|
headers["Authorization"] = await createNip98AuthHeader(serviceUrl, "POST");
|
||||||
|
} catch {
|
||||||
|
// If not logged in, try without auth (some services allow anonymous)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = await fetch(serviceUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: form,
|
body: form,
|
||||||
|
headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
throw new Error(`Upload failed (HTTP ${resp.status})`);
|
errors.push(`${serviceUrl}: HTTP ${resp.status}`);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
|
|
||||||
|
// nostr.build response format
|
||||||
if (data.status === "success" && data.data?.[0]?.url) {
|
if (data.status === "success" && data.data?.[0]?.url) {
|
||||||
return data.data[0].url as string;
|
return data.data[0].url as string;
|
||||||
}
|
}
|
||||||
throw new Error(data.message || "Upload failed — no URL returned");
|
// 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`All upload services failed:\n${errors.join("\n")}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { fetchBookmarkList, publishBookmarkList } from "../lib/nostr";
|
import { fetchBookmarkList, fetchBookmarkListFull, publishBookmarkListFull } from "../lib/nostr";
|
||||||
|
|
||||||
const STORAGE_KEY = "wrystr_bookmarks";
|
const STORAGE_KEY = "wrystr_bookmarks";
|
||||||
|
const ARTICLE_STORAGE_KEY = "wrystr_bookmarks_articles";
|
||||||
|
|
||||||
function loadLocal(): string[] {
|
function loadLocal(): string[] {
|
||||||
try {
|
try {
|
||||||
@@ -15,18 +16,46 @@ function saveLocal(ids: string[]) {
|
|||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(ids));
|
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 {
|
interface BookmarkState {
|
||||||
bookmarkedIds: string[];
|
bookmarkedIds: string[];
|
||||||
|
bookmarkedArticleAddrs: string[]; // "30023:<pubkey>:<d-tag>" format
|
||||||
fetchBookmarks: (pubkey: string) => Promise<void>;
|
fetchBookmarks: (pubkey: string) => Promise<void>;
|
||||||
addBookmark: (eventId: string) => Promise<void>;
|
addBookmark: (eventId: string) => Promise<void>;
|
||||||
removeBookmark: (eventId: string) => Promise<void>;
|
removeBookmark: (eventId: string) => Promise<void>;
|
||||||
isBookmarked: (eventId: string) => boolean;
|
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) => ({
|
export const useBookmarkStore = create<BookmarkState>((set, get) => ({
|
||||||
bookmarkedIds: loadLocal(),
|
bookmarkedIds: loadLocal(),
|
||||||
|
bookmarkedArticleAddrs: loadArticleAddrs(),
|
||||||
|
|
||||||
fetchBookmarks: async (pubkey: string) => {
|
fetchBookmarks: async (pubkey: string) => {
|
||||||
|
try {
|
||||||
|
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 {
|
||||||
|
// Fallback to old format
|
||||||
try {
|
try {
|
||||||
const ids = await fetchBookmarkList(pubkey);
|
const ids = await fetchBookmarkList(pubkey);
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
@@ -34,28 +63,49 @@ export const useBookmarkStore = create<BookmarkState>((set, get) => ({
|
|||||||
const merged = Array.from(new Set([...ids, ...local]));
|
const merged = Array.from(new Set([...ids, ...local]));
|
||||||
set({ bookmarkedIds: merged });
|
set({ bookmarkedIds: merged });
|
||||||
saveLocal(merged);
|
saveLocal(merged);
|
||||||
} catch {
|
} catch { /* ignore */ }
|
||||||
// Non-critical — local bookmarks still work
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
addBookmark: async (eventId: string) => {
|
addBookmark: async (eventId: string) => {
|
||||||
const { bookmarkedIds } = get();
|
const { bookmarkedIds, bookmarkedArticleAddrs } = get();
|
||||||
if (bookmarkedIds.includes(eventId)) return;
|
if (bookmarkedIds.includes(eventId)) return;
|
||||||
const updated = [...bookmarkedIds, eventId];
|
const updated = [...bookmarkedIds, eventId];
|
||||||
set({ bookmarkedIds: updated });
|
set({ bookmarkedIds: updated });
|
||||||
saveLocal(updated);
|
saveLocal(updated);
|
||||||
publishBookmarkList(updated).catch(() => {});
|
publishBookmarkListFull(updated, bookmarkedArticleAddrs).catch(() => {});
|
||||||
},
|
},
|
||||||
|
|
||||||
removeBookmark: async (eventId: string) => {
|
removeBookmark: async (eventId: string) => {
|
||||||
|
const { bookmarkedArticleAddrs } = get();
|
||||||
const updated = get().bookmarkedIds.filter((id) => id !== eventId);
|
const updated = get().bookmarkedIds.filter((id) => id !== eventId);
|
||||||
set({ bookmarkedIds: updated });
|
set({ bookmarkedIds: updated });
|
||||||
saveLocal(updated);
|
saveLocal(updated);
|
||||||
publishBookmarkList(updated).catch(() => {});
|
publishBookmarkListFull(updated, bookmarkedArticleAddrs).catch(() => {});
|
||||||
},
|
},
|
||||||
|
|
||||||
isBookmarked: (eventId: string) => {
|
isBookmarked: (eventId: string) => {
|
||||||
return get().bookmarkedIds.includes(eventId);
|
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
122
src/stores/drafts.ts
Normal 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);
|
||||||
|
},
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user