From 9be3907e0fe8a58d2b4cd233c68d2f2ff139611d Mon Sep 17 00:00:00 2001
From: liyunze <50455574+liyunze-coding@users.noreply.github.com>
Date: Tue, 1 Sep 2026 22:46:45 +0800
Subject: [PATCH] added replies
---
src/lib/components/CommentNode.svelte | 262 ++++++++++++++++++++++++
src/lib/components/CommentThread.svelte | 54 +++++
src/lib/server/api.ts | 63 +++++-
src/lib/server/db.ts | 10 +-
src/lib/types.ts | 1 +
src/routes/layout.css | 4 +
src/routes/notes/[id]/+page.svelte | 45 ++--
src/routes/questions/[id]/+page.svelte | 49 ++---
8 files changed, 411 insertions(+), 77 deletions(-)
create mode 100644 src/lib/components/CommentNode.svelte
create mode 100644 src/lib/components/CommentThread.svelte
diff --git a/src/lib/components/CommentNode.svelte b/src/lib/components/CommentNode.svelte
new file mode 100644
index 0000000..92bda2a
--- /dev/null
+++ b/src/lib/components/CommentNode.svelte
@@ -0,0 +1,262 @@
+
+
+
+
+
+
+ {comment.authorName} · {timeAgo(comment.createdAt)}
+ {#if edited}
+ · Edited
+ {/if}
+
+
+ {#if editing}
+
+
+
+
+ {editError}
+
+ {:else}
+
+ {comment.content}
+
+ {/if}
+
+
+ {#if get(isAuthenticated)}
+
+ {/if}
+ {#if isMine}
+ {#if !editing}
+
+ {/if}
+
+ {/if}
+
+
+ {#if replying}
+
+
+
+
+
+ {replyError}
+
+
+ {/if}
+
+
+
+ {#if children.length > 0}
+
+
+
+ {#if collapsed}
+
+ {:else}
+ {#each children as child}
+
+ {/each}
+ {/if}
+
+
+ {/if}
+
diff --git a/src/lib/components/CommentThread.svelte b/src/lib/components/CommentThread.svelte
new file mode 100644
index 0000000..a931089
--- /dev/null
+++ b/src/lib/components/CommentThread.svelte
@@ -0,0 +1,54 @@
+
+
+
+ {#each roots as root}
+
+
+
+ {:else}
+
No {targetType === "note" ? "comments" : "answers"} yet.
+ {/each}
+
diff --git a/src/lib/server/api.ts b/src/lib/server/api.ts
index e50aab1..8485bb6 100644
--- a/src/lib/server/api.ts
+++ b/src/lib/server/api.ts
@@ -529,7 +529,7 @@ function questionsUpdate(
// ---- comments ----
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(
db: Db,
@@ -537,9 +537,10 @@ function commentsCreateOnNote(
) {
const user = requireAuth(db, args.token);
const id = newId();
+ const now = Date.now();
db.prepare(
- `INSERT INTO comments (id, content, authorId, authorName, parentId, parentCommentId, createdAt)
- VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ `INSERT INTO comments (id, content, authorId, authorName, parentId, parentCommentId, createdAt, updatedAt)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
id,
args.content,
@@ -547,7 +548,8 @@ function commentsCreateOnNote(
user.name,
args.parentId,
args.parentCommentId ?? null,
- Date.now(),
+ now,
+ now,
);
db.prepare("UPDATE notes SET commentCount = commentCount + 1 WHERE id = ?").run(args.parentId);
return id;
@@ -559,9 +561,10 @@ function commentsCreateOnQuestion(
) {
const user = requireAuth(db, args.token);
const id = newId();
+ const now = Date.now();
db.prepare(
- `INSERT INTO comments (id, content, authorId, authorName, questionId, parentCommentId, createdAt)
- VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ `INSERT INTO comments (id, content, authorId, authorName, questionId, parentCommentId, createdAt, updatedAt)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
id,
args.content,
@@ -569,7 +572,8 @@ function commentsCreateOnQuestion(
user.name,
args.questionId,
args.parentCommentId ?? null,
- Date.now(),
+ now,
+ now,
);
db.prepare("UPDATE questions SET answerCount = answerCount + 1 WHERE id = ?").run(
args.questionId,
@@ -599,7 +603,49 @@ function commentsRemove(db: Db, args: { token: string; id: string }) {
| { id: string; authorId: string }
| undefined;
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 ----
@@ -1058,6 +1104,7 @@ const handlers: Record = {
"comments:listByNote": commentsListByNote,
"comments:listByQuestion": commentsListByQuestion,
"comments:remove": commentsRemove,
+ "comments:update": commentsUpdate,
"votes:cast": votesCast,
"votes:getMyVote": votesGetMyVote,
"details:getNoteWithDetails": getNoteWithDetails,
diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts
index ef310ac..a62706c 100644
--- a/src/lib/server/db.ts
+++ b/src/lib/server/db.ts
@@ -72,7 +72,8 @@ function createSchema(database: DatabaseSync) {
parentId TEXT,
questionId TEXT,
parentCommentId TEXT,
- createdAt INTEGER NOT NULL
+ createdAt INTEGER NOT NULL,
+ updatedAt INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS votes (
@@ -220,6 +221,13 @@ function migrate(database: DatabaseSync) {
if (!unitColumns.some((c) => c.name === "code2")) {
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) {
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 4e3a676..24f3809 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -62,4 +62,5 @@ export type CommentDoc = Doc<"comments"> & {
questionId?: Id<"questions">;
parentCommentId?: Id<"comments">;
createdAt: number;
+ updatedAt?: number;
};
diff --git a/src/routes/layout.css b/src/routes/layout.css
index e901f97..c33fd8b 100644
--- a/src/routes/layout.css
+++ b/src/routes/layout.css
@@ -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;
}
+.thread-branch {
+ @apply w-3 shrink-0 cursor-pointer self-stretch border-l-2 border-rule transition hover:border-secondary hover:brightness-150;
+}
+
.markdown {
font-size: inherit;
line-height: inherit;
diff --git a/src/routes/notes/[id]/+page.svelte b/src/routes/notes/[id]/+page.svelte
index b6244fe..91e3515 100644
--- a/src/routes/notes/[id]/+page.svelte
+++ b/src/routes/notes/[id]/+page.svelte
@@ -7,6 +7,7 @@
import VoteStack from "$lib/components/VoteStack.svelte";
import Markdown from "$lib/components/Markdown.svelte";
import MarkdownEditor from "$lib/components/MarkdownEditor.svelte";
+ import CommentThread from "$lib/components/CommentThread.svelte";
import { timeAgo } from "$lib/time";
import type { NoteDoc, CommentDoc, TopicDoc, UnitDoc } from "$lib/types";
@@ -42,6 +43,11 @@
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() {
if (!commentText.trim()) return;
const token = getToken();
@@ -59,8 +65,7 @@
parentId: page.params.id,
});
commentText = "";
- const updated = await query("details:getNoteWithDetails", { id: page.params.id });
- if (updated) comments = (updated as any).comments ?? [];
+ await reloadComments();
} catch (err: any) {
commentError = err.message ?? "Failed to post comment";
} finally {
@@ -250,36 +255,12 @@
{/if}
- {#each comments as comment}
-
-
{comment.authorName} · {timeAgo(comment.createdAt)}
-
- {comment.content}
-
- {#if get(currentUser)?._id === comment.authorId}
-
- {/if}
-
- {:else}
-
No comments yet.
- {/each}
+
{:else}
diff --git a/src/routes/questions/[id]/+page.svelte b/src/routes/questions/[id]/+page.svelte
index d17441a..b5de7c6 100644
--- a/src/routes/questions/[id]/+page.svelte
+++ b/src/routes/questions/[id]/+page.svelte
@@ -7,6 +7,7 @@
import VoteStack from "$lib/components/VoteStack.svelte";
import Markdown from "$lib/components/Markdown.svelte";
import MarkdownEditor from "$lib/components/MarkdownEditor.svelte";
+ import CommentThread from "$lib/components/CommentThread.svelte";
import { timeAgo } from "$lib/time";
import type { QuestionDoc, CommentDoc, TopicDoc, UnitDoc } from "$lib/types";
@@ -41,6 +42,11 @@
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() {
if (!answerText.trim()) return;
const token = getToken();
@@ -58,11 +64,7 @@
questionId: page.params.id,
});
answerText = "";
- const updated = await query("details:getQuestionWithDetails", { id: page.params.id });
- if (updated) {
- question = { ...(updated as any), answers: undefined } as QuestionDoc;
- answers = (updated as any).answers ?? [];
- }
+ await reloadAnswers();
} catch (err: any) {
answerError = err.message ?? "Failed to post answer";
} finally {
@@ -250,37 +252,12 @@
{/if}
- {#each answers as answer}
-
-
{answer.authorName} · {timeAgo(answer.createdAt)}
-
- {answer.content}
-
- {#if get(currentUser)?._id === answer.authorId}
-
- {/if}
-
- {:else}
-
No answers yet.
- {/each}
+
{:else}