mirror of
https://github.com/hoornet/vega.git
synced 2026-05-06 12:19:11 -07:00
Show image attachments as thumbnails instead of raw URLs
Uploaded images are now kept in a separate attachments list with thumbnail previews and remove buttons. URLs are appended to note content only at publish time. This keeps the textarea clean for writing and avoids URLs appearing mid-sentence at the cursor position.
This commit is contained in:
@@ -16,6 +16,7 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () =
|
||||
try { return localStorage.getItem(COMPOSE_DRAFT_KEY) || ""; }
|
||||
catch { return ""; }
|
||||
});
|
||||
const [attachments, setAttachments] = useState<string[]>([]);
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -42,7 +43,7 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () =
|
||||
const charCount = text.length;
|
||||
const warnLimit = charCount > 3500;
|
||||
const overLimit = charCount > 4000;
|
||||
const canPost = text.trim().length > 0 && !publishing && !uploading;
|
||||
const canPost = (text.trim().length > 0 || attachments.length > 0) && !publishing && !uploading;
|
||||
|
||||
// Insert text at the current cursor position in the textarea
|
||||
const insertAtCursor = (str: string) => {
|
||||
@@ -61,13 +62,22 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () =
|
||||
}
|
||||
};
|
||||
|
||||
// Add uploaded URL to attachments instead of inserting into text
|
||||
const addAttachment = (url: string) => {
|
||||
setAttachments((prev) => [...prev, url]);
|
||||
};
|
||||
|
||||
const removeAttachment = (index: number) => {
|
||||
setAttachments((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
// Upload a web File object (from clipboard/drag-drop)
|
||||
const handleImageUpload = async (file: File) => {
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const url = await uploadImage(file);
|
||||
insertAtCursor(url);
|
||||
addAttachment(url);
|
||||
} catch (err) {
|
||||
setError(`Image upload failed: ${err}`);
|
||||
} finally {
|
||||
@@ -90,7 +100,7 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () =
|
||||
};
|
||||
const mimeType = mimeMap[ext] || "application/octet-stream";
|
||||
const url = await uploadBytes(new Uint8Array(bytes), fileName, mimeType);
|
||||
insertAtCursor(url);
|
||||
addAttachment(url);
|
||||
} catch (err) {
|
||||
setError(`Upload failed: ${err}`);
|
||||
} finally {
|
||||
@@ -169,7 +179,11 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () =
|
||||
setPublishing(true);
|
||||
setError(null);
|
||||
try {
|
||||
const event = await publishNote(text.trim());
|
||||
// Build final content: text + attachment URLs on separate lines
|
||||
const parts = [text.trim(), ...attachments].filter(Boolean);
|
||||
const content = parts.join("\n");
|
||||
|
||||
const event = await publishNote(content);
|
||||
// Inject into feed immediately so the user sees their post
|
||||
if (onNoteInjected) {
|
||||
onNoteInjected(event);
|
||||
@@ -180,6 +194,7 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () =
|
||||
});
|
||||
}
|
||||
setText("");
|
||||
setAttachments([]);
|
||||
localStorage.removeItem(COMPOSE_DRAFT_KEY);
|
||||
textareaRef.current?.focus();
|
||||
onPublished?.();
|
||||
@@ -225,6 +240,35 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () =
|
||||
className="w-full bg-transparent text-text text-[13px] placeholder:text-text-dim resize-none focus:outline-none leading-relaxed"
|
||||
/>
|
||||
|
||||
{/* Attachment thumbnails */}
|
||||
{attachments.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{attachments.map((url, i) => (
|
||||
<div key={i} className="relative group">
|
||||
{/\.(mp4|webm|mov|ogg|m4v)(\?|$)/i.test(url) ? (
|
||||
<div className="h-16 w-20 rounded-sm border border-border bg-bg-raised flex items-center justify-center text-text-dim text-[10px]">
|
||||
video
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
className="h-16 w-auto rounded-sm border border-border object-cover"
|
||||
onError={(e) => { (e.target as HTMLImageElement).className = "h-16 w-20 rounded-sm border border-border bg-bg-raised"; }}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={() => removeAttachment(i)}
|
||||
className="absolute -top-1.5 -right-1.5 w-4 h-4 bg-danger text-white text-[10px] rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Remove"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-danger text-[11px] mb-2">{error}</p>
|
||||
)}
|
||||
|
||||
@@ -16,6 +16,7 @@ interface InlineReplyBoxProps {
|
||||
|
||||
export function InlineReplyBox({ event, name, rootEvent }: InlineReplyBoxProps) {
|
||||
const [replyText, setReplyText] = useState("");
|
||||
const [attachments, setAttachments] = useState<string[]>([]);
|
||||
const [replying, setReplying] = useState(false);
|
||||
const [replyError, setReplyError] = useState<string | null>(null);
|
||||
const [replySent, setReplySent] = useState(false);
|
||||
@@ -38,12 +39,20 @@ export function InlineReplyBox({ event, name, rootEvent }: InlineReplyBoxProps)
|
||||
}
|
||||
};
|
||||
|
||||
const addAttachment = (url: string) => {
|
||||
setAttachments((prev) => [...prev, url]);
|
||||
};
|
||||
|
||||
const removeAttachment = (index: number) => {
|
||||
setAttachments((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleImageUpload = async (file: File) => {
|
||||
setUploading(true);
|
||||
setUploadError(null);
|
||||
try {
|
||||
const url = await uploadImage(file);
|
||||
insertAtCursor(url);
|
||||
addAttachment(url);
|
||||
} catch (err) {
|
||||
setUploadError(`Upload failed: ${err}`);
|
||||
} finally {
|
||||
@@ -79,7 +88,7 @@ export function InlineReplyBox({ event, name, rootEvent }: InlineReplyBoxProps)
|
||||
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);
|
||||
addAttachment(url);
|
||||
} catch (err) {
|
||||
setUploadError(`Upload failed: ${err}`);
|
||||
} finally {
|
||||
@@ -102,7 +111,7 @@ export function InlineReplyBox({ event, name, rootEvent }: InlineReplyBoxProps)
|
||||
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);
|
||||
addAttachment(url);
|
||||
} catch (err) {
|
||||
setUploadError(`Upload failed: ${err}`);
|
||||
} finally {
|
||||
@@ -111,12 +120,17 @@ export function InlineReplyBox({ event, name, rootEvent }: InlineReplyBoxProps)
|
||||
};
|
||||
|
||||
const handleReplySubmit = async () => {
|
||||
if (!replyText.trim() || replying) return;
|
||||
if ((!replyText.trim() && attachments.length === 0) || replying) return;
|
||||
setReplying(true);
|
||||
setReplyError(null);
|
||||
try {
|
||||
await publishReply(replyText.trim(), { id: event.id, pubkey: event.pubkey }, rootEvent);
|
||||
// Build final content: text + attachment URLs on separate lines
|
||||
const parts = [replyText.trim(), ...attachments].filter(Boolean);
|
||||
const content = parts.join("\n");
|
||||
|
||||
await publishReply(content, { id: event.id, pubkey: event.pubkey }, rootEvent);
|
||||
setReplyText("");
|
||||
setAttachments([]);
|
||||
setReplySent(true);
|
||||
adjustReplyCount(1);
|
||||
setTimeout(() => { setReplySent(false); }, 1500);
|
||||
@@ -148,6 +162,36 @@ export function InlineReplyBox({ event, name, rootEvent }: InlineReplyBoxProps)
|
||||
className="w-full bg-transparent text-text text-[12px] placeholder:text-text-dim resize-none focus:outline-none leading-relaxed"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{/* Attachment thumbnails */}
|
||||
{attachments.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-1">
|
||||
{attachments.map((url, i) => (
|
||||
<div key={i} className="relative group">
|
||||
{/\.(mp4|webm|mov|ogg|m4v)(\?|$)/i.test(url) ? (
|
||||
<div className="h-12 w-16 rounded-sm border border-border bg-bg-raised flex items-center justify-center text-text-dim text-[9px]">
|
||||
video
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
className="h-12 w-auto rounded-sm border border-border object-cover"
|
||||
onError={(e) => { (e.target as HTMLImageElement).className = "h-12 w-16 rounded-sm border border-border bg-bg-raised"; }}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={() => removeAttachment(i)}
|
||||
className="absolute -top-1.5 -right-1.5 w-4 h-4 bg-danger text-white text-[10px] rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Remove"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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">
|
||||
@@ -175,17 +219,7 @@ export function InlineReplyBox({ event, name, rootEvent }: InlineReplyBoxProps)
|
||||
</button>
|
||||
{showReplyEmoji && (
|
||||
<EmojiPicker
|
||||
onSelect={(emoji) => {
|
||||
const ta = replyRef.current;
|
||||
if (ta) {
|
||||
const start = ta.selectionStart ?? replyText.length;
|
||||
const end = ta.selectionEnd ?? replyText.length;
|
||||
setReplyText(replyText.slice(0, start) + emoji + replyText.slice(end));
|
||||
setTimeout(() => { ta.selectionStart = ta.selectionEnd = start + emoji.length; ta.focus(); }, 0);
|
||||
} else {
|
||||
setReplyText((t) => t + emoji);
|
||||
}
|
||||
}}
|
||||
onSelect={(emoji) => insertAtCursor(emoji)}
|
||||
onClose={() => setShowReplyEmoji(false)}
|
||||
/>
|
||||
)}
|
||||
@@ -193,7 +227,7 @@ export function InlineReplyBox({ event, name, rootEvent }: InlineReplyBoxProps)
|
||||
<span className="text-text-dim text-[10px]">Ctrl+Enter</span>
|
||||
<button
|
||||
onClick={handleReplySubmit}
|
||||
disabled={!replyText.trim() || replying || uploading}
|
||||
disabled={(!replyText.trim() && attachments.length === 0) || 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"
|
||||
>
|
||||
{replySent ? "replied ✓" : replying ? "posting…" : "reply"}
|
||||
|
||||
Reference in New Issue
Block a user