Skip to content

Commit afac4d1

Browse files
committed
[attachments] inline images support
1 parent 2559a3e commit afac4d1

10 files changed

Lines changed: 406 additions & 23 deletions

File tree

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
}

frontend/src/lib/components/CommentForm.svelte

Lines changed: 125 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,94 @@
11
<script lang="ts">
22
import { Textarea } from "$lib/components/ui/textarea";
33
import { Button } from "$lib/components/ui/button";
4+
import { uploadInlineImage } from "$lib/upload-image";
45
import { toast } from "$lib/toast";
56
67
let {
8+
taskId = 0,
79
onSubmit = async (_content: string) => {},
810
}: {
11+
taskId?: number;
912
onSubmit: (content: string) => Promise<void>;
1013
} = $props();
1114
1215
let text = $state("");
1316
let posting = $state(false);
17+
let uploading = $state(false);
18+
let textareaEl: HTMLTextAreaElement | undefined = $state();
19+
let fileInput: HTMLInputElement | undefined = $state();
20+
21+
function insertAtCursor(insertion: string) {
22+
const el = textareaEl;
23+
if (!el) return;
24+
const start = el.selectionStart ?? text.length;
25+
const end = el.selectionEnd ?? start;
26+
const before = text.slice(0, start);
27+
const after = text.slice(end);
28+
text = before + insertion + after;
29+
requestAnimationFrame(() => {
30+
const pos = start + insertion.length;
31+
el.selectionStart = pos;
32+
el.selectionEnd = pos;
33+
el.focus();
34+
});
35+
}
36+
37+
async function uploadAndInsert(file: File) {
38+
if (uploading || !taskId) return;
39+
uploading = true;
40+
try {
41+
const result = await uploadInlineImage(taskId, file);
42+
const name = result.fileName.replace(/\.[^.]+$/, "").replace(/[[\]\\]/g, "");
43+
insertAtCursor(`![${name}](attachment:${result.attachmentId})`);
44+
toast.success("Image uploaded");
45+
} catch (e: any) {
46+
toast.error(e.message || "Image upload failed");
47+
} finally {
48+
uploading = false;
49+
}
50+
}
51+
52+
function handleImagePick(e: Event) {
53+
const input = e.target as HTMLInputElement;
54+
const file = input.files?.[0];
55+
if (file) uploadAndInsert(file);
56+
input.value = "";
57+
}
58+
59+
function handleImagePaste(e: ClipboardEvent) {
60+
if (!taskId) return;
61+
const items = e.clipboardData?.items;
62+
if (!items) return;
63+
for (const item of items) {
64+
if (item.kind === "file" && item.type.startsWith("image/")) {
65+
e.preventDefault();
66+
const file = item.getAsFile();
67+
if (file) uploadAndInsert(file);
68+
return;
69+
}
70+
}
71+
}
72+
73+
async function handleImageDrop(e: DragEvent) {
74+
if (!taskId) return;
75+
e.preventDefault();
76+
const files = e.dataTransfer?.files;
77+
if (!files) return;
78+
for (let i = 0; i < files.length; i++) {
79+
const file = files[i];
80+
if (file.type.startsWith("image/")) {
81+
await uploadAndInsert(file);
82+
} else {
83+
toast.error(`"${file.name}" is not an image. Only images can be dropped.`);
84+
}
85+
}
86+
}
87+
88+
function handleDragOver(e: DragEvent) {
89+
if (!taskId) return;
90+
e.preventDefault();
91+
}
1492
1593
async function handleSubmit() {
1694
if (!text.trim()) return;
@@ -27,11 +105,53 @@
27105
</script>
28106

29107
<div>
30-
<Textarea
31-
bind:value={text}
32-
placeholder="Add a comment..."
33-
class="mb-2 min-h-20"
34-
/>
108+
<!-- svelte-ignore a11y_no_static_element_interactions -->
109+
<div
110+
ondragover={handleDragOver}
111+
ondrop={handleImageDrop}
112+
>
113+
<Textarea
114+
bind:value={text}
115+
bind:this={textareaEl}
116+
onpaste={handleImagePaste}
117+
placeholder="Add a comment..."
118+
class="mb-2 min-h-20"
119+
/>
120+
</div>
121+
<div class="mb-2 flex items-center gap-2">
122+
<button
123+
type="button"
124+
onclick={() => fileInput?.click()}
125+
disabled={uploading || !taskId}
126+
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"
127+
title="Insert image"
128+
>
129+
{#if uploading}
130+
<span class="size-3 animate-spin rounded-full border-2 border-current border-t-transparent"></span>
131+
Uploading...
132+
{:else}
133+
<svg
134+
xmlns="http://www.w3.org/2000/svg"
135+
viewBox="0 0 20 20"
136+
fill="currentColor"
137+
class="size-4"
138+
><path
139+
fill-rule="evenodd"
140+
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"
141+
clip-rule="evenodd"
142+
/></svg
143+
>
144+
Image
145+
{/if}
146+
</button>
147+
<input
148+
type="file"
149+
bind:this={fileInput}
150+
accept="image/png,image/jpeg,image/gif,image/webp"
151+
class="hidden"
152+
onchange={handleImagePick}
153+
/>
154+
</div>
35155
<Button
36156
onclick={handleSubmit}
37157
disabled={posting || !text.trim()}

frontend/src/lib/components/CommentList.svelte

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import { Textarea } from "$lib/components/ui/textarea";
55
import { parse } from "marked";
66
import { processAutoLinks } from "$lib/autolink";
7+
import { resolveAttachmentRefs } from "$lib/resolve-attachment-refs";
78
import DOMPurify from "dompurify";
89
import type { AutoLinkContext } from "$lib/autolink";
910
import type { Comment } from "$lib/types/api";
@@ -13,12 +14,14 @@
1314
let {
1415
comments = [] as Comment[],
1516
currentUserId = 0,
17+
attachmentsMap = new Map<number, string>(),
1618
autoLinkCtx = {} as AutoLinkContext,
1719
onEdit = async (_commentId: number, _content: string) => {},
1820
onDelete = async (_commentId: number) => {},
1921
}: {
2022
comments: Comment[];
2123
currentUserId: number;
24+
attachmentsMap: Map<number, string>;
2225
autoLinkCtx: AutoLinkContext;
2326
onEdit: (commentId: number, content: string) => Promise<void>;
2427
onDelete: (commentId: number) => Promise<void>;
@@ -44,9 +47,10 @@
4447
function renderMarkdown(text: string): string {
4548
if (!text) return "";
4649
return DOMPurify.sanitize(
47-
parse(processAutoLinks(text, autoLinkCtx), {
48-
async: false,
49-
}) as string,
50+
parse(
51+
processAutoLinks(resolveAttachmentRefs(text, attachmentsMap), autoLinkCtx),
52+
{ async: false },
53+
) as string,
5054
);
5155
}
5256

frontend/src/lib/components/TaskForm.svelte

Lines changed: 136 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import AssigneeCombobox from "$lib/components/AssigneeCombobox.svelte";
77
import * as Card from "$lib/components/ui/card";
88
import { PRIORITIES, KINDS, STATUSES, type Project } from "$lib/types/api";
9+
import { uploadInlineImage } from "$lib/upload-image";
10+
import { toast } from "$lib/toast";
911
1012
interface TaskFormData {
1113
project_slug: string;
@@ -20,6 +22,7 @@
2022
2123
interface Props {
2224
mode?: "create" | "edit";
25+
taskId?: number;
2326
projects?: Project[];
2427
initialTitle?: string;
2528
initialDescription?: string;
@@ -37,6 +40,7 @@
3740
3841
let {
3942
mode = "create",
43+
taskId = 0,
4044
projects = [] as Project[],
4145
initialTitle = "",
4246
initialDescription = "",
@@ -74,6 +78,82 @@
7478
7579
let saving = $state(false);
7680
let error = $state("");
81+
let uploading = $state(false);
82+
let descriptionTextarea: HTMLTextAreaElement | undefined = $state();
83+
let fileInput: HTMLInputElement | undefined = $state();
84+
85+
function insertAtCursor(text: string) {
86+
const el = descriptionTextarea;
87+
if (!el) return;
88+
const start = el.selectionStart ?? description.length;
89+
const end = el.selectionEnd ?? start;
90+
const before = description.slice(0, start);
91+
const after = description.slice(end);
92+
description = before + text + after;
93+
requestAnimationFrame(() => {
94+
const pos = start + text.length;
95+
el.selectionStart = pos;
96+
el.selectionEnd = pos;
97+
el.focus();
98+
});
99+
}
100+
101+
async function uploadAndInsert(file: File) {
102+
if (uploading) return;
103+
if (!taskId) return;
104+
uploading = true;
105+
try {
106+
const result = await uploadInlineImage(taskId, file);
107+
const name = result.fileName.replace(/\.[^.]+$/, "").replace(/[[\]\\]/g, "");
108+
insertAtCursor(`![${name}](attachment:${result.attachmentId})`);
109+
toast.success("Image uploaded");
110+
} catch (e: any) {
111+
toast.error(e.message || "Image upload failed");
112+
} finally {
113+
uploading = false;
114+
}
115+
}
116+
117+
function handleImagePick(e: Event) {
118+
const input = e.target as HTMLInputElement;
119+
const file = input.files?.[0];
120+
if (file) uploadAndInsert(file);
121+
input.value = "";
122+
}
123+
124+
function handleImagePaste(e: ClipboardEvent) {
125+
if (!taskId) return;
126+
const items = e.clipboardData?.items;
127+
if (!items) return;
128+
for (const item of items) {
129+
if (item.kind === "file" && item.type.startsWith("image/")) {
130+
e.preventDefault();
131+
const file = item.getAsFile();
132+
if (file) uploadAndInsert(file);
133+
return;
134+
}
135+
}
136+
}
137+
138+
async function handleImageDrop(e: DragEvent) {
139+
if (!taskId) return;
140+
e.preventDefault();
141+
const files = e.dataTransfer?.files;
142+
if (!files) return;
143+
for (let i = 0; i < files.length; i++) {
144+
const file = files[i];
145+
if (file.type.startsWith("image/")) {
146+
await uploadAndInsert(file);
147+
} else {
148+
toast.error(`"${file.name}" is not an image. Only images can be dropped.`);
149+
}
150+
}
151+
}
152+
153+
function handleDragOver(e: DragEvent) {
154+
if (!taskId) return;
155+
e.preventDefault();
156+
}
77157
78158
async function handleSubmit() {
79159
if (!title.trim()) {
@@ -154,12 +234,62 @@
154234
</div>
155235

156236
<div class="space-y-1.5">
157-
<label for="description" class="text-sm font-medium">Description</label>
158-
<Textarea
159-
id="description"
160-
bind:value={description}
161-
placeholder="Optional description (supports Markdown)"
162-
class="min-h-32"
237+
<div class="flex items-center justify-between">
238+
<label for="description" class="text-sm font-medium"
239+
>Description</label
240+
>
241+
{#if taskId > 0}
242+
<button
243+
type="button"
244+
onclick={() => fileInput?.click()}
245+
disabled={uploading}
246+
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"
247+
title="Insert image"
248+
>
249+
{#if uploading}
250+
<span class="size-3 animate-spin rounded-full border-2 border-current border-t-transparent"></span>
251+
Uploading...
252+
{:else}
253+
<svg
254+
xmlns="http://www.w3.org/2000/svg"
255+
viewBox="0 0 20 20"
256+
fill="currentColor"
257+
class="size-4"
258+
><path
259+
fill-rule="evenodd"
260+
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"
261+
clip-rule="evenodd"
262+
/></svg
263+
>
264+
Image
265+
{/if}
266+
</button>
267+
{:else}
268+
<span class="text-xs text-muted-foreground"
269+
>Image upload available when editing</span
270+
>
271+
{/if}
272+
</div>
273+
<!-- svelte-ignore a11y_no_static_element_interactions -->
274+
<div
275+
ondragover={handleDragOver}
276+
ondrop={handleImageDrop}
277+
>
278+
<Textarea
279+
id="description"
280+
bind:value={description}
281+
bind:this={descriptionTextarea}
282+
onpaste={handleImagePaste}
283+
placeholder="Optional description (supports Markdown)"
284+
class="min-h-32"
285+
/>
286+
</div>
287+
<input
288+
type="file"
289+
bind:this={fileInput}
290+
accept="image/png,image/jpeg,image/gif,image/webp"
291+
class="hidden"
292+
onchange={handleImagePick}
163293
/>
164294
</div>
165295

0 commit comments

Comments
 (0)