v3: added content slugs, fixed real-time updates in client, added @mentions across the app, added new file selector and drop zone

This commit is contained in:
khannurien
2026-03-22 16:06:26 +00:00
parent 39a0cc397e
commit 34e908d1bc
42 changed files with 2170 additions and 628 deletions

View File

@@ -5,8 +5,11 @@ import type { Comment, RawComment, User } from "../model.ts";
import { deserializeComment } from "../model.ts";
import { Avatar } from "./Avatar.tsx";
import { Markdown } from "./Markdown.tsx";
import { TextEditor, type TextEditorHandle } from "./TextEditor.tsx";
import { relativeTime } from "../utils/relativeTime.ts";
import { ErrorCard } from "./ErrorCard.tsx";
import { Tooltip } from "./Tooltip.tsx";
import { ConfirmModal } from "./ConfirmModal.tsx";
interface CommentThreadProps {
dumpId: string;
@@ -15,6 +18,7 @@ interface CommentThreadProps {
token: string | null;
onCommentCreated: (comment: Comment) => void;
onCommentDeleted: (commentId: string) => void;
onCommentUpdated: (comment: Comment) => void;
}
function buildTree(comments: Comment[]): Map<string, Comment[]> {
@@ -27,7 +31,7 @@ function buildTree(comments: Comment[]): Map<string, Comment[]> {
return map;
}
const MAX_INDENT_DEPTH = 4;
const MAX_INDENT_DEPTH = 6;
interface CommentNodeProps {
comment: Comment;
@@ -38,6 +42,7 @@ interface CommentNodeProps {
token: string | null;
onCommentCreated: (comment: Comment) => void;
onCommentDeleted: (commentId: string) => void;
onCommentUpdated: (comment: Comment) => void;
}
function CommentNode({
@@ -49,12 +54,20 @@ function CommentNode({
token,
onCommentCreated,
onCommentDeleted,
onCommentUpdated,
}: 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 [editOpen, setEditOpen] = useState(false);
const [editBody, setEditBody] = useState("");
const [confirmDelete, setConfirmDelete] = useState(false);
const [editSubmitting, setEditSubmitting] = useState(false);
const [editError, setEditError] = useState<string | null>(null);
const replyEditorRef = useRef<TextEditorHandle>(null);
const editEditorRef = useRef<TextEditorHandle>(null);
const children = tree.get(comment.id) ?? [];
@@ -98,8 +111,38 @@ function CommentNode({
}
}
async function handleEditSave(e: React.FormEvent) {
e.preventDefault();
if (!editBody.trim() || !token) return;
setEditSubmitting(true);
setEditError(null);
try {
const res = await fetch(`${API_URL}/api/comments/${comment.id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ body: editBody }),
});
const data = await res.json();
if (data.success) {
onCommentUpdated(deserializeComment(data.data as RawComment));
setEditOpen(false);
} else {
setEditError(data.error?.message ?? "Failed to save edit.");
}
} catch {
setEditError("Could not reach the server. Please try again.");
} finally {
setEditSubmitting(false);
}
}
const canDelete = !comment.deleted && !!currentUser &&
(currentUser.id === comment.userId || currentUser.isAdmin);
const canEdit = !comment.deleted && !!currentUser &&
(currentUser.id === comment.userId || currentUser.isAdmin);
if (comment.deleted) {
return (
@@ -118,7 +161,7 @@ function CommentNode({
{children.length > 0 && (
<ul
className="comment-replies"
style={depth >= MAX_INDENT_DEPTH ? { paddingLeft: 0 } : undefined}
style={depth >= MAX_INDENT_DEPTH ? { paddingLeft: 0, marginLeft: 0, borderLeft: "none" } : undefined}
>
{children.map((child) => (
<CommentNode
@@ -131,6 +174,7 @@ function CommentNode({
token={token}
onCommentCreated={onCommentCreated}
onCommentDeleted={onCommentDeleted}
onCommentUpdated={onCommentUpdated}
/>
))}
</ul>
@@ -141,7 +185,7 @@ function CommentNode({
return (
<li className="comment-node">
<div className="comment-node-inner">
<div className="comment-node-inner" id={`comment-${comment.id}`}>
<div className="comment-avatar">
<Avatar
userId={comment.userId}
@@ -158,52 +202,121 @@ function CommentNode({
>
{comment.authorUsername}
</Link>
<time
<Link
to={`/dumps/${dumpId}#comment-${comment.id}`}
className="comment-time"
dateTime={comment.createdAt.toISOString()}
title={comment.createdAt.toLocaleString()}
>
{relativeTime(comment.createdAt)}
</time>
<Tooltip text={comment.createdAt.toLocaleString()}>
<time dateTime={comment.createdAt.toISOString()}>
{relativeTime(comment.createdAt)}
</time>
</Tooltip>
</Link>
{comment.updatedAt && (
<Tooltip text={`Edited ${comment.updatedAt.toLocaleString()}`}>
<span className="comment-edited">
edited {relativeTime(comment.updatedAt)}
</span>
</Tooltip>
)}
</div>
<Markdown className="comment-body">{comment.body}</Markdown>
{editOpen
? (
<form className="comment-form" onSubmit={handleEditSave}>
<TextEditor
ref={editEditorRef}
className="comment-reply-textarea"
value={editBody}
onChange={setEditBody}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) handleEditSave(e);
}}
autoResize
rows={1}
/>
{editError && (
<ErrorCard title="Failed to save edit" message={editError} />
)}
<div className="comment-form-actions">
<button
type="submit"
className="comment-submit-btn"
disabled={editSubmitting || !editBody.trim()}
>
{editSubmitting ? "Saving…" : "Save"}
</button>
<button
type="button"
className="comment-action-btn"
onClick={() => {
setEditOpen(false);
setEditBody("");
setEditError(null);
}}
>
Cancel
</button>
</div>
</form>
)
: <Markdown className="comment-body">{comment.body}</Markdown>}
<div className="comment-actions">
{currentUser && (
{currentUser && !editOpen && (
<button
type="button"
className="comment-action-btn"
onClick={() => {
setReplyOpen((v) => !v);
setTimeout(() => replyTextareaRef.current?.focus(), 0);
setTimeout(() => replyEditorRef.current?.focus(), 0);
}}
>
Reply
</button>
)}
{canDelete && (
{canEdit && !editOpen && (
<button
type="button"
className="comment-action-btn"
onClick={() => {
setEditBody(comment.body);
setEditOpen(true);
setTimeout(() => editEditorRef.current?.focus(), 0);
}}
>
Edit
</button>
)}
{canDelete && !editOpen && (
<button
type="button"
className="comment-action-btn comment-delete-btn"
onClick={handleDelete}
onClick={() => setConfirmDelete(true)}
>
Delete
</button>
)}
{confirmDelete && (
<ConfirmModal
message="Delete this comment?"
confirmLabel="Delete"
onConfirm={() => { setConfirmDelete(false); handleDelete(); }}
onCancel={() => setConfirmDelete(false)}
/>
)}
</div>
{replyOpen && (
<form className="comment-form" onSubmit={handleReply}>
<textarea
ref={replyTextareaRef}
<TextEditor
ref={replyEditorRef}
className="comment-reply-textarea"
value={replyBody}
onChange={(e) => setReplyBody(e.target.value)}
onChange={setReplyBody}
onKeyDown={(e) => {
if (
e.key === "Enter" && (e.ctrlKey || e.metaKey)
) handleReply(e);
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) handleReply(e);
}}
placeholder="Write a reply…"
rows={3}
autoResize
rows={1}
/>
{replyError && (
<ErrorCard title="Failed to post reply" message={replyError} />
@@ -235,7 +348,7 @@ function CommentNode({
{children.length > 0 && (
<ul
className="comment-replies"
style={depth >= MAX_INDENT_DEPTH ? { paddingLeft: 0 } : undefined}
style={depth >= MAX_INDENT_DEPTH ? { paddingLeft: 0, marginLeft: 0, borderLeft: "none" } : undefined}
>
{children.map((child) => (
<CommentNode
@@ -248,6 +361,7 @@ function CommentNode({
token={token}
onCommentCreated={onCommentCreated}
onCommentDeleted={onCommentDeleted}
onCommentUpdated={onCommentUpdated}
/>
))}
</ul>
@@ -263,6 +377,7 @@ export function CommentThread({
token,
onCommentCreated,
onCommentDeleted,
onCommentUpdated,
}: CommentThreadProps) {
const [topLevelBody, setTopLevelBody] = useState("");
const [submitting, setSubmitting] = useState(false);
@@ -309,33 +424,56 @@ export function CommentThread({
</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 && (
<ErrorCard title="Failed to post comment" message={topLevelError} />
)}
<div className="comment-form-actions">
<button
type="submit"
className="comment-submit-btn"
disabled={submitting || !topLevelBody.trim()}
>
{submitting ? "Posting…" : "Post comment"}
</button>
<form className="comment-top-form" onSubmit={handleTopLevelSubmit}>
<div className="comment-top-form-inner">
<div className="comment-avatar">
<Avatar
userId={currentUser.id}
username={currentUser.username}
hasAvatar={!!currentUser.avatarMime}
size={28}
/>
</div>
<div className="comment-top-form-body">
<TextEditor
className="comment-reply-textarea"
value={topLevelBody}
onChange={setTopLevelBody}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) handleTopLevelSubmit(e);
}}
placeholder="Add a comment…"
autoResize
rows={1}
/>
{topLevelError && (
<ErrorCard
title="Failed to post comment"
message={topLevelError}
/>
)}
<div className="comment-form-actions">
<button
type="submit"
className="comment-submit-btn"
disabled={submitting || !topLevelBody.trim()}
>
{submitting ? "Posting…" : "Post comment"}
</button>
{topLevelBody.trim() && (
<button
type="button"
className="comment-action-btn"
onClick={() => {
setTopLevelBody("");
setTopLevelError(null);
}}
>
Cancel
</button>
)}
</div>
</div>
</div>
</form>
)}
@@ -353,6 +491,7 @@ export function CommentThread({
token={token}
onCommentCreated={onCommentCreated}
onCommentDeleted={onCommentDeleted}
onCommentUpdated={onCommentUpdated}
/>
))}
</ul>