From 4e04ad38c31d4c0c84296b71a241cc7033733e7a Mon Sep 17 00:00:00 2001 From: Jure <44338+hoornet@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:04:30 +0100 Subject: [PATCH] Image upload in replies, multi-image articles, UX fixes Add image support to InlineReplyBox (paste, file picker), paste-to-upload in article editor, multi-select for article image toolbar. Fix emoji picker opening off-screen (right-align), enlarge emoji/attach buttons, make note card names clickable to open profile. --- src/components/article/ArticleEditor.tsx | 53 +++++++++- src/components/article/MarkdownToolbar.tsx | 28 +++--- src/components/feed/ComposeBox.tsx | 4 +- src/components/feed/InlineReplyBox.tsx | 109 ++++++++++++++++++++- src/components/feed/NoteCard.tsx | 6 +- src/components/shared/EmojiPicker.tsx | 2 +- 6 files changed, 180 insertions(+), 22 deletions(-) diff --git a/src/components/article/ArticleEditor.tsx b/src/components/article/ArticleEditor.tsx index 4068a3c..227ae15 100644 --- a/src/components/article/ArticleEditor.tsx +++ b/src/components/article/ArticleEditor.tsx @@ -6,7 +6,7 @@ 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"; +import { uploadBytes, uploadImage } from "../../lib/upload"; import { getCurrentWindow } from "@tauri-apps/api/window"; export function ArticleEditor() { @@ -162,6 +162,56 @@ export function ArticleEditor() { } }; + const handleArticlePaste = async (e: React.ClipboardEvent) => { + const fileFromFiles = Array.from(e.clipboardData.files).find((f) => f.type.startsWith("image/")); + if (fileFromFiles) { + e.preventDefault(); + setUploading(true); + setError(null); + try { + const url = await uploadImage(fileFromFiles); + const ta = textareaRef.current; + if (ta) { + const start = ta.selectionStart ?? content.length; + const end = ta.selectionEnd ?? content.length; + const md = `![image](${url})`; + setContent(content.slice(0, start) + md + content.slice(end)); + setTimeout(() => { ta.selectionStart = ta.selectionEnd = start + md.length; ta.focus(); }, 0); + } + } catch (err) { + setError(`Image upload failed: ${err}`); + } finally { + setUploading(false); + } + return; + } + const items = Array.from(e.clipboardData.items ?? []); + const imageItem = items.find((item) => item.type.startsWith("image/")); + if (imageItem) { + const file = imageItem.getAsFile(); + if (file) { + e.preventDefault(); + setUploading(true); + setError(null); + try { + const url = await uploadImage(file); + const ta = textareaRef.current; + if (ta) { + const start = ta.selectionStart ?? content.length; + const end = ta.selectionEnd ?? content.length; + const md = `![image](${url})`; + setContent(content.slice(0, start) + md + content.slice(end)); + setTimeout(() => { ta.selectionStart = ta.selectionEnd = start + md.length; ta.focus(); }, 0); + } + } catch (err) { + setError(`Image upload failed: ${err}`); + } finally { + setUploading(false); + } + } + } + }; + // If no active draft, show the draft list if (!activeDraftId) { return ; @@ -356,6 +406,7 @@ export function ArticleEditor() { value={content} onChange={(e) => setContent(e.target.value)} onKeyDown={(e) => handleEditorKeyDown(e, textareaRef, content, setContent)} + onPaste={handleArticlePaste} 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" /> diff --git a/src/components/article/MarkdownToolbar.tsx b/src/components/article/MarkdownToolbar.tsx index 43eec32..25057ec 100644 --- a/src/components/article/MarkdownToolbar.tsx +++ b/src/components/article/MarkdownToolbar.tsx @@ -141,27 +141,29 @@ export function MarkdownToolbar({ textareaRef, content, setContent, setUploading const handleImageUpload = async () => { try { const selected = await open({ - multiple: false, + multiple: true, filters: [ { name: "Images", extensions: ["jpg", "jpeg", "png", "gif", "webp", "svg"] }, ], }); if (!selected) return; - const filePath = typeof selected === "string" ? selected : selected; + const paths = Array.isArray(selected) ? 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 = { - jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif", - webp: "image/webp", svg: "image/svg+xml", - }; - const url = await uploadBytes(new Uint8Array(bytes), fileName, mimeMap[ext] || "image/png"); - const textarea = textareaRef.current; - if (textarea) { - applyMarkdown(textarea, "image", content, setContent, `![${fileName}](${url})`); + for (const filePath of paths) { + const bytes = await readFile(filePath); + const fileName = filePath.split(/[\\/]/).pop() || "image.png"; + const ext = fileName.split(".").pop()?.toLowerCase() || "png"; + const mimeMap: Record = { + jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif", + webp: "image/webp", svg: "image/svg+xml", + }; + const url = await uploadBytes(new Uint8Array(bytes), fileName, mimeMap[ext] || "image/png"); + const textarea = textareaRef.current; + if (textarea) { + applyMarkdown(textarea, "image", content, setContent, `![${fileName}](${url})`); + } } } finally { setUploading?.(false); diff --git a/src/components/feed/ComposeBox.tsx b/src/components/feed/ComposeBox.tsx index 14bc9a2..70a4660 100644 --- a/src/components/feed/ComposeBox.tsx +++ b/src/components/feed/ComposeBox.tsx @@ -244,7 +244,7 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () = @@ -259,7 +259,7 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () = onClick={handleFilePicker} disabled={uploading} title="Attach image or video" - className="text-text-dim hover:text-text text-[13px] transition-colors disabled:opacity-30" + className="text-text-dim hover:text-text text-[16px] transition-colors disabled:opacity-30" > + diff --git a/src/components/feed/InlineReplyBox.tsx b/src/components/feed/InlineReplyBox.tsx index 4f1324c..ff509f9 100644 --- a/src/components/feed/InlineReplyBox.tsx +++ b/src/components/feed/InlineReplyBox.tsx @@ -1,8 +1,11 @@ import { useState, useRef } from "react"; import { NDKEvent } from "@nostr-dev-kit/ndk"; import { publishReply } from "../../lib/nostr"; +import { uploadImage, uploadBytes } from "../../lib/upload"; import { useReplyCount } from "../../hooks/useReplyCount"; import { EmojiPicker } from "../shared/EmojiPicker"; +import { open } from "@tauri-apps/plugin-dialog"; +import { readFile } from "@tauri-apps/plugin-fs"; interface InlineReplyBoxProps { event: NDKEvent; @@ -16,9 +19,95 @@ export function InlineReplyBox({ event, name, rootEvent }: InlineReplyBoxProps) const [replyError, setReplyError] = useState(null); const [replySent, setReplySent] = useState(false); const [showReplyEmoji, setShowReplyEmoji] = useState(false); + const [uploading, setUploading] = useState(false); + const [uploadError, setUploadError] = useState(null); const replyRef = useRef(null); const [, adjustReplyCount] = useReplyCount(event.id); + const insertAtCursor = (str: string) => { + const ta = replyRef.current; + if (ta) { + const start = ta.selectionStart ?? replyText.length; + const end = ta.selectionEnd ?? replyText.length; + setReplyText(replyText.slice(0, start) + str + replyText.slice(end)); + setTimeout(() => { ta.selectionStart = ta.selectionEnd = start + str.length; ta.focus(); }, 0); + } else { + setReplyText((t) => t + str); + } + }; + + const handleImageUpload = async (file: File) => { + setUploading(true); + setUploadError(null); + try { + const url = await uploadImage(file); + insertAtCursor(url); + } catch (err) { + setUploadError(`Upload failed: ${err}`); + } finally { + setUploading(false); + } + }; + + const handlePaste = async (e: React.ClipboardEvent) => { + const fileFromFiles = Array.from(e.clipboardData.files).find((f) => f.type.startsWith("image/")); + if (fileFromFiles) { + e.preventDefault(); + handleImageUpload(fileFromFiles); + return; + } + const items = Array.from(e.clipboardData.items ?? []); + const imageItem = items.find((item) => item.type.startsWith("image/")); + if (imageItem) { + const file = imageItem.getAsFile(); + if (file) { + e.preventDefault(); + handleImageUpload(file); + return; + } + } + const pastedText = e.clipboardData.getData("text/plain"); + if (pastedText && /\.(jpg|jpeg|png|gif|webp|svg|mp4|webm|mov)$/i.test(pastedText.trim()) && /^(\/|[A-Z]:\\)/.test(pastedText.trim())) { + e.preventDefault(); + setUploading(true); + setUploadError(null); + try { + const bytes = await readFile(pastedText.trim()); + const fileName = pastedText.trim().split(/[\\/]/).pop() || "file"; + const ext = fileName.split(".").pop()?.toLowerCase() || ""; + const mimeMap: Record = { 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] || "application/octet-stream"); + insertAtCursor(url); + } catch (err) { + setUploadError(`Upload failed: ${err}`); + } finally { + setUploading(false); + } + } + }; + + const handleFilePicker = async () => { + try { + const selected = await open({ + multiple: false, + filters: [{ name: "Media", extensions: ["jpg", "jpeg", "png", "gif", "webp", "svg", "mp4", "webm", "mov"] }], + }); + if (!selected) return; + setUploading(true); + setUploadError(null); + const bytes = await readFile(selected); + const fileName = selected.split(/[\\/]/).pop() || "file"; + const ext = fileName.split(".").pop()?.toLowerCase() || ""; + const mimeMap: Record = { jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif", webp: "image/webp", mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime" }; + const url = await uploadBytes(new Uint8Array(bytes), fileName, mimeMap[ext] || "application/octet-stream"); + insertAtCursor(url); + } catch (err) { + setUploadError(`Upload failed: ${err}`); + } finally { + setUploading(false); + } + }; + const handleReplySubmit = async () => { if (!replyText.trim() || replying) return; setReplying(true); @@ -51,18 +140,34 @@ export function InlineReplyBox({ event, name, rootEvent }: InlineReplyBoxProps) value={replyText} onChange={(e) => setReplyText(e.target.value)} onKeyDown={handleReplyKeyDown} + onPaste={handlePaste} placeholder={`Reply to ${name}…`} rows={2} className="w-full bg-transparent text-text text-[12px] placeholder:text-text-dim resize-none focus:outline-none" autoFocus /> {replyError &&

{replyError}

} + {uploadError &&

{uploadError}

}
+ {uploading && ( + + + uploading… + + )} +
@@ -86,7 +191,7 @@ export function InlineReplyBox({ event, name, rootEvent }: InlineReplyBoxProps) Ctrl+Enter {nip05 && ( {verified === "valid" ? "✓ " : ""}{nip05} diff --git a/src/components/shared/EmojiPicker.tsx b/src/components/shared/EmojiPicker.tsx index 36c9685..e8c21ec 100644 --- a/src/components/shared/EmojiPicker.tsx +++ b/src/components/shared/EmojiPicker.tsx @@ -19,7 +19,7 @@ export function EmojiPicker({ onSelect, onClose }: EmojiPickerProps) { return ( <>
-
+
{/* Group tabs */}
{EMOJI_GROUPS.map((g, i) => (