Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions frontend/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -94,4 +94,9 @@
.prose a:hover {
color: hsl(var(--muted-foreground));
}
.prose img {
max-width: 100%;
height: auto;
border-radius: 0.375rem;
}
}
55 changes: 45 additions & 10 deletions frontend/src/lib/components/CommentForm.svelte
Original file line number Diff line number Diff line change
@@ -1,41 +1,76 @@
<script lang="ts">
import { Textarea } from "$lib/components/ui/textarea";
import { Button } from "$lib/components/ui/button";
import InlineImageButton from "$lib/components/InlineImageButton.svelte";
import { InlineImageEditor } from "$lib/inline-image-editor.svelte";
import { toast } from "$lib/toast";

let {
taskId = 0,
onSubmit = async (_content: string) => {},
}: {
taskId?: number;
onSubmit: (content: string) => Promise<void>;
} = $props();

let text = $state("");
let posting = $state(false);
let textareaEl: HTMLTextAreaElement | undefined = $state();
let fileInput: HTMLInputElement | undefined = $state();

const images = new InlineImageEditor({
getTaskId: () => taskId,
getValue: () => text,
setValue: (v) => {
text = v;
},
getTextarea: () => textareaEl,
});

async function handleSubmit() {
if (!text.trim()) return;
if (images.uploading) return;
posting = true;
try {
await onSubmit(text.trim());
text = "";
} catch (e: any) {
toast.error(e.message || "Failed to add comment");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to add comment");
} finally {
posting = false;
}
}
</script>

<div>
<Textarea
bind:value={text}
placeholder="Add a comment..."
class="mb-2 min-h-20"
/>
<Button
onclick={handleSubmit}
disabled={posting || !text.trim()}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
ondragover={(e) => images.handleDragOver(e)}
ondrop={(e) => images.handleImageDrop(e)}
>
<Textarea
bind:value={text}
bind:this={textareaEl}
onpaste={(e) => images.handleImagePaste(e)}
placeholder="Add a comment..."
class="mb-2 min-h-20"
/>
</div>
<div class="mb-2 flex items-center gap-2">
<InlineImageButton
uploading={images.uploading}
disabled={!taskId}
onclick={() => fileInput?.click()}
/>
<input
type="file"
bind:this={fileInput}
accept="image/png,image/jpeg,image/gif,image/webp"
class="hidden"
onchange={(e) => images.handleImagePick(e)}
/>
</div>
<Button onclick={handleSubmit} disabled={posting || images.uploading || !text.trim()}>
{posting ? "Posting..." : "Comment"}
</Button>
</div>
102 changes: 89 additions & 13 deletions frontend/src/lib/components/CommentList.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@
import { Avatar, AvatarFallback } from "$lib/components/ui/avatar";
import { Button } from "$lib/components/ui/button";
import { Textarea } from "$lib/components/ui/textarea";
import InlineImageButton from "$lib/components/InlineImageButton.svelte";
import { parse } from "marked";
import { processAutoLinks } from "$lib/autolink";
import { resolveAttachmentRefs } from "$lib/resolve-attachment-refs";
import { InlineImageEditor } from "$lib/inline-image-editor.svelte";
import DOMPurify from "dompurify";
import type { AutoLinkContext } from "$lib/autolink";
import type { Comment } from "$lib/types/api";
Expand All @@ -13,12 +16,16 @@
let {
comments = [] as Comment[],
currentUserId = 0,
taskId = 0,
attachmentsMap = new Map<number, string>(),
autoLinkCtx = {} as AutoLinkContext,
onEdit = async (_commentId: number, _content: string) => {},
onDelete = async (_commentId: number) => {},
}: {
comments: Comment[];
currentUserId: number;
taskId?: number;
attachmentsMap: Map<number, string>;
autoLinkCtx: AutoLinkContext;
onEdit: (commentId: number, content: string) => Promise<void>;
onDelete: (commentId: number) => Promise<void>;
Expand All @@ -27,6 +34,18 @@
let editingId = $state<number | null>(null);
let editText = $state("");
let saving = $state(false);
let editTextarea: HTMLTextAreaElement | undefined = $state();
let fileInput: HTMLInputElement | undefined = $state();

const images = new InlineImageEditor({
getTaskId: () => taskId,
getValue: () => editText,
setValue: (v) => {
editText = v;
},
getTextarea: () => editTextarea,
getSessionId: () => editingId,
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function formatDateTime(d: string): string {
return new Date(d).toLocaleString();
Expand All @@ -44,18 +63,24 @@
function renderMarkdown(text: string): string {
if (!text) return "";
return DOMPurify.sanitize(
parse(processAutoLinks(text, autoLinkCtx), {
async: false,
}) as string,
parse(
processAutoLinks(
resolveAttachmentRefs(text, attachmentsMap),
autoLinkCtx,
),
{ async: false },
) as string,
);
}

function startEdit(comment: Comment) {
if (images.uploading) return;
editingId = comment.id;
editText = comment.content;
}

function cancelEdit() {
if (images.uploading) return;
editingId = null;
editText = "";
}
Expand All @@ -71,8 +96,8 @@
await onEdit(commentId, editText);
editingId = null;
editText = "";
} catch (e: any) {
toast.error(e.message || "Failed to update comment");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to update comment");
} finally {
saving = false;
}
Expand All @@ -97,18 +122,63 @@
</div>

{#if editingId === comment.id}
<Textarea bind:value={editText} class="mb-2 min-h-16" />
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
ondragover={(e) => {
if (saving) {
e.preventDefault();
return;
}
images.handleDragOver(e);
}}
ondrop={(e) => {
if (saving) {
e.preventDefault();
return;
}
images.handleImageDrop(e);
}}
>
<Textarea
bind:value={editText}
bind:this={editTextarea}
onpaste={(e) => {
if (!saving) images.handleImagePaste(e);
}}
class="mb-2 min-h-16"
/>
</div>
<div class="mb-2 flex items-center gap-2">
<InlineImageButton
uploading={images.uploading}
disabled={!taskId || saving}
onclick={() => fileInput?.click()}
/>
<input
type="file"
disabled={saving || images.uploading}
bind:this={fileInput}
accept="image/png,image/jpeg,image/gif,image/webp"
class="hidden"
onchange={(e) => images.handleImagePick(e)}
/>
</div>
<div class="flex gap-2">
<Button
size="sm"
onclick={() => handleEdit(comment.id)}
disabled={saving}
disabled={saving || images.uploading}
>
{saving ? "Saving..." : "Save"}
</Button>
<Button size="sm" variant="ghost" onclick={cancelEdit}
>Cancel</Button
<Button
size="sm"
variant="ghost"
onclick={cancelEdit}
disabled={images.uploading}
>
Cancel
</Button>
</div>
{:else}
<div class="prose prose-sm dark:prose-invert max-w-none text-sm">
Expand All @@ -117,17 +187,23 @@
{#if canManageComment(comment)}
<div class="mt-1 flex gap-2">
<button
class="text-muted-foreground hover:text-foreground cursor-pointer text-xs underline underline-offset-2"
class="text-muted-foreground hover:text-foreground cursor-pointer text-xs underline underline-offset-2 disabled:opacity-50 disabled:no-underline"
disabled={images.uploading}
onclick={() => startEdit(comment)}>Edit</button
>
<button
class="text-muted-foreground hover:text-destructive cursor-pointer text-xs underline underline-offset-2"
class="text-muted-foreground hover:text-destructive cursor-pointer text-xs underline underline-offset-2 disabled:opacity-50 disabled:no-underline"
disabled={images.uploading}
onclick={async () => {
if (confirm("Delete this comment?")) {
try {
await onDelete(comment.id);
} catch (e: any) {
toast.error(e.message || "Failed to delete comment");
} catch (e) {
toast.error(
e instanceof Error
? e.message
: "Failed to delete comment",
);
}
}
}}>Delete</button
Expand Down
39 changes: 39 additions & 0 deletions frontend/src/lib/components/InlineImageButton.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<script lang="ts">
let {
uploading = false,
disabled = false,
onclick,
}: {
uploading?: boolean;
disabled?: boolean;
onclick: () => void;
} = $props();
</script>

<button
type="button"
{onclick}
disabled={disabled || uploading}
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"
title="Insert image"
>
{#if uploading}
<span
class="size-3 animate-spin rounded-full border-2 border-current border-t-transparent"
></span>
Uploading...
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-4"
><path
fill-rule="evenodd"
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"
clip-rule="evenodd"
/></svg
>
Image
{/if}
</button>
Loading
Loading