mirror of
https://github.com/dsec-hub/dsec-notebook.git
synced 2026-09-22 15:43:58 +00:00
added replies
This commit is contained in:
parent
3130a1affa
commit
9be3907e0f
8 changed files with 411 additions and 77 deletions
262
src/lib/components/CommentNode.svelte
Normal file
262
src/lib/components/CommentNode.svelte
Normal 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>
|
||||
54
src/lib/components/CommentThread.svelte
Normal file
54
src/lib/components/CommentThread.svelte
Normal 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>
|
||||
|
|
@ -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<string, Handler> = {
|
|||
"comments:listByNote": commentsListByNote,
|
||||
"comments:listByQuestion": commentsListByQuestion,
|
||||
"comments:remove": commentsRemove,
|
||||
"comments:update": commentsUpdate,
|
||||
"votes:cast": votesCast,
|
||||
"votes:getMyVote": votesGetMyVote,
|
||||
"details:getNoteWithDetails": getNoteWithDetails,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -62,4 +62,5 @@ export type CommentDoc = Doc<"comments"> & {
|
|||
questionId?: Id<"questions">;
|
||||
parentCommentId?: Id<"comments">;
|
||||
createdAt: number;
|
||||
updatedAt?: number;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
<div>
|
||||
{#each comments as comment}
|
||||
<div class="border-rule border-t py-4">
|
||||
<p class="kicker">{comment.authorName} · {timeAgo(comment.createdAt)}</p>
|
||||
<p class="text-ink mt-2 text-sm leading-relaxed whitespace-pre-wrap">
|
||||
{comment.content}
|
||||
</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}
|
||||
<CommentThread
|
||||
{comments}
|
||||
targetType="note"
|
||||
targetId={note._id}
|
||||
reload={reloadComments}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
{:else}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
<div>
|
||||
{#each answers as answer}
|
||||
<div class="border-rule border-t py-4">
|
||||
<p class="kicker">{answer.authorName} · {timeAgo(answer.createdAt)}</p>
|
||||
<p class="text-ink mt-2 text-sm leading-relaxed whitespace-pre-wrap">
|
||||
{answer.content}
|
||||
</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}
|
||||
<CommentThread
|
||||
comments={answers}
|
||||
targetType="question"
|
||||
targetId={question._id}
|
||||
reload={reloadAnswers}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
{:else}
|
||||
|
|
|
|||
Loading…
Reference in a new issue