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 ----
|
// ---- 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,
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue