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:
Jure
2026-03-26 08:43:18 +01:00
parent 0189433269
commit ac061a8d4b
2 changed files with 99 additions and 21 deletions

View File

@@ -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>
)}

View File

@@ -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"}