Skip to content

Commit 6544ef8

Browse files
committed
[attachments] inline images support
1 parent b6be03e commit 6544ef8

12 files changed

Lines changed: 483 additions & 47 deletions

frontend/src/app.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,4 +94,9 @@
9494
.prose a:hover {
9595
color: hsl(var(--muted-foreground));
9696
}
97+
.prose img {
98+
max-width: 100%;
99+
height: auto;
100+
border-radius: 0.375rem;
101+
}
97102
}
Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,76 @@
11
<script lang="ts">
22
import { Textarea } from "$lib/components/ui/textarea";
33
import { Button } from "$lib/components/ui/button";
4+
import InlineImageButton from "$lib/components/InlineImageButton.svelte";
5+
import { InlineImageEditor } from "$lib/inline-image-editor.svelte";
46
import { toast } from "$lib/toast";
57
68
let {
9+
taskId = 0,
710
onSubmit = async (_content: string) => {},
811
}: {
12+
taskId?: number;
913
onSubmit: (content: string) => Promise<void>;
1014
} = $props();
1115
1216
let text = $state("");
1317
let posting = $state(false);
18+
let textareaEl: HTMLTextAreaElement | undefined = $state();
19+
let fileInput: HTMLInputElement | undefined = $state();
20+
21+
const images = new InlineImageEditor({
22+
getTaskId: () => taskId,
23+
getValue: () => text,
24+
setValue: (v) => {
25+
text = v;
26+
},
27+
getTextarea: () => textareaEl,
28+
});
1429
1530
async function handleSubmit() {
1631
if (!text.trim()) return;
32+
if (images.uploading) return;
1733
posting = true;
1834
try {
1935
await onSubmit(text.trim());
2036
text = "";
21-
} catch (e: any) {
22-
toast.error(e.message || "Failed to add comment");
37+
} catch (e) {
38+
toast.error(e instanceof Error ? e.message : "Failed to add comment");
2339
} finally {
2440
posting = false;
2541
}
2642
}
2743
</script>
2844

2945
<div>
30-
<Textarea
31-
bind:value={text}
32-
placeholder="Add a comment..."
33-
class="mb-2 min-h-20"
34-
/>
35-
<Button
36-
onclick={handleSubmit}
37-
disabled={posting || !text.trim()}
46+
<!-- svelte-ignore a11y_no_static_element_interactions -->
47+
<div
48+
ondragover={(e) => images.handleDragOver(e)}
49+
ondrop={(e) => images.handleImageDrop(e)}
3850
>
51+
<Textarea
52+
bind:value={text}
53+
bind:this={textareaEl}
54+
onpaste={(e) => images.handleImagePaste(e)}
55+
placeholder="Add a comment..."
56+
class="mb-2 min-h-20"
57+
/>
58+
</div>
59+
<div class="mb-2 flex items-center gap-2">
60+
<InlineImageButton
61+
uploading={images.uploading}
62+
disabled={!taskId}
63+
onclick={() => fileInput?.click()}
64+
/>
65+
<input
66+
type="file"
67+
bind:this={fileInput}
68+
accept="image/png,image/jpeg,image/gif,image/webp"
69+
class="hidden"
70+
onchange={(e) => images.handleImagePick(e)}
71+
/>
72+
</div>
73+
<Button onclick={handleSubmit} disabled={posting || images.uploading || !text.trim()}>
3974
{posting ? "Posting..." : "Comment"}
4075
</Button>
4176
</div>

frontend/src/lib/components/CommentList.svelte

