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 @@
+
+
+
+
+
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 {