diff --git a/web/src/lib/components/CommentEditor.svelte b/web/src/lib/components/CommentEditor.svelte new file mode 100644 index 00000000..c505b7c4 --- /dev/null +++ b/web/src/lib/components/CommentEditor.svelte @@ -0,0 +1,299 @@ + + +
+
+
+ + {#if pendingUploads > 0} + Uploading {pendingUploads} file{pendingUploads === 1 ? '' : 's'}… + {:else} + {onCancel ? 'Ctrl+Enter to submit · Esc to cancel' : 'Ctrl+Enter to submit · paste or drop an image'} + {/if} + +
+ {#if onCancel} + + {/if} + +
+
+
+ + diff --git a/web/src/lib/components/timeline/ItemTimeline.svelte b/web/src/lib/components/timeline/ItemTimeline.svelte index 86517801..6c47884a 100644 --- a/web/src/lib/components/timeline/ItemTimeline.svelte +++ b/web/src/lib/components/timeline/ItemTimeline.svelte @@ -8,16 +8,11 @@ import TimelineCommentCard from './TimelineCommentCard.svelte'; import TimelineActivityCard from './TimelineActivityCard.svelte'; import TimelineVersionCard from './TimelineVersionCard.svelte'; - import { - filesFromPaste, - filesFromDrop, - isFileDrag, - uploadIntoTextarea, - attachmentRefsIn - } from '$lib/utils/commentAttachments'; + import { attachmentRefsIn } from '$lib/utils/commentAttachments'; import { fetchAttachmentMetadata } from '$lib/components/editor/attachment-metadata'; import { attachmentDownloadUrl, type AttachmentMeta } from '$lib/markdown/attachments'; import Lightbox, { type LightboxImage } from '$lib/components/common/Lightbox.svelte'; + import CommentEditor from '$lib/components/CommentEditor.svelte'; interface Props { wsSlug: string; @@ -51,13 +46,6 @@ let loading: boolean = $state(false); let loadingMore: boolean = $state(false); let error: string = $state(''); - let newBody: string = $state(''); - - // Comment composer attachment state (IDEA-1650). pendingUploads gates - // submit while a paste/drop upload is in flight; composeTextarea is the - // caret anchor the upload helper splices markdown into. - let pendingUploads: number = $state(0); - let composeTextarea: HTMLTextAreaElement | undefined = $state(); // Resolver for `pad-attachment:UUID` references in comment bodies. // Metadata (MIME + size) is fetched lazily per UUID via a HEAD probe and @@ -98,29 +86,6 @@ } }); - function startComposeUploads(files: File[]) { - if (!composeTextarea) return; - uploadIntoTextarea(files, composeTextarea, wsSlug, { - getValue: () => newBody, - setValue: (v) => { - newBody = v; - }, - onPendingDelta: (d) => { - pendingUploads += d; - }, - onError: (msg) => { - error = msg; - } - }); - } - - function handleComposePaste(e: ClipboardEvent) { - const files = filesFromPaste(e); - if (files.length === 0) return; - e.preventDefault(); - startComposeUploads(files); - } - // Lightbox state (IDEA-1660). Set when a thumbnail is activated; cleared // on close. Null = closed, so the host remounts fresh on each open. let lightbox: { images: LightboxImage[]; index: number } | null = $state(null); @@ -194,20 +159,6 @@ }); }); - function handleComposeDragOver(e: DragEvent) { - // Cancel only file drags so the browser delivers the drop here - // instead of navigating; text drag-drop within the textarea is left - // to default handling. - if (isFileDrag(e)) e.preventDefault(); - } - - function handleComposeDrop(e: DragEvent) { - const files = filesFromDrop(e); - if (files.length === 0) return; - e.preventDefault(); - startComposeUploads(files); - } - // Current user ID for reaction toggle — read from the global auth store. let currentUserId = $derived(authStore.userId); @@ -311,31 +262,26 @@ let submitting: boolean = $state(false); - async function submitComment() { - if (!newBody.trim() || submitting || pendingUploads > 0) return; + // Posts a new comment. Throws on failure so CommentEditor preserves the + // draft; clears itself on success. + async function submitComment(body: string) { submitting = true; + error = ''; try { await api.comments.create(wsSlug, itemSlug, { - body: newBody.trim(), + body, created_by: 'user', source: 'web' }); - newBody = ''; await loadTimeline(); } catch (err: any) { error = err?.message ?? 'Failed to post comment'; + throw err; } finally { submitting = false; } } - function handleKeydown(e: KeyboardEvent) { - if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { - e.preventDefault(); - submitComment(); - } - } - async function handleReply(commentId: string, body: string) { try { await api.comments.reply(wsSlug, commentId, { @@ -397,32 +343,13 @@ thread but cannot post; the composer is hidden entirely. --> {#if canEdit}
- -
- - {pendingUploads > 0 - ? `Uploading ${pendingUploads} file${pendingUploads === 1 ? '' : 's'}…` - : 'Ctrl+Enter to submit'} - - -
+
{/if} @@ -538,65 +465,6 @@ gap: var(--space-2); } - .compose-input { - width: 100%; - padding: var(--space-2) var(--space-3); - background: var(--bg-secondary); - border: 1px solid var(--border); - border-radius: var(--radius); - color: var(--text-primary); - font-size: 0.9em; - font-family: inherit; - line-height: 1.5; - resize: vertical; - min-height: 60px; - } - - .compose-input::placeholder { - color: var(--text-muted); - } - - .compose-input:focus { - outline: none; - border-color: var(--accent-blue); - } - - .compose-input:disabled { - opacity: 0.6; - } - - .compose-actions { - display: flex; - align-items: center; - justify-content: flex-end; - gap: var(--space-3); - } - - .shortcut-hint { - font-size: 0.75em; - color: var(--text-muted); - } - - .submit-btn { - padding: var(--space-1) var(--space-4); - background: var(--accent-blue); - border: none; - border-radius: var(--radius); - color: #fff; - font-size: 0.85em; - font-weight: 500; - cursor: pointer; - } - - .submit-btn:hover:not(:disabled) { - filter: brightness(1.1); - } - - .submit-btn:disabled { - opacity: 0.5; - cursor: not-allowed; - } - /* ── Loading / Error ──────────────────────────────────────────────────── */ .loading { diff --git a/web/src/lib/components/timeline/TimelineCommentCard.svelte b/web/src/lib/components/timeline/TimelineCommentCard.svelte index 8786b117..f61b0892 100644 --- a/web/src/lib/components/timeline/TimelineCommentCard.svelte +++ b/web/src/lib/components/timeline/TimelineCommentCard.svelte @@ -2,7 +2,7 @@ import type { Comment, Item, Reaction } from '$lib/types'; import { relativeTime, renderMarkdown } from '$lib/utils/markdown'; import type { AttachmentResolver } from '$lib/markdown/attachments'; - import { filesFromPaste, filesFromDrop, isFileDrag, uploadIntoTextarea } from '$lib/utils/commentAttachments'; + import CommentEditor from '$lib/components/CommentEditor.svelte'; import ReactionPicker from './ReactionPicker.svelte'; interface Props { @@ -36,75 +36,22 @@ let { comment, wsSlug, username = '', items, currentUserId = '', canEdit = true, attachmentResolver, onDelete, onReply, onReaction, onRemoveReaction }: Props = $props(); let showReplyForm = $state(false); - let replyBody = $state(''); let submittingReply = $state(false); - // Reply-box attachment upload (IDEA-1650). Mirrors the top-level - // composer in ItemTimeline; replyPending gates submit while a - // paste/drop upload is in flight. - let replyTextarea: HTMLTextAreaElement | undefined = $state(); - let replyPending = $state(0); - - function startReplyUploads(files: File[]) { - if (!replyTextarea) return; - uploadIntoTextarea(files, replyTextarea, wsSlug, { - getValue: () => replyBody, - setValue: (v) => { - replyBody = v; - }, - onPendingDelta: (d) => { - replyPending += d; - }, - onError: (msg) => { - if (typeof window !== 'undefined') window.alert(`Couldn't upload: ${msg}`); - } - }); - } - - function handleReplyPaste(e: ClipboardEvent) { - const files = filesFromPaste(e); - if (files.length === 0) return; - e.preventDefault(); - startReplyUploads(files); - } - - function handleReplyDragOver(e: DragEvent) { - if (isFileDrag(e)) e.preventDefault(); - } - - function handleReplyDrop(e: DragEvent) { - const files = filesFromDrop(e); - if (files.length === 0) return; - e.preventDefault(); - startReplyUploads(files); - } - - async function submitReply() { - const body = replyBody.trim(); - if (!body || submittingReply || replyPending > 0) return; + // Posts a reply via the host callback. Throws on failure so CommentEditor + // keeps the draft; closes the form on success. + async function submitReply(body: string) { submittingReply = true; try { await onReply(comment.id, body); - replyBody = ''; showReplyForm = false; - } catch { - // Keep draft on failure so the user can retry. + } catch (err) { + throw err; } finally { submittingReply = false; } } - function handleReplyKeydown(e: KeyboardEvent) { - if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { - e.preventDefault(); - submitReply(); - } - if (e.key === 'Escape') { - showReplyForm = false; - replyBody = ''; - } - } - interface ReactionGroup { emoji: string; count: number; @@ -245,30 +192,15 @@ {#if showReplyForm && canEdit}
- -
- - {replyPending > 0 - ? `Uploading ${replyPending} file${replyPending === 1 ? '' : 's'}…` - : 'Ctrl+Enter to submit · Esc to cancel'} - -
- - -
-
+ { showReplyForm = false; }} + />
{/if} @@ -549,80 +481,7 @@ display: flex; flex-direction: column; gap: var(--space-2); - } - - .reply-input { - width: 100%; - padding: var(--space-2) var(--space-3); - background: var(--bg-tertiary); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - color: var(--text-primary); - font-size: 0.85em; - font-family: inherit; - line-height: 1.5; - resize: vertical; - min-height: 52px; - } - - .reply-input::placeholder { - color: var(--text-muted); - } - - .reply-input:focus { - outline: none; - border-color: var(--accent-blue); - } - - .reply-actions { - display: flex; - align-items: center; - justify-content: space-between; - } - - .reply-hint { - font-size: 0.7em; - color: var(--text-muted); - } - - .reply-buttons { - display: flex; - gap: var(--space-2); - } - - .reply-cancel { - padding: var(--space-1) var(--space-3); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - background: var(--bg-secondary); - color: var(--text-muted); - font-size: 0.8em; - cursor: pointer; - } - - .reply-cancel:hover { - color: var(--text-primary); - border-color: var(--text-muted); - } - - .reply-submit { - padding: var(--space-1) var(--space-3); - background: var(--accent-blue); - border: none; - border-radius: var(--radius-sm); - color: #fff; - font-size: 0.8em; - font-weight: 500; - cursor: pointer; - } - - .reply-submit:hover:not(:disabled) { - filter: brightness(1.1); - } - - .reply-submit:disabled { - opacity: 0.5; - cursor: not-allowed; + margin-top: var(--space-2); } .replies {