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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user