v3: src/components sorted into feature folders, the four largest files split, and the component tests mirror the new layout — 48 components sat in one flat folder, ChatModal ran to 1301 lines, DumpCreateModal to 983, and the tests were grouped by theme, so there was no telling which component had none. Components now live in category/, chat/, comment/, dump/ (with dump/create/ for the new-dump wizard), editor/, form/, layout/ (header, page shell, floating buttons), player/, playlist/, profile/, social/ (vote, like, follow, the who-voted popover) and ui/; FeedTabBar and PresenceRow, used by the index page alone, move to pages/index/. ChatModal gives up ChatMessageItem, PendingItem, ChatEditBox and ReplyRef, the message limits and text helpers (chat/message.ts), the day and clock labels (chat/time.ts) and the draft storage (utils/chatDraft.ts, next to dumpDraft.ts). DumpCreateModal gives up StepIndicator, LocalFilePreview, TitleField and DuplicateNotice; CommentThread gives up CommentForm and CommentNode; TextEditor gives up the emoji picker popup and its skin-tone storage; MediaPlayer gives up its icons (which FilePreview imported from it), Waveform, SeekBar and the remembered volume. NewPlaylistForm, a button that opens a modal holding the form, is NewPlaylistButton now, and Thumbnail, ThumbnailPlaceholder, FilePreview and RichContentCard use named exports like everything else. The tests are split one file per component under src/test/components/<folder>/ (and src/test/pages/index/), the same 625 of them. No behaviour changes; the catalogues only move their source references.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-27 19:30:28 +00:00
parent de5abeceb2
commit 07d0344be7
131 changed files with 3893 additions and 3596 deletions

View File

