added replies

This commit is contained in:
liyunze 2026-09-01 22:46:45 +08:00
parent 3130a1affa
commit 9be3907e0f
8 changed files with 411 additions and 77 deletions

View file

@ -0,0 +1,262 @@
<script lang="ts">
import { mutation } from "$lib/api";
import { getToken, currentUser, isAuthenticated } from "$lib/stores/auth";
import { get } from "svelte/store";
import { timeAgo } from "$lib/time";
import CommentNode from "./CommentNode.svelte";
import type { CommentDoc } from "$lib/types";
interface CommentTreeNode {
comment: CommentDoc;
children: CommentTreeNode[];
}
let {
comment,
children = [],
targetType,
targetId,
reload,
}: {
comment: CommentDoc;
children?: CommentTreeNode[];
targetType: "note" | "question";
targetId: string;
reload: () => Promise<void>;
} = $props();
let collapsed = $state(false);
let replying = $state(false);
let replyText = $state("");
let replyLoading = $state(false);
let replyError = $state("");
let editing = $state(false);
let editText = $state("");
let editLoading = $state(false);
let editError = $state("");
let deleteLoading = $state(false);
const isMine = $derived(get(currentUser)?._id === comment.authorId);
const edited = $derived(comment.updatedAt != null && comment.updatedAt > comment.createdAt);
async function submitReply() {
if (!replyText.trim()) return;
const token = getToken();
if (!token) {
replyError = "Please sign in to reply";
return;
}
replyLoading = true;
replyError = "";
try {
await mutation(
targetType === "note" ? "comments:createOnNote" : "comments:createOnQuestion",
targetType === "note"
? {
token,
content: replyText.trim(),
parentId: targetId,
parentCommentId: comment._id,
}
: {
token,
content: replyText.trim(),
questionId: targetId,
parentCommentId: comment._id,
},
);
replyText = "";
replying = false;
collapsed = false;
await reload();
} catch (err: any) {
replyError = err.message ?? "Failed to post reply";
} finally {
replyLoading = false;
}
}
function startEdit() {
editText = comment.content;
editError = "";
editing = true;
}
function cancelEdit() {
editing = false;
editError = "";
}
async function saveEdit() {
if (!editText.trim()) {
editError = "Comment cannot be empty";
return;
}
const token = getToken();
if (!token) {
editError = "You must be signed in";
return;
}
editLoading = true;
editError = "";
try {
await mutation("comments:update", { token, id: comment._id, content: editText.trim() });
editing = false;
await reload();
} catch (err: any) {
editError = err.message ?? "Failed to save";
} finally {
editLoading = false;
}
}
async function remove() {
const token = getToken();
if (!token) return;
if (!confirm("Delete this comment and its replies?")) return;
deleteLoading = true;
try {
await mutation("comments:remove", { token, id: comment._id });
await reload();
} catch (err: any) {
alert(err.message ?? "Failed to delete");
} finally {
deleteLoading = false;
}
}
</script>
<div class="py-3">
<div class="flex gap-3">
<div class="min-w-0 flex-1">
<p class="kicker">
{comment.authorName} · <span class="text-faint">{timeAgo(comment.createdAt)}</span>
{#if edited}
· Edited
{/if}
</p>
{#if editing}
<textarea bind:value={editText} rows={3} class="field mt-2 resize-y"></textarea>
<div class="mt-2 flex items-center gap-3">
<button
type="button"
onclick={saveEdit}
disabled={editLoading}
class="btn-primary"
>
{editLoading ? "Saving..." : "Save"}
</button>
<button type="button" onclick={cancelEdit} class="kicker hover:text-ink">
Cancel
</button>
<span class="text-primary text-xs">{editError}</span>
</div>
{:else}
<p class="text-ink mt-1 text-sm leading-relaxed whitespace-pre-wrap">
{comment.content}
</p>
{/if}
<div class="mt-2 flex items-center gap-3">
{#if get(isAuthenticated)}
<button
type="button"
onclick={() => {
replying = !replying;
replyError = "";
}}
class="text-secondary hover:text-secondary-dark text-xs"
>
Reply
</button>
{/if}
{#if isMine}
{#if !editing}
<button
type="button"
onclick={startEdit}
class="text-secondary hover:text-secondary-dark text-xs"
>
Edit
</button>
{/if}
<button
type="button"
onclick={remove}
disabled={deleteLoading}
class="text-primary hover:text-primary-dark text-xs"
>
{deleteLoading ? "Deleting..." : "Delete"}
</button>
{/if}
</div>
{#if replying}
<div class="mt-3">
<textarea
bind:value={replyText}
rows={2}
placeholder="Reply..."
class="field resize-y"></textarea>
<div class="mt-2 flex items-center gap-3">
<button
type="button"
onclick={submitReply}
disabled={replyLoading || !replyText.trim()}
class="btn-primary"
>
{replyLoading ? "Posting..." : "Post reply"}
</button>
<button
type="button"
onclick={() => (replying = false)}
class="kicker hover:text-ink"
>
Cancel
</button>
<span class="text-primary text-xs">{replyError}</span>
</div>
</div>
{/if}
</div>
</div>
{#if children.length > 0}
<div class="mt-1 flex">
<button
type="button"
class="thread-branch"
onclick={() => (collapsed = !collapsed)}
title={collapsed ? "Show replies" : "Hide replies"}
aria-label={collapsed ? "Show replies" : "Hide replies"}
></button>
<div class="min-w-0 flex-1 pl-3">
{#if collapsed}
<button
type="button"
onclick={() => (collapsed = false)}
class="text-muted hover:text-ink py-1 text-xs"
>
Show {children.length} repl{children.length === 1 ? "y" : "ies"}
</button>
{:else}
{#each children as child}
<CommentNode
comment={child.comment}
children={child.children}
{targetType}
{targetId}
{reload}
/>
{/each}
{/if}
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,54 @@
<script lang="ts">
import CommentNode from "./CommentNode.svelte";
import type { CommentDoc } from "$lib/types";
interface CommentTreeNode {
comment: CommentDoc;
children: CommentTreeNode[];
}
let {
comments = [],
targetType,
targetId,
reload,
}: {
comments?: CommentDoc[];
targetType: "note" | "question";
targetId: string;
reload: () => Promise<void>;
} = $props();
const roots = $derived.by(() => {
const map = new Map<string, CommentTreeNode>();
for (const comment of comments) {
map.set(comment._id, { comment, children: [] });
}
const result: CommentTreeNode[] = [];
for (const comment of comments) {
const node = map.get(comment._id)!;
if (comment.parentCommentId && map.has(comment.parentCommentId)) {
map.get(comment.parentCommentId)!.children.push(node);
} else {
result.push(node);
}
}
return result;
});
</script>
<div>
{#each roots as root}
<div class="border-rule border-t">
<CommentNode
comment={root.comment}
children={root.children}
{targetType}
{targetId}
{reload}
/>
</div>
{:else}
<p class="text-muted text-sm">No {targetType === "note" ? "comments" : "answers"} yet.</p>
{/each}
</div>

View file

@ -529,7 +529,7 @@ function questionsUpdate(
// ---- comments ---- // ---- comments ----
const COMMENT_COLUMNS = const COMMENT_COLUMNS =
"id AS _id, content, authorId, authorName, parentId, questionId, parentCommentId, createdAt"; "id AS _id, content, authorId, authorName, parentId, questionId, parentCommentId, createdAt, updatedAt";
function commentsCreateOnNote( function commentsCreateOnNote(
db: Db, db: Db,
@ -537,9 +537,10 @@ function commentsCreateOnNote(
) { ) {
const user = requireAuth(db, args.token); const user = requireAuth(db, args.token);
const id = newId(); const id = newId();
const now = Date.now();
db.prepare( db.prepare(
`INSERT INTO comments (id, content, authorId, authorName, parentId, parentCommentId, createdAt) `INSERT INTO comments (id, content, authorId, authorName, parentId, parentCommentId, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
).run( ).run(
id, id,
args.content, args.content,
@ -547,7 +548,8 @@ function commentsCreateOnNote(
user.name, user.name,
args.parentId, args.parentId,
args.parentCommentId ?? null, args.parentCommentId ?? null,
Date.now(), now,
now,
); );
db.prepare("UPDATE notes SET commentCount = commentCount + 1 WHERE id = ?").run(args.parentId); db.prepare("UPDATE notes SET commentCount = commentCount + 1 WHERE id = ?").run(args.parentId);
return id; return id;
@ -559,9 +561,10 @@ function commentsCreateOnQuestion(
) { ) {
const user = requireAuth(db, args.token); const user = requireAuth(db, args.token);
const id = newId(); const id = newId();
const now = Date.now();
db.prepare( db.prepare(
`INSERT INTO comments (id, content, authorId, authorName, questionId, parentCommentId, createdAt) `INSERT INTO comments (id, content, authorId, authorName, questionId, parentCommentId, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
).run( ).run(
id, id,
args.content, args.content,
@ -569,7 +572,8 @@ function commentsCreateOnQuestion(
user.name, user.name,
args.questionId, args.questionId,
args.parentCommentId ?? null, args.parentCommentId ?? null,
Date.now(), now,
now,
); );
db.prepare("UPDATE questions SET answerCount = answerCount + 1 WHERE id = ?").run( db.prepare("UPDATE questions SET answerCount = answerCount + 1 WHERE id = ?").run(
args.questionId, args.questionId,
@ -599,7 +603,49 @@ function commentsRemove(db: Db, args: { token: string; id: string }) {
| { id: string; authorId: string } | { id: string; authorId: string }
| undefined; | undefined;
if (!comment || comment.authorId !== user._id) throw new Error("Not authorized"); if (!comment || comment.authorId !== user._id) throw new Error("Not authorized");
db.prepare("DELETE FROM comments WHERE id = ?").run(args.id);
const ids = [args.id];
const stack = [args.id];
while (stack.length > 0) {
const current = stack.pop()!;
const children = db
.prepare("SELECT id FROM comments WHERE parentCommentId = ?")
.all(current) as { id: string }[];
for (const child of children) {
ids.push(child.id);
stack.push(child.id);
}
}
db.exec("BEGIN");
try {
for (const id of ids) {
db.prepare("DELETE FROM comments WHERE id = ?").run(id);
}
db.exec("COMMIT");
} catch (err) {
db.exec("ROLLBACK");
throw err;
}
}
function commentsUpdate(db: Db, args: { token: string; id: string; content: string }) {
const user = requireAuth(db, args.token);
const comment = db.prepare("SELECT id, authorId FROM comments WHERE id = ?").get(args.id) as
| { id: string; authorId: string }
| undefined;
if (!comment || comment.authorId !== user._id) throw new Error("Not authorized");
const content = (args.content ?? "").trim();
if (!content) throw new Error("Comment cannot be empty");
const updatedAt = Date.now();
db.prepare("UPDATE comments SET content = ?, updatedAt = ? WHERE id = ?").run(
content,
updatedAt,
args.id,
);
return { updatedAt };
} }
// ---- votes ---- // ---- votes ----
@ -1058,6 +1104,7 @@ const handlers: Record<string, Handler> = {
"comments:listByNote": commentsListByNote, "comments:listByNote": commentsListByNote,
"comments:listByQuestion": commentsListByQuestion, "comments:listByQuestion": commentsListByQuestion,
"comments:remove": commentsRemove, "comments:remove": commentsRemove,
"comments:update": commentsUpdate,
"votes:cast": votesCast, "votes:cast": votesCast,
"votes:getMyVote": votesGetMyVote, "votes:getMyVote": votesGetMyVote,
"details:getNoteWithDetails": getNoteWithDetails, "details:getNoteWithDetails": getNoteWithDetails,

View file

@ -72,7 +72,8 @@ function createSchema(database: DatabaseSync) {
parentId TEXT, parentId TEXT,
questionId TEXT, questionId TEXT,
parentCommentId TEXT, parentCommentId TEXT,
createdAt INTEGER NOT NULL createdAt INTEGER NOT NULL,
updatedAt INTEGER NOT NULL DEFAULT 0
); );
CREATE TABLE IF NOT EXISTS votes ( CREATE TABLE IF NOT EXISTS votes (
@ -220,6 +221,13 @@ function migrate(database: DatabaseSync) {
if (!unitColumns.some((c) => c.name === "code2")) { if (!unitColumns.some((c) => c.name === "code2")) {
database.exec("ALTER TABLE units ADD COLUMN code2 TEXT"); database.exec("ALTER TABLE units ADD COLUMN code2 TEXT");
} }
const commentColumns = database.prepare("PRAGMA table_info(comments)").all() as {
name: string;
}[];
if (!commentColumns.some((c) => c.name === "updatedAt")) {
database.exec("ALTER TABLE comments ADD COLUMN updatedAt INTEGER NOT NULL DEFAULT 0");
}
} }
function seed(database: DatabaseSync) { function seed(database: DatabaseSync) {

View file

@ -62,4 +62,5 @@ export type CommentDoc = Doc<"comments"> & {
questionId?: Id<"questions">; questionId?: Id<"questions">;
parentCommentId?: Id<"comments">; parentCommentId?: Id<"comments">;
createdAt: number; createdAt: number;
updatedAt?: number;
}; };

View file

@ -124,6 +124,10 @@ button {
@apply text-ink hover:border-primary hover:text-primary bg-surface inline-flex h-8 min-w-8 items-center justify-center rounded-sm border border-transparent px-1.5 font-sans text-xs font-medium transition-colors; @apply text-ink hover:border-primary hover:text-primary bg-surface inline-flex h-8 min-w-8 items-center justify-center rounded-sm border border-transparent px-1.5 font-sans text-xs font-medium transition-colors;
} }
.thread-branch {
@apply w-3 shrink-0 cursor-pointer self-stretch border-l-2 border-rule transition hover:border-secondary hover:brightness-150;
}
.markdown { .markdown {
font-size: inherit; font-size: inherit;
line-height: inherit; line-height: inherit;

View file

@ -7,6 +7,7 @@
import VoteStack from "$lib/components/VoteStack.svelte"; import VoteStack from "$lib/components/VoteStack.svelte";
import Markdown from "$lib/components/Markdown.svelte"; import Markdown from "$lib/components/Markdown.svelte";
import MarkdownEditor from "$lib/components/MarkdownEditor.svelte"; import MarkdownEditor from "$lib/components/MarkdownEditor.svelte";
import CommentThread from "$lib/components/CommentThread.svelte";
import { timeAgo } from "$lib/time"; import { timeAgo } from "$lib/time";
import type { NoteDoc, CommentDoc, TopicDoc, UnitDoc } from "$lib/types"; import type { NoteDoc, CommentDoc, TopicDoc, UnitDoc } from "$lib/types";
@ -42,6 +43,11 @@
loading = false; loading = false;
}); });
async function reloadComments() {
const updated = await query("details:getNoteWithDetails", { id: page.params.id });
if (updated) comments = (updated as any).comments ?? [];
}
async function postComment() { async function postComment() {
if (!commentText.trim()) return; if (!commentText.trim()) return;
const token = getToken(); const token = getToken();
@ -59,8 +65,7 @@
parentId: page.params.id, parentId: page.params.id,
}); });
commentText = ""; commentText = "";
const updated = await query("details:getNoteWithDetails", { id: page.params.id }); await reloadComments();
if (updated) comments = (updated as any).comments ?? [];
} catch (err: any) { } catch (err: any) {
commentError = err.message ?? "Failed to post comment"; commentError = err.message ?? "Failed to post comment";
} finally { } finally {
@ -250,36 +255,12 @@
{/if} {/if}
<div> <div>
{#each comments as comment} <CommentThread
<div class="border-rule border-t py-4"> {comments}
<p class="kicker">{comment.authorName} · {timeAgo(comment.createdAt)}</p> targetType="note"
<p class="text-ink mt-2 text-sm leading-relaxed whitespace-pre-wrap"> targetId={note._id}
{comment.content} reload={reloadComments}
</p> />
{#if get(currentUser)?._id === comment.authorId}
<button
onclick={async () => {
const token = getToken();
if (!token) return;
try {
await mutation("comments:remove", {
token,
id: comment._id,
});
const updated = await query("details:getNoteWithDetails", {
id: page.params.id,
});
if (updated) comments = (updated as any).comments ?? [];
} catch (e) {}
}}
class="text-primary hover:text-primary-dark mt-2 text-xs"
>Delete</button
>
{/if}
</div>
{:else}
<p class="text-muted text-sm">No comments yet.</p>
{/each}
</div> </div>
</section> </section>
{:else} {:else}

View file

@ -7,6 +7,7 @@
import VoteStack from "$lib/components/VoteStack.svelte"; import VoteStack from "$lib/components/VoteStack.svelte";
import Markdown from "$lib/components/Markdown.svelte"; import Markdown from "$lib/components/Markdown.svelte";
import MarkdownEditor from "$lib/components/MarkdownEditor.svelte"; import MarkdownEditor from "$lib/components/MarkdownEditor.svelte";
import CommentThread from "$lib/components/CommentThread.svelte";
import { timeAgo } from "$lib/time"; import { timeAgo } from "$lib/time";
import type { QuestionDoc, CommentDoc, TopicDoc, UnitDoc } from "$lib/types"; import type { QuestionDoc, CommentDoc, TopicDoc, UnitDoc } from "$lib/types";
@ -41,6 +42,11 @@
loading = false; loading = false;
}); });
async function reloadAnswers() {
const updated = await query("details:getQuestionWithDetails", { id: page.params.id });
if (updated) answers = (updated as any).answers ?? [];
}
async function postAnswer() { async function postAnswer() {
if (!answerText.trim()) return; if (!answerText.trim()) return;
const token = getToken(); const token = getToken();
@ -58,11 +64,7 @@
questionId: page.params.id, questionId: page.params.id,
}); });
answerText = ""; answerText = "";
const updated = await query("details:getQuestionWithDetails", { id: page.params.id }); await reloadAnswers();
if (updated) {
question = { ...(updated as any), answers: undefined } as QuestionDoc;
answers = (updated as any).answers ?? [];
}
} catch (err: any) { } catch (err: any) {
answerError = err.message ?? "Failed to post answer"; answerError = err.message ?? "Failed to post answer";
} finally { } finally {
@ -250,37 +252,12 @@
{/if} {/if}
<div> <div>
{#each answers as answer} <CommentThread
<div class="border-rule border-t py-4"> comments={answers}
<p class="kicker">{answer.authorName} · {timeAgo(answer.createdAt)}</p> targetType="question"
<p class="text-ink mt-2 text-sm leading-relaxed whitespace-pre-wrap"> targetId={question._id}
{answer.content} reload={reloadAnswers}
</p> />
{#if get(currentUser)?._id === answer.authorId}
<button
onclick={async () => {
const token = getToken();
if (!token) return;
try {
await mutation("comments:remove", {
token,
id: answer._id,
});
const updated = await query(
"details:getQuestionWithDetails",
{ id: page.params.id },
);
if (updated) answers = (updated as any).answers ?? [];
} catch (e) {}
}}
class="text-primary hover:text-primary-dark mt-2 text-xs"
>Delete</button
>
{/if}
</div>
{:else}
<p class="text-muted text-sm">No answers yet.</p>
{/each}
</div> </div>
</section> </section>
{:else} {:else}