|
6 | 6 | import AssigneeCombobox from "$lib/components/AssigneeCombobox.svelte"; |
7 | 7 | import * as Card from "$lib/components/ui/card"; |
8 | 8 | import { PRIORITIES, KINDS, STATUSES, type Project } from "$lib/types/api"; |
| 9 | + import { uploadInlineImage } from "$lib/upload-image"; |
| 10 | + import { toast } from "$lib/toast"; |
9 | 11 |
|
10 | 12 | interface TaskFormData { |
11 | 13 | project_slug: string; |
|
20 | 22 |
|
21 | 23 | interface Props { |
22 | 24 | mode?: "create" | "edit"; |
| 25 | + taskId?: number; |
23 | 26 | projects?: Project[]; |
24 | 27 | initialTitle?: string; |
25 | 28 | initialDescription?: string; |
|
37 | 40 |
|
38 | 41 | let { |
39 | 42 | mode = "create", |
| 43 | + taskId = 0, |
40 | 44 | projects = [] as Project[], |
41 | 45 | initialTitle = "", |
42 | 46 | initialDescription = "", |
|
74 | 78 |
|
75 | 79 | let saving = $state(false); |
76 | 80 | 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(``); |
| 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 | + } |
77 | 157 |
|
78 | 158 | async function handleSubmit() { |
79 | 159 | if (!title.trim()) { |
|
154 | 234 | </div> |
155 | 235 |
|
156 | 236 | <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} |
163 | 293 | /> |
164 | 294 | </div> |
165 | 295 |
|
|
0 commit comments