Lines changed: 89 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22
import { Avatar, AvatarFallback } from "$lib/components/ui/avatar";
33
import { Button } from "$lib/components/ui/button";
44
import { Textarea } from "$lib/components/ui/textarea";
5+
import InlineImageButton from "$lib/components/InlineImageButton.svelte";
56
import { parse } from "marked";
67
import { processAutoLinks } from "$lib/autolink";
8+
import { resolveAttachmentRefs } from "$lib/resolve-attachment-refs";
9+
import { InlineImageEditor } from "$lib/inline-image-editor.svelte";
710
import DOMPurify from "dompurify";
811
import type { AutoLinkContext } from "$lib/autolink";
912
import type { Comment } from "$lib/types/api";
@@ -13,12 +16,16 @@
1316
let {
1417
comments = [] as Comment[],
1518
currentUserId = 0,
19+
taskId = 0,
20+
attachmentsMap = new Map<number, string>(),
1621
autoLinkCtx = {} as AutoLinkContext,
1722
onEdit = async (_commentId: number, _content: string) => {},
1823
onDelete = async (_commentId: number) => {},
1924
}: {
2025
comments: Comment[];
2126
currentUserId: number;
27+
taskId?: number;
28+
attachmentsMap: Map<number, string>;
2229
autoLinkCtx: AutoLinkContext;
2330
onEdit: (commentId: number, content: string) => Promise<void>;
2431
onDelete: (commentId: number) => Promise<void>;
@@ -27,6 +34,18 @@
2734
let editingId = $state<number | null>(null);
2835
let editText = $state("");
2936
let saving = $state(false);
37+
let editTextarea: HTMLTextAreaElement | undefined = $state();
38+
let fileInput: HTMLInputElement | undefined = $state();
39+
40+
const images = new InlineImageEditor({
41+
getTaskId: () => taskId,
42+
getValue: () => editText,
43+
setValue: (v) => {
44+
editText = v;
45+
},
46+
getTextarea: () => editTextarea,
47+
getSessionId: () => editingId,
48+
});
3049
3150
function formatDateTime(d: string): string {
3251
return new Date(d).toLocaleString();
@@ -44,18 +63,24 @@
4463
function renderMarkdown(text: string): string {
4564
if (!text) return "";
4665
return DOMPurify.sanitize(
47-
parse(processAutoLinks(text, autoLinkCtx), {
48-
async: false,
49-
}) as string,
66+
parse(
67+
processAutoLinks(
68+
resolveAttachmentRefs(text, attachmentsMap),
69+
autoLinkCtx,
70+
),
71+
{ async: false },
72+
) as string,
5073
);
5174
}
5275
5376
function startEdit(comment: Comment) {
77+
if (images.uploading) return;
5478
editingId = comment.id;
5579
editText = comment.content;
5680
}
5781
5882
function cancelEdit() {
83+
if (images.uploading) return;
5984
editingId = null;
6085
editText = "";
6186
}
@@ -71,8 +96,8 @@
7196
await onEdit(commentId, editText);
7297
editingId = null;
7398
editText = "";
74-
} catch (e: any) {
75-
toast.error(e.message || "Failed to update comment");
99+
} catch (e) {
100+
toast.error(e instanceof Error ? e.message : "Failed to update comment");
76101
} finally {
77102
saving = false;
78103
}
@@ -97,18 +122,63 @@
97122
</div>
98123

99124
{#if editingId === comment.id}
100-
<Textarea bind:value={editText} class="mb-2 min-h-16" />
125+
<!-- svelte-ignore a11y_no_static_element_interactions -->
126+
<div
127+
ondragover={(e) => {
128+
if (saving) {
129+
e.preventDefault();
130+
return;
131+
}
132+
images.handleDragOver(e);
133+
}}
134+
ondrop={(e) => {
135+
if (saving) {
136+
e.preventDefault();
137+
return;
138+
}
139+
images.handleImageDrop(e);
140+
}}
141+
>
142+
<Textarea
143+
bind:value={editText}
144+
bind:this={editTextarea}
145+
onpaste={(e) => {
146+
if (!saving) images.handleImagePaste(e);
147+
}}
148+
class="mb-2 min-h-16"
149+
/>
150+
</div>
151+
<div class="mb-2 flex items-center gap-2">
152+
<InlineImageButton
153+
uploading={images.uploading}
154+
disabled={!taskId || saving}
155+
onclick={() => fileInput?.click()}
156+
/>
157+
<input
158+
type="file"
159+
disabled={saving || images.uploading}
160+
bind:this={fileInput}
161+
accept="image/png,image/jpeg,image/gif,image/webp"
162+
class="hidden"
163+
onchange={(e) => images.handleImagePick(e)}
164+
/>
165+
</div>
101166
<div class="flex gap-2">
102167
<Button
103168
size="sm"
104169
onclick={() => handleEdit(comment.id)}
105-
disabled={saving}
170+
disabled={saving || images.uploading}
106171
>
107172
{saving ? "Saving..." : "Save"}
108173
</Button>
109-
<Button size="sm" variant="ghost" onclick={cancelEdit}
110-
>Cancel</Button
174+
<Button
175+
size="sm"
176+
variant="ghost"
177+
onclick={cancelEdit}
178+
disabled={images.uploading}
111179
>
180+
Cancel
181+
</Button>
112182
</div>
113183
{:else}
114184
<div class="prose prose-sm dark:prose-invert max-w-none text-sm">
@@ -117,17 +187,23 @@
117187
{#if canManageComment(comment)}
118188
<div class="mt-1 flex gap-2">
119189
<button
120-
class="text-muted-foreground hover:text-foreground cursor-pointer text-xs underline underline-offset-2"
190+
class="text-muted-foreground hover:text-foreground cursor-pointer text-xs underline underline-offset-2 disabled:opacity-50 disabled:no-underline"
191+
disabled={images.uploading}
121192
onclick={() => startEdit(comment)}>Edit</button
122193
>
123194
<button
124-
class="text-muted-foreground hover:text-destructive cursor-pointer text-xs underline underline-offset-2"
195+
class="text-muted-foreground hover:text-destructive cursor-pointer text-xs underline underline-offset-2 disabled:opacity-50 disabled:no-underline"
196+
disabled={images.uploading}
125197
onclick={async () => {
126198
if (confirm("Delete this comment?")) {
127199
try {
128200
await onDelete(comment.id);
129-
} catch (e: any) {
130-
toast.error(e.message || "Failed to delete comment");
201+
} catch (e) {
202+
toast.error(
203+
e instanceof Error
204+
? e.message
205+
: "Failed to delete comment",
206+
);
131207
}
132208
}
133209
}}>Delete</button
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<script lang="ts">
2+
let {
3+
uploading = false,
4+
disabled = false,
5+
onclick,
6+
}: {
7+
uploading?: boolean;
8+
disabled?: boolean;
9+
onclick: () => void;
10+
} = $props();
11+
</script>
12+
13+
<button
14+
type="button"
15+
{onclick}
16+
disabled={disabled || uploading}
17+
class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-50"
18+
title="Insert image"
19+
>
20+
{#if uploading}
21+
<span
22+
class="size-3 animate-spin rounded-full border-2 border-current border-t-transparent"
23+
></span>
24+
Uploading...
25+
{:else}
26+
<svg
27+
xmlns="http://www.w3.org/2000/svg"
28+
viewBox="0 0 20 20"
29+
fill="currentColor"
30+
class="size-4"
31+
><path
32+
fill-rule="evenodd"
33+
d="M1 5.25A2.25 2.25 0 0 1 3.25 3h13.5A2.25 2.25 0 0 1 19 5.25v9.5A2.25 2.25 0 0 1 16.75 17H3.25A2.25 2.25 0 0 1 1 14.75v-9.5Zm1.5 5.81v3.69c0 .414.336.75.75.75h13.5a.75.75 0 0 0 .75-.75v-2.69l-2.22-2.219a.75.75 0 0 0-1.06 0l-1.91 1.909.47.47a.75.75 0 1 1-1.06 1.06L6.53 8.091a.75.75 0 0 0-1.06 0l-2.97 2.97ZM12 7a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"
34+
clip-rule="evenodd"
35+
/></svg
36+
>
37+
Image
38+
{/if}
39+
</button>

0 commit comments

Comments
 (0)