v2: global player, infinite scroll, image picker, threaded comments
This commit is contained in:
353
src/components/CommentThread.tsx
Normal file
353
src/components/CommentThread.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import type { Comment, RawComment, User } from "../model.ts";
|
||||
import { deserializeComment } from "../model.ts";
|
||||
import { Avatar } from "./Avatar.tsx";
|
||||
import { Markdown } from "./Markdown.tsx";
|
||||
import { relativeTime } from "../utils/relativeTime.ts";
|
||||
|
||||
interface CommentThreadProps {
|
||||
dumpId: string;
|
||||
comments: Comment[];
|
||||
currentUser: User | null;
|
||||
token: string | null;
|
||||
onCommentCreated: (comment: Comment) => void;
|
||||
onCommentDeleted: (commentId: string) => void;
|
||||
}
|
||||
|
||||
function buildTree(comments: Comment[]): Map<string, Comment[]> {
|
||||
const map = new Map<string, Comment[]>();
|
||||
for (const c of comments) {
|
||||
const key = c.parentId ?? "root";
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(c);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
const MAX_INDENT_DEPTH = 4;
|
||||
|
||||
interface CommentNodeProps {
|
||||
comment: Comment;
|
||||
tree: Map<string, Comment[]>;
|
||||
depth: number;
|
||||
dumpId: string;
|
||||
currentUser: User | null;
|
||||
token: string | null;
|
||||
onCommentCreated: (comment: Comment) => void;
|
||||
onCommentDeleted: (commentId: string) => void;
|
||||
}
|
||||
|
||||
function CommentNode({
|
||||
comment,
|
||||
tree,
|
||||
depth,
|
||||
dumpId,
|
||||
currentUser,
|
||||
token,
|
||||
onCommentCreated,
|
||||
onCommentDeleted,
|
||||
}: CommentNodeProps) {
|
||||
const [replyOpen, setReplyOpen] = useState(false);
|
||||
const [replyBody, setReplyBody] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [replyError, setReplyError] = useState<string | null>(null);
|
||||
const replyTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const children = tree.get(comment.id) ?? [];
|
||||
|
||||
async function handleReply(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!replyBody.trim() || !token) return;
|
||||
setSubmitting(true);
|
||||
setReplyError(null);
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/dumps/${dumpId}/comments`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ body: replyBody, parentId: comment.id }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
onCommentCreated(deserializeComment(data.data as RawComment));
|
||||
setReplyBody("");
|
||||
setReplyOpen(false);
|
||||
} else {
|
||||
setReplyError(data.error?.message ?? "Failed to post reply.");
|
||||
}
|
||||
} catch {
|
||||
setReplyError("Could not reach the server. Please try again.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!token) return;
|
||||
const res = await fetch(`${API_URL}/api/comments/${comment.id}`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}).catch(() => null);
|
||||
if (res?.ok) {
|
||||
onCommentDeleted(comment.id);
|
||||
}
|
||||
}
|
||||
|
||||
const canDelete = !comment.deleted && !!currentUser &&
|
||||
(currentUser.id === comment.userId || currentUser.isAdmin);
|
||||
|
||||
if (comment.deleted) {
|
||||
return (
|
||||
<li className="comment-node">
|
||||
<div className="comment-node-inner comment-node-inner--deleted">
|
||||
<div className="comment-avatar comment-avatar--deleted">
|
||||
<div className="comment-avatar-placeholder" style={{ width: 28, height: 28 }} />
|
||||
</div>
|
||||
<div className="comment-content">
|
||||
<p className="comment-deleted-placeholder">[deleted]</p>
|
||||
</div>
|
||||
</div>
|
||||
{children.length > 0 && (
|
||||
<ul
|
||||
className="comment-replies"
|
||||
style={depth >= MAX_INDENT_DEPTH ? { paddingLeft: 0 } : undefined}
|
||||
>
|
||||
{children.map((child) => (
|
||||
<CommentNode
|
||||
key={child.id}
|
||||
comment={child}
|
||||
tree={tree}
|
||||
depth={depth + 1}
|
||||
dumpId={dumpId}
|
||||
currentUser={currentUser}
|
||||
token={token}
|
||||
onCommentCreated={onCommentCreated}
|
||||
onCommentDeleted={onCommentDeleted}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="comment-node">
|
||||
<div className="comment-node-inner">
|
||||
<div className="comment-avatar">
|
||||
<Avatar
|
||||
userId={comment.userId}
|
||||
username={comment.authorUsername}
|
||||
hasAvatar={!!comment.authorAvatarMime}
|
||||
size={28}
|
||||
/>
|
||||
</div>
|
||||
<div className="comment-content">
|
||||
<div className="comment-meta">
|
||||
<Link
|
||||
to={`/users/${comment.authorUsername}`}
|
||||
className="comment-author"
|
||||
>
|
||||
{comment.authorUsername}
|
||||
</Link>
|
||||
<time
|
||||
className="comment-time"
|
||||
dateTime={comment.createdAt.toISOString()}
|
||||
title={comment.createdAt.toLocaleString()}
|
||||
>
|
||||
{relativeTime(comment.createdAt)}
|
||||
</time>
|
||||
</div>
|
||||
<Markdown className="comment-body">{comment.body}</Markdown>
|
||||
<div className="comment-actions">
|
||||
{currentUser && (
|
||||
<button
|
||||
type="button"
|
||||
className="comment-action-btn"
|
||||
onClick={() => {
|
||||
setReplyOpen((v) => !v);
|
||||
setTimeout(() => replyTextareaRef.current?.focus(), 0);
|
||||
}}
|
||||
>
|
||||
Reply
|
||||
</button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button
|
||||
type="button"
|
||||
className="comment-action-btn comment-delete-btn"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{replyOpen && (
|
||||
<form className="comment-form" onSubmit={handleReply}>
|
||||
<textarea
|
||||
ref={replyTextareaRef}
|
||||
className="comment-reply-textarea"
|
||||
value={replyBody}
|
||||
onChange={(e) => setReplyBody(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) handleReply(e);
|
||||
}}
|
||||
placeholder="Write a reply…"
|
||||
rows={3}
|
||||
/>
|
||||
{replyError && (
|
||||
<p className="comment-form-error">{replyError}</p>
|
||||
)}
|
||||
<div className="comment-form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="comment-submit-btn"
|
||||
disabled={submitting || !replyBody.trim()}
|
||||
>
|
||||
{submitting ? "Posting…" : "Post reply"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="comment-action-btn"
|
||||
onClick={() => {
|
||||
setReplyOpen(false);
|
||||
setReplyBody("");
|
||||
setReplyError(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{children.length > 0 && (
|
||||
<ul
|
||||
className="comment-replies"
|
||||
style={depth >= MAX_INDENT_DEPTH
|
||||
? { paddingLeft: 0 }
|
||||
: undefined}
|
||||
>
|
||||
{children.map((child) => (
|
||||
<CommentNode
|
||||
key={child.id}
|
||||
comment={child}
|
||||
tree={tree}
|
||||
depth={depth + 1}
|
||||
dumpId={dumpId}
|
||||
currentUser={currentUser}
|
||||
token={token}
|
||||
onCommentCreated={onCommentCreated}
|
||||
onCommentDeleted={onCommentDeleted}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function CommentThread({
|
||||
dumpId,
|
||||
comments,
|
||||
currentUser,
|
||||
token,
|
||||
onCommentCreated,
|
||||
onCommentDeleted,
|
||||
}: CommentThreadProps) {
|
||||
const [topLevelBody, setTopLevelBody] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [topLevelError, setTopLevelError] = useState<string | null>(null);
|
||||
|
||||
const tree = buildTree(comments);
|
||||
const roots = tree.get("root") ?? [];
|
||||
|
||||
async function handleTopLevelSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!topLevelBody.trim() || !token) return;
|
||||
setSubmitting(true);
|
||||
setTopLevelError(null);
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/dumps/${dumpId}/comments`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ body: topLevelBody }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
onCommentCreated(deserializeComment(data.data as RawComment));
|
||||
setTopLevelBody("");
|
||||
} else {
|
||||
setTopLevelError(data.error?.message ?? "Failed to post comment.");
|
||||
}
|
||||
} catch {
|
||||
setTopLevelError("Could not reach the server. Please try again.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="comment-section">
|
||||
<h2 className="comment-section-title">
|
||||
{(() => {
|
||||
const n = comments.filter((c) => !c.deleted).length;
|
||||
return n === 1 ? "1 comment" : `${n} comments`;
|
||||
})()}
|
||||
</h2>
|
||||
|
||||
{currentUser && (
|
||||
<form className="comment-form comment-top-form" onSubmit={handleTopLevelSubmit}>
|
||||
<textarea
|
||||
className="comment-reply-textarea"
|
||||
value={topLevelBody}
|
||||
onChange={(e) => setTopLevelBody(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) handleTopLevelSubmit(e);
|
||||
}}
|
||||
placeholder="Add a comment…"
|
||||
rows={3}
|
||||
/>
|
||||
{topLevelError && (
|
||||
<p className="comment-form-error">{topLevelError}</p>
|
||||
)}
|
||||
<div className="comment-form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="comment-submit-btn"
|
||||
disabled={submitting || !topLevelBody.trim()}
|
||||
>
|
||||
{submitting ? "Posting…" : "Post comment"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{roots.length > 0 && (
|
||||
<ul className="comment-list">
|
||||
{roots.map((comment) => (
|
||||
<CommentNode
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
tree={tree}
|
||||
depth={0}
|
||||
dumpId={dumpId}
|
||||
currentUser={currentUser}
|
||||
token={token}
|
||||
onCommentCreated={onCommentCreated}
|
||||
onCommentDeleted={onCommentDeleted}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user