v2: global player, infinite scroll, image picker, threaded comments

This commit is contained in:
khannurien
2026-03-21 13:55:22 +00:00
parent be426eb150
commit 7c098e7c4c
48 changed files with 4346 additions and 711 deletions

View 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>
);
}