mirror of
https://github.com/hoornet/vega.git
synced 2026-05-15 04:19:35 -07:00
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.
This commit is contained in:
@@ -6,7 +6,7 @@ import { MarkdownToolbar, handleEditorKeyDown } from "./MarkdownToolbar";
|
|||||||
import { useDraftStore, type ArticleDraft } from "../../stores/drafts";
|
import { useDraftStore, type ArticleDraft } from "../../stores/drafts";
|
||||||
import { open } from "@tauri-apps/plugin-dialog";
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
import { readFile } from "@tauri-apps/plugin-fs";
|
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";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
|
|
||||||
export function ArticleEditor() {
|
export function ArticleEditor() {
|
||||||
@@ -162,6 +162,56 @@ export function ArticleEditor() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleArticlePaste = async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
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 = ``;
|
||||||
|
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 = ``;
|
||||||
|
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 no active draft, show the draft list
|
||||||
if (!activeDraftId) {
|
if (!activeDraftId) {
|
||||||
return <DraftListView onNewDraft={handleNewDraft} />;
|
return <DraftListView onNewDraft={handleNewDraft} />;
|
||||||
@@ -356,6 +406,7 @@ export function ArticleEditor() {
|
|||||||
value={content}
|
value={content}
|
||||||
onChange={(e) => setContent(e.target.value)}
|
onChange={(e) => setContent(e.target.value)}
|
||||||
onKeyDown={(e) => handleEditorKeyDown(e, textareaRef, content, setContent)}
|
onKeyDown={(e) => handleEditorKeyDown(e, textareaRef, content, setContent)}
|
||||||
|
onPaste={handleArticlePaste}
|
||||||
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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -141,27 +141,29 @@ export function MarkdownToolbar({ textareaRef, content, setContent, setUploading
|
|||||||
const handleImageUpload = async () => {
|
const handleImageUpload = async () => {
|
||||||
try {
|
try {
|
||||||
const selected = await open({
|
const selected = await open({
|
||||||
multiple: false,
|
multiple: true,
|
||||||
filters: [
|
filters: [
|
||||||
{ name: "Images", extensions: ["jpg", "jpeg", "png", "gif", "webp", "svg"] },
|
{ name: "Images", extensions: ["jpg", "jpeg", "png", "gif", "webp", "svg"] },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
const filePath = typeof selected === "string" ? selected : selected;
|
const paths = Array.isArray(selected) ? selected : [selected];
|
||||||
setUploading?.(true);
|
setUploading?.(true);
|
||||||
setError?.(null);
|
setError?.(null);
|
||||||
try {
|
try {
|
||||||
const bytes = await readFile(filePath);
|
for (const filePath of paths) {
|
||||||
const fileName = filePath.split(/[\\/]/).pop() || "image.png";
|
const bytes = await readFile(filePath);
|
||||||
const ext = fileName.split(".").pop()?.toLowerCase() || "png";
|
const fileName = filePath.split(/[\\/]/).pop() || "image.png";
|
||||||
const mimeMap: Record<string, string> = {
|
const ext = fileName.split(".").pop()?.toLowerCase() || "png";
|
||||||
jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif",
|
const mimeMap: Record<string, string> = {
|
||||||
webp: "image/webp", svg: "image/svg+xml",
|
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;
|
const url = await uploadBytes(new Uint8Array(bytes), fileName, mimeMap[ext] || "image/png");
|
||||||
if (textarea) {
|
const textarea = textareaRef.current;
|
||||||
applyMarkdown(textarea, "image", content, setContent, ``);
|
if (textarea) {
|
||||||
|
applyMarkdown(textarea, "image", content, setContent, ``);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setUploading?.(false);
|
setUploading?.(false);
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () =
|
|||||||
<button
|
<button
|
||||||
onClick={() => setShowEmoji((v) => !v)}
|
onClick={() => setShowEmoji((v) => !v)}
|
||||||
title="Insert emoji"
|
title="Insert emoji"
|
||||||
className="text-text-dim hover:text-text text-[13px] transition-colors"
|
className="text-text-dim hover:text-text text-[16px] transition-colors"
|
||||||
>
|
>
|
||||||
☺
|
☺
|
||||||
</button>
|
</button>
|
||||||
@@ -259,7 +259,7 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () =
|
|||||||
onClick={handleFilePicker}
|
onClick={handleFilePicker}
|
||||||
disabled={uploading}
|
disabled={uploading}
|
||||||
title="Attach image or video"
|
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"
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { useState, useRef } from "react";
|
import { useState, useRef } from "react";
|
||||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||||
import { publishReply } from "../../lib/nostr";
|
import { publishReply } from "../../lib/nostr";
|
||||||
|
import { uploadImage, uploadBytes } from "../../lib/upload";
|
||||||
import { useReplyCount } from "../../hooks/useReplyCount";
|
import { useReplyCount } from "../../hooks/useReplyCount";
|
||||||
import { EmojiPicker } from "../shared/EmojiPicker";
|
import { EmojiPicker } from "../shared/EmojiPicker";
|
||||||
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { readFile } from "@tauri-apps/plugin-fs";
|
||||||
|
|
||||||
interface InlineReplyBoxProps {
|
interface InlineReplyBoxProps {
|
||||||
event: NDKEvent;
|
event: NDKEvent;
|
||||||
@@ -16,9 +19,95 @@ export function InlineReplyBox({ event, name, rootEvent }: InlineReplyBoxProps)
|
|||||||
const [replyError, setReplyError] = useState<string | null>(null);
|
const [replyError, setReplyError] = useState<string | null>(null);
|
||||||
const [replySent, setReplySent] = useState(false);
|
const [replySent, setReplySent] = useState(false);
|
||||||
const [showReplyEmoji, setShowReplyEmoji] = useState(false);
|
const [showReplyEmoji, setShowReplyEmoji] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||||
const replyRef = useRef<HTMLTextAreaElement>(null);
|
const replyRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const [, adjustReplyCount] = useReplyCount(event.id);
|
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<HTMLTextAreaElement>) => {
|
||||||
|
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<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] || "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<string, string> = { 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 () => {
|
const handleReplySubmit = async () => {
|
||||||
if (!replyText.trim() || replying) return;
|
if (!replyText.trim() || replying) return;
|
||||||
setReplying(true);
|
setReplying(true);
|
||||||
@@ -51,18 +140,34 @@ export function InlineReplyBox({ event, name, rootEvent }: InlineReplyBoxProps)
|
|||||||
value={replyText}
|
value={replyText}
|
||||||
onChange={(e) => setReplyText(e.target.value)}
|
onChange={(e) => setReplyText(e.target.value)}
|
||||||
onKeyDown={handleReplyKeyDown}
|
onKeyDown={handleReplyKeyDown}
|
||||||
|
onPaste={handlePaste}
|
||||||
placeholder={`Reply to ${name}…`}
|
placeholder={`Reply to ${name}…`}
|
||||||
rows={2}
|
rows={2}
|
||||||
className="w-full bg-transparent text-text text-[12px] placeholder:text-text-dim resize-none focus:outline-none"
|
className="w-full bg-transparent text-text text-[12px] placeholder:text-text-dim resize-none focus:outline-none"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
{replyError && <p className="text-danger text-[10px] mb-1">{replyError}</p>}
|
{replyError && <p className="text-danger text-[10px] mb-1">{replyError}</p>}
|
||||||
|
{uploadError && <p className="text-danger text-[10px] mb-1">{uploadError}</p>}
|
||||||
<div className="flex items-center justify-end gap-2 mt-1">
|
<div className="flex items-center justify-end gap-2 mt-1">
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleFilePicker}
|
||||||
|
disabled={uploading}
|
||||||
|
title="Attach image or video"
|
||||||
|
className="text-text-dim hover:text-text text-[16px] transition-colors disabled:opacity-30"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowReplyEmoji((v) => !v)}
|
onClick={() => setShowReplyEmoji((v) => !v)}
|
||||||
title="Insert emoji"
|
title="Insert emoji"
|
||||||
className="text-text-dim hover:text-text text-[12px] transition-colors"
|
className="text-text-dim hover:text-text text-[16px] transition-colors"
|
||||||
>
|
>
|
||||||
☺
|
☺
|
||||||
</button>
|
</button>
|
||||||
@@ -86,7 +191,7 @@ export function InlineReplyBox({ event, name, rootEvent }: InlineReplyBoxProps)
|
|||||||
<span className="text-text-dim text-[10px]">Ctrl+Enter</span>
|
<span className="text-text-dim text-[10px]">Ctrl+Enter</span>
|
||||||
<button
|
<button
|
||||||
onClick={handleReplySubmit}
|
onClick={handleReplySubmit}
|
||||||
disabled={!replyText.trim() || replying}
|
disabled={!replyText.trim() || replying || uploading}
|
||||||
className="px-2 py-0.5 text-[10px] bg-accent hover:bg-accent-hover text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
className="px-2 py-0.5 text-[10px] bg-accent hover:bg-accent-hover text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{replySent ? "replied ✓" : replying ? "posting…" : "reply"}
|
{replySent ? "replied ✓" : replying ? "posting…" : "reply"}
|
||||||
|
|||||||
@@ -83,10 +83,10 @@ export function NoteCard({ event, focused, onReplyInThread }: NoteCardProps) {
|
|||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-baseline gap-2 mb-0.5">
|
<div className="flex items-baseline gap-2 mb-0.5">
|
||||||
<span
|
<button
|
||||||
className="text-text font-medium truncate text-[13px] cursor-pointer hover:text-accent transition-colors"
|
className="text-text font-medium truncate text-[13px] cursor-pointer hover:text-accent transition-colors text-left"
|
||||||
onClick={() => openProfile(event.pubkey)}
|
onClick={() => openProfile(event.pubkey)}
|
||||||
>{name}</span>
|
>{name}</button>
|
||||||
{nip05 && (
|
{nip05 && (
|
||||||
<span className={`text-[10px] truncate max-w-40 ${verified === "valid" ? "text-success" : "text-text-dim"}`}>
|
<span className={`text-[10px] truncate max-w-40 ${verified === "valid" ? "text-success" : "text-text-dim"}`}>
|
||||||
{verified === "valid" ? "✓ " : ""}{nip05}
|
{verified === "valid" ? "✓ " : ""}{nip05}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function EmojiPicker({ onSelect, onClose }: EmojiPickerProps) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 z-[9]" onClick={onClose} />
|
<div className="fixed inset-0 z-[9]" onClick={onClose} />
|
||||||
<div className="absolute bottom-7 left-0 bg-bg-raised border border-border shadow-lg z-10 w-64">
|
<div className="absolute bottom-7 right-0 bg-bg-raised border border-border shadow-lg z-10 w-64">
|
||||||
{/* Group tabs */}
|
{/* Group tabs */}
|
||||||
<div className="flex border-b border-border">
|
<div className="flex border-b border-border">
|
||||||
{EMOJI_GROUPS.map((g, i) => (
|
{EMOJI_GROUPS.map((g, i) => (
|
||||||
|
|||||||
Reference in New Issue
Block a user