This repository has no description
1<script lang="ts">
2 import { resolve } from "$app/paths";
3 import Pencil from "$icon/pencil";
4 import Trash2 from "$icon/trash-2";
5 import { deleteComment } from "$lib/api/comment";
6 import { getAuth } from "$lib/auth.svelte";
7 import { type MarkupContext } from "$lib/markup";
8 import ErrorAlert from "$lib/components/ui/Error.svelte";
9 import Comment from "./Comment.svelte";
10 import CommentBox from "./CommentBox.svelte";
11 import CommentEditor from "./CommentEditor.svelte";
12 import type { CommentThread, CommentView, ThreadInput } from "./comments";
13
14 interface Props {
15 thread: CommentThread;
16 subjectUri: string;
17 subjectCid?: string;
18 markup: MarkupContext;
19 onsubmitted?: (submitted: ThreadInput) => void;
20 onedited?: (edited: ThreadInput) => void;
21 ondeleted?: (uri: string) => void;
22 }
23
24 let { thread, subjectUri, subjectCid, markup, onsubmitted, onedited, ondeleted }: Props =
25 $props();
26
27 const auth = getAuth();
28 const currentUser = $derived(auth?.currentUser ?? null);
29
30 let replying = $state(false);
31 let editingUri = $state<string | null>(null);
32 let deletingUri = $state<string | null>(null);
33 let deleteError = $state<string | null>(null);
34
35 const handleDelete = async (comment: CommentView) => {
36 const agent = auth?.agent;
37 if (!agent || deletingUri) return;
38 if (!confirm("Delete this comment? This cannot be undone.")) return;
39 deletingUri = comment.uri;
40 deleteError = null;
41 try {
42 await deleteComment(agent, comment.rkey);
43 ondeleted?.(comment.uri);
44 } catch (err) {
45 deleteError = err instanceof Error ? err.message : "Failed to delete comment";
46 } finally {
47 deletingUri = null;
48 }
49 };
50</script>
51
52{#snippet commentActions(comment: CommentView)}
53 {#if currentUser?.did === comment.authorDid}
54 <div
55 class="absolute top-4 right-4 flex items-center gap-2 opacity-0 transition group-hover:opacity-100 focus-within:opacity-100"
56 >
57 <button
58 type="button"
59 aria-label="Edit comment"
60 class="cursor-pointer text-foreground-subtle hover:text-foreground-default"
61 onclick={() => (editingUri = comment.uri)}
62 >
63 <Pencil class="size-3" />
64 </button>
65 <button
66 type="button"
67 aria-label="Delete comment"
68 disabled={deletingUri === comment.uri}
69 class="cursor-pointer text-foreground-danger hover:text-foreground-danger-strong disabled:opacity-50"
70 onclick={() => handleDelete(comment)}
71 >
72 <Trash2 class="size-3" />
73 </button>
74 </div>
75 {/if}
76{/snippet}
77
78{#snippet commentEditor(
79 comment: CommentView,
80 replyUri: string | undefined,
81 replyCid: string | undefined
82)}
83 <CommentEditor
84 {subjectUri}
85 {subjectCid}
86 replyToUri={replyUri}
87 replyToCid={replyCid}
88 authorDid={comment.authorDid}
89 authorHandle={comment.authorHandle}
90 rkey={comment.rkey}
91 createdAt={comment.createdAt}
92 body={comment.body}
93 {markup}
94 rows={4}
95 placeholder="Edit your comment. Markdown is supported."
96 submitLabel="Save"
97 submitIcon={Pencil}
98 autofocus
99 onsubmitted={(edited) => {
100 editingUri = null;
101 onedited?.(edited);
102 }}
103 oncancel={() => (editingUri = null)}
104 />
105{/snippet}
106
107<div
108 class="overflow-hidden rounded border border-border-default bg-background-canvas bg-background-default/50 drop-shadow-xs"
109>
110 {#snippet selfEditor()}
111 {@render commentEditor(thread.self, undefined, undefined)}
112 {/snippet}
113 <div class="group relative">
114 <Comment
115 authorHandle={thread.self.authorHandle}
116 authorDid={thread.self.authorDid}
117 createdAt={thread.self.createdAt}
118 body={thread.self.body}
119 bodyHtml={thread.self.bodyHtml}
120 variant="top"
121 deleted={thread.self.deleted}
122 editor={editingUri === thread.self.uri ? selfEditor : undefined}
123 />
124 {#if editingUri !== thread.self.uri && !thread.self.deleted}
125 {@render commentActions(thread.self)}
126 {/if}
127 </div>
128
129 {#if thread.replies.length}
130 <div class="ml-10">
131 {#each thread.replies as reply, i (reply.uri)}
132 {#snippet replyEditor()}
133 {@render commentEditor(reply, thread.self.uri, thread.self.cid)}
134 {/snippet}
135 <div class="group relative isolate -ml-4">
136 <!-- thread connector; on the last reply it stops at the avatar's centre (h-8) -->
137 <div
138 class={`pointer-events-none absolute top-0 left-4 -z-10 w-0.5 bg-border-default ${i === thread.replies.length - 1 ? "h-8" : "bottom-0"}`}
139 ></div>
140 <Comment
141 authorHandle={reply.authorHandle}
142 authorDid={reply.authorDid}
143 createdAt={reply.createdAt}
144 body={reply.body}
145 bodyHtml={reply.bodyHtml}
146 variant="reply"
147 editor={editingUri === reply.uri ? replyEditor : undefined}
148 />
149 {#if editingUri !== reply.uri}
150 {@render commentActions(reply)}
151 {/if}
152 </div>
153 {/each}
154 </div>
155 {/if}
156
157 {#if deleteError}
158 <div class="border-t border-border-default px-6 py-2">
159 <ErrorAlert label={deleteError} />
160 </div>
161 {/if}
162
163 {#if thread.self.deleted && !thread.self.cid}
164 <!-- deleted parent with no recoverable strongRef; nothing to reply against -->
165 {:else if replying && currentUser}
166 <div class={thread.replies.length ? "border-t border-border-default" : ""}>
167 <CommentBox
168 variant="thread"
169 {subjectUri}
170 {subjectCid}
171 replyToUri={thread.self.uri}
172 replyToCid={thread.self.cid}
173 authorDid={currentUser.did}
174 authorHandle={currentUser.handle}
175 {markup}
176 autofocus
177 onsubmitted={(submitted) => {
178 replying = false;
179 onsubmitted?.(submitted);
180 }}
181 oncancel={() => (replying = false)}
182 />
183 </div>
184 {:else}
185 <div
186 class={`flex items-center gap-2 bg-background-default/50 px-6 py-2 ${thread.replies.length ? "border-t border-border-default" : ""}`}
187 >
188 {#if currentUser}
189 <button
190 type="button"
191 class="w-full cursor-text text-left text-foreground-subtle focus:outline-none"
192 onclick={() => (replying = true)}
193 >
194 Leave a reply...
195 </button>
196 {:else}
197 <span class="text-foreground-subtle">
198 <a href={resolve("/login")} class="underline">Login</a> to leave a reply
199 </span>
200 {/if}
201 </div>
202 {/if}
203</div>