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}