@@ -0,0 +1,305 @@
import React, { useRef, useState } from "react";
import { Link } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL } from "../../config/api.ts";
import { can } from "../../utils/permissions.ts";
import type {
Comment,
CreateCommentRequest,
RawComment,
UpdateCommentRequest,
User,
} from "../../model.ts";
import { deserializeComment } from "../../model.ts";
import { Avatar } from "../ui/Avatar.tsx";
import { Markdown } from "../editor/Markdown.tsx";
import type { TextEditorHandle } from "../editor/TextEditor.tsx";
import { LikeButton } from "../social/LikeButton.tsx";
import { relativeTime } from "../../utils/relativeTime.ts";
import { Tooltip } from "../ui/Tooltip.tsx";
import { ConfirmModal } from "../ui/ConfirmModal.tsx";
import { useHeightTransition } from "../../hooks/useHeightTransition.ts";
import { useWS } from "../../hooks/useWS.ts";
import { useAuth } from "../../hooks/useAuth.ts";
import { expectOk } from "../form/index.ts";
import { CommentForm } from "./CommentForm.tsx";
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;
onCommentUpdated: (comment: Comment) => void;
}
export function CommentNode({
comment,
tree,
depth,
dumpId,
currentUser,
token,
onCommentCreated,
onCommentDeleted,
onCommentUpdated,
}: CommentNodeProps) {
const [replyOpen, setReplyOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [deleteFailed, setDeleteFailed] = useState(false);
const innerRef = useHeightTransition<HTMLDivElement>(
`${editOpen}:${replyOpen}`,
);
const replyEditorRef = useRef<TextEditorHandle>(null);
const editEditorRef = useRef<TextEditorHandle>(null);
const { likeCounts, myLikes, castCommentLike, removeCommentLike } = useWS();
const { authFetch } = useAuth();
const children = tree.get(comment.id) ?? [];
async function handleReply(body: string) {
const res = await authFetch(`${API_URL}/api/dumps/${dumpId}/comments`, {
method: "POST",
body: JSON.stringify(
{ body, parentId: comment.id } satisfies CreateCommentRequest,
),
});
onCommentCreated(deserializeComment(await expectOk<RawComment>(res)));
setReplyOpen(false);
}
async function handleDelete() {
if (!token) return;
setDeleteFailed(false);
const res = await authFetch(`${API_URL}/api/comments/${comment.id}`, {
method: "DELETE",
}).catch(() => null);
if (res?.ok) {
onCommentDeleted(comment.id);
} else {
setDeleteFailed(true);
}
}
async function handleEditSave(body: string) {
// Saving it unchanged would only stamp it "edited".
if (body === comment.body) {
setEditOpen(false);
return;
}
const res = await authFetch(`${API_URL}/api/comments/${comment.id}`, {
method: "PATCH",
body: JSON.stringify({ body } satisfies UpdateCommentRequest),
});
onCommentUpdated(deserializeComment(await expectOk<RawComment>(res)));
setEditOpen(false);
}
const canModerate = can(currentUser, "comment:moderate");
const canDelete = !comment.deleted && !!currentUser &&
(currentUser.id === comment.userId || canModerate);
const canEdit = !comment.deleted && !!currentUser &&
(currentUser.id === comment.userId || canModerate);
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">
<Trans>[deleted]</Trans>
</p>
</div>
</div>
{children.length > 0 && (
<ul
className="comment-replies"
style={{ "--depth": depth } as React.CSSProperties}
>
{children.map((child) => (
<CommentNode
key={child.id}
comment={child}
tree={tree}
depth={depth + 1}
dumpId={dumpId}
currentUser={currentUser}
token={token}
onCommentCreated={onCommentCreated}
onCommentDeleted={onCommentDeleted}
onCommentUpdated={onCommentUpdated}
/>
))}
</ul>
)}
</li>
);
}
return (
<li className="comment-node">
<div
className="comment-node-inner"
id={`comment-${comment.id}`}
ref={innerRef}
>
<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>
<Link
to={`/dumps/${dumpId}#comment-${comment.id}`}
className="comment-time"
>
<Tooltip text={comment.createdAt.toLocaleString()}>
<time dateTime={comment.createdAt.toISOString()}>
{relativeTime(comment.createdAt)}
</time>
</Tooltip>
</Link>
{comment.updatedAt && (
<Tooltip text={t`Edited ${comment.updatedAt.toLocaleString()}`}>
<span className="comment-edited">
<Trans>edited {relativeTime(comment.updatedAt)}</Trans>
</span>
</Tooltip>
)}
</div>
{editOpen
? (
<CommentForm
editorRef={editEditorRef}
defaultValue={comment.body}
onSubmit={handleEditSave}
submitLabel={<Trans>Save</Trans>}
pendingLabel={<Trans>Saving…</Trans>}
errorTitle={t`Failed to save edit`}
className="animate-in"
onCancel={() => setEditOpen(false)}
/>
)
: <Markdown className="comment-body">{comment.body}</Markdown>}
<div className="comment-actions">
{!editOpen && (
<LikeButton
commentId={comment.id}
count={likeCounts[comment.id] ?? comment.likeCount}
liked={myLikes.has(comment.id)}
disabled={!currentUser}
onLike={castCommentLike}
onUnlike={removeCommentLike}
/>
)}
{currentUser && !editOpen && (
<button
type="button"
className="comment-action-btn"
onClick={() => {
setReplyOpen((v) => !v);
setTimeout(() => replyEditorRef.current?.focus(), 0);
}}
>
<Trans>Reply</Trans>
</button>
)}
{canEdit && !editOpen && (
<button
type="button"
className="comment-action-btn"
onClick={() => {
setEditOpen(true);
setTimeout(() => editEditorRef.current?.focus(), 0);
}}
>
<Trans>Edit</Trans>
</button>
)}
{canDelete && !editOpen && (
<button
type="button"
className="comment-action-btn comment-delete-btn"
onClick={() => setConfirmDelete(true)}
>
<Trans>Delete</Trans>
</button>
)}
{confirmDelete && (
<ConfirmModal
message={t`Delete this comment?`}
confirmLabel={t`Delete`}
onConfirm={() => {
setConfirmDelete(false);
handleDelete();
}}
onCancel={() => setConfirmDelete(false)}
/>
)}
</div>
{deleteFailed && (
<p className="form-hint form-hint--error" role="alert">
<Trans>This comment couldn't be deleted. Try again.</Trans>
</p>
)}
{replyOpen && (
<CommentForm
editorRef={replyEditorRef}
onSubmit={handleReply}
submitLabel={<Trans>Post reply</Trans>}
pendingLabel={<Trans>Posting…</Trans>}
errorTitle={t`Failed to post reply`}
className="animate-in"
placeholder={t`Write a reply…`}
onCancel={() => setReplyOpen(false)}
/>
)}
</div>
</div>
{children.length > 0 && (
<ul
className="comment-replies"
style={{ "--depth": depth } as React.CSSProperties}
>
{children.map((child) => (
<CommentNode
key={child.id}
comment={child}
tree={tree}
depth={depth + 1}
dumpId={dumpId}
currentUser={currentUser}
token={token}
onCommentCreated={onCommentCreated}
onCommentDeleted={onCommentDeleted}
onCommentUpdated={onCommentUpdated}
/>
))}
</ul>
)}
</li>
);
}