v3: code quality and visual consistency pass (refactored all forms across the app), fixed filename-related upload bug
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 47s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 47s
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import React, { useMemo, useRef, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { Controller } from "react-hook-form";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import { API_URL, VALIDATION } from "../config/api.ts";
|
||||
@@ -10,16 +11,141 @@ import type {
|
||||
UpdateCommentRequest,
|
||||
User,
|
||||
} from "../model.ts";
|
||||
import { deserializeComment, parseAPIResponse } 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 { LikeButton } from "./LikeButton.tsx";
|
||||
import { relativeTime } from "../utils/relativeTime.ts";
|
||||
import { ErrorCard } from "./ErrorCard.tsx";
|
||||
import { Tooltip } from "./Tooltip.tsx";
|
||||
import { ConfirmModal } from "./ConfirmModal.tsx";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
import {
|
||||
expectOk,
|
||||
FormError,
|
||||
FormProvider,
|
||||
SubmitButton,
|
||||
useApiForm,
|
||||
} from "./form/index.ts";
|
||||
|
||||
function authHeaders(token: string | null, json = true): HeadersInit {
|
||||
const headers: Record<string, string> = {};
|
||||
if (json) headers["Content-Type"] = "application/json";
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
return headers;
|
||||
}
|
||||
|
||||
interface CommentFormProps {
|
||||
/** Async submit; throw to surface a root error, resolve to clear/close. */
|
||||
onSubmit: (body: string) => Promise<void>;
|
||||
submitLabel: React.ReactNode;
|
||||
pendingLabel: React.ReactNode;
|
||||
errorTitle: string;
|
||||
placeholder?: string;
|
||||
defaultValue?: string;
|
||||
onCancel?: () => void;
|
||||
/** Top-level form: only show Cancel once there's text to discard. */
|
||||
cancelWhenEmpty?: boolean;
|
||||
editorRef?: React.Ref<TextEditorHandle>;
|
||||
/** `top` renders the avatar-flanked layout; `inline` is for reply/edit. */
|
||||
layout?: "inline" | "top";
|
||||
leading?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-field comment editor (post / reply / edit). Wraps the shared
|
||||
* react-hook-form lifecycle so the three call sites stay identical.
|
||||
*/
|
||||
function CommentForm({
|
||||
onSubmit,
|
||||
submitLabel,
|
||||
pendingLabel,
|
||||
errorTitle,
|
||||
placeholder,
|
||||
defaultValue = "",
|
||||
onCancel,
|
||||
cancelWhenEmpty,
|
||||
editorRef,
|
||||
layout = "inline",
|
||||
leading,
|
||||
}: CommentFormProps) {
|
||||
const form = useApiForm<{ body: string }>({
|
||||
defaultValues: { body: defaultValue },
|
||||
});
|
||||
const body = form.watch("body");
|
||||
const disabled = !body.trim() || body.length > VALIDATION.COMMENT_BODY_MAX;
|
||||
|
||||
const submit = form.submit(async ({ body }) => {
|
||||
if (!body.trim() || body.length > VALIDATION.COMMENT_BODY_MAX) return;
|
||||
await onSubmit(body);
|
||||
form.reset({ body: "" });
|
||||
});
|
||||
|
||||
const showCancel = onCancel && (!cancelWhenEmpty || body.trim().length > 0);
|
||||
|
||||
const inner = (
|
||||
<>
|
||||
<Controller
|
||||
name="body"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<TextEditor
|
||||
ref={editorRef}
|
||||
className="comment-reply-textarea"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onSubmit={() => void submit()}
|
||||
placeholder={placeholder}
|
||||
autoResize
|
||||
rows={1}
|
||||
maxLength={VALIDATION.COMMENT_BODY_MAX}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FormError title={errorTitle} />
|
||||
<div className="comment-form-actions">
|
||||
<SubmitButton
|
||||
className="comment-submit-btn"
|
||||
pendingLabel={pendingLabel}
|
||||
disabled={disabled}
|
||||
>
|
||||
{submitLabel}
|
||||
</SubmitButton>
|
||||
{showCancel && (
|
||||
<button
|
||||
type="button"
|
||||
className="comment-cancel-btn"
|
||||
onClick={() => {
|
||||
form.reset({ body: "" });
|
||||
onCancel?.();
|
||||
}}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
{layout === "top"
|
||||
? (
|
||||
<form className="comment-top-form" onSubmit={submit}>
|
||||
<div className="comment-top-form-inner">
|
||||
{leading}
|
||||
<div className="comment-top-form-body">{inner}</div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
: (
|
||||
<form className="comment-form" onSubmit={submit}>
|
||||
{inner}
|
||||
</form>
|
||||
)}
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
interface CommentThreadProps {
|
||||
dumpId: string;
|
||||
@@ -65,14 +191,8 @@ function CommentNode({
|
||||
onCommentUpdated,
|
||||
}: CommentNodeProps) {
|
||||
const [replyOpen, setReplyOpen] = useState(false);
|
||||
const [replyBody, setReplyBody] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [replyError, setReplyError] = useState<string | null>(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);
|
||||
@@ -81,41 +201,16 @@ function CommentNode({
|
||||
|
||||
const children = tree.get(comment.id) ?? [];
|
||||
|
||||
async function handleReply(e?: React.SubmitEvent) {
|
||||
e?.preventDefault();
|
||||
if (
|
||||
!replyBody.trim() || !token ||
|
||||
replyBody.length > VALIDATION.COMMENT_BODY_MAX
|
||||
) 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,
|
||||
} satisfies CreateCommentRequest,
|
||||
),
|
||||
});
|
||||
const data = parseAPIResponse<RawComment>(await res.json());
|
||||
if (data.success) {
|
||||
onCommentCreated(deserializeComment(data.data));
|
||||
setReplyBody("");
|
||||
setReplyOpen(false);
|
||||
} else {
|
||||
setReplyError(data.error.message);
|
||||
}
|
||||
} catch {
|
||||
setReplyError(t`Could not reach the server. Please try again.`);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
async function handleReply(body: string) {
|
||||
const res = await fetch(`${API_URL}/api/dumps/${dumpId}/comments`, {
|
||||
method: "POST",
|
||||
headers: authHeaders(token),
|
||||
body: JSON.stringify(
|
||||
{ body, parentId: comment.id } satisfies CreateCommentRequest,
|
||||
),
|
||||
});
|
||||
onCommentCreated(deserializeComment(await expectOk<RawComment>(res)));
|
||||
setReplyOpen(false);
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
@@ -129,35 +224,14 @@ function CommentNode({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEditSave(e?: React.SubmitEvent) {
|
||||
e?.preventDefault();
|
||||
if (
|
||||
!editBody.trim() || !token ||
|
||||
editBody.length > VALIDATION.COMMENT_BODY_MAX
|
||||
) 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 } satisfies UpdateCommentRequest),
|
||||
});
|
||||
const data = parseAPIResponse<RawComment>(await res.json());
|
||||
if (data.success) {
|
||||
onCommentUpdated(deserializeComment(data.data));
|
||||
setEditOpen(false);
|
||||
} else {
|
||||
setEditError(data.error.message);
|
||||
}
|
||||
} catch {
|
||||
setEditError(t`Could not reach the server. Please try again.`);
|
||||
} finally {
|
||||
setEditSubmitting(false);
|
||||
}
|
||||
async function handleEditSave(body: string) {
|
||||
const res = await fetch(`${API_URL}/api/comments/${comment.id}`, {
|
||||
method: "PATCH",
|
||||
headers: authHeaders(token),
|
||||
body: JSON.stringify({ body } satisfies UpdateCommentRequest),
|
||||
});
|
||||
onCommentUpdated(deserializeComment(await expectOk<RawComment>(res)));
|
||||
setEditOpen(false);
|
||||
}
|
||||
|
||||
const canDelete = !comment.deleted && !!currentUser &&
|
||||
@@ -245,47 +319,15 @@ function CommentNode({
|
||||
</div>
|
||||
{editOpen
|
||||
? (
|
||||
<form className="comment-form" onSubmit={handleEditSave}>
|
||||
<TextEditor
|
||||
ref={editEditorRef}
|
||||
className="comment-reply-textarea"
|
||||
value={editBody}
|
||||
onChange={setEditBody}
|
||||
onSubmit={handleEditSave}
|
||||
autoResize
|
||||
rows={1}
|
||||
maxLength={VALIDATION.COMMENT_BODY_MAX}
|
||||
/>
|
||||
{editError && (
|
||||
<ErrorCard
|
||||
title={t`Failed to save edit`}
|
||||
message={editError}
|
||||
/>
|
||||
)}
|
||||
<div className="comment-form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="comment-submit-btn"
|
||||
disabled={editSubmitting || !editBody.trim() ||
|
||||
editBody.length > VALIDATION.COMMENT_BODY_MAX}
|
||||
>
|
||||
{editSubmitting
|
||||
? <Trans>Saving…</Trans>
|
||||
: <Trans>Save</Trans>}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="comment-cancel-btn"
|
||||
onClick={() => {
|
||||
setEditOpen(false);
|
||||
setEditBody("");
|
||||
setEditError(null);
|
||||
}}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<CommentForm
|
||||
editorRef={editEditorRef}
|
||||
defaultValue={comment.body}
|
||||
onSubmit={handleEditSave}
|
||||
submitLabel={<Trans>Save</Trans>}
|
||||
pendingLabel={<Trans>Saving…</Trans>}
|
||||
errorTitle={t`Failed to save edit`}
|
||||
onCancel={() => setEditOpen(false)}
|
||||
/>
|
||||
)
|
||||
: <Markdown className="comment-body">{comment.body}</Markdown>}
|
||||
<div className="comment-actions">
|
||||
@@ -316,7 +358,6 @@ function CommentNode({
|
||||
type="button"
|
||||
className="comment-action-btn"
|
||||
onClick={() => {
|
||||
setEditBody(comment.body);
|
||||
setEditOpen(true);
|
||||
setTimeout(() => editEditorRef.current?.focus(), 0);
|
||||
}}
|
||||
@@ -346,48 +387,15 @@ function CommentNode({
|
||||
)}
|
||||
</div>
|
||||
{replyOpen && (
|
||||
<form className="comment-form" onSubmit={handleReply}>
|
||||
<TextEditor
|
||||
ref={replyEditorRef}
|
||||
className="comment-reply-textarea"
|
||||
value={replyBody}
|
||||
onChange={setReplyBody}
|
||||
onSubmit={handleReply}
|
||||
placeholder={t`Write a reply…`}
|
||||
autoResize
|
||||
rows={1}
|
||||
maxLength={VALIDATION.COMMENT_BODY_MAX}
|
||||
/>
|
||||
{replyError && (
|
||||
<ErrorCard
|
||||
title={t`Failed to post reply`}
|
||||
message={replyError}
|
||||
/>
|
||||
)}
|
||||
<div className="comment-form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="comment-submit-btn"
|
||||
disabled={submitting || !replyBody.trim() ||
|
||||
replyBody.length > VALIDATION.COMMENT_BODY_MAX}
|
||||
>
|
||||
{submitting
|
||||
? <Trans>Posting…</Trans>
|
||||
: <Trans>Post reply</Trans>}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="comment-cancel-btn"
|
||||
onClick={() => {
|
||||
setReplyOpen(false);
|
||||
setReplyBody("");
|
||||
setReplyError(null);
|
||||
}}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<CommentForm
|
||||
editorRef={replyEditorRef}
|
||||
onSubmit={handleReply}
|
||||
submitLabel={<Trans>Post reply</Trans>}
|
||||
pendingLabel={<Trans>Posting…</Trans>}
|
||||
errorTitle={t`Failed to post reply`}
|
||||
placeholder={t`Write a reply…`}
|
||||
onCancel={() => setReplyOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -425,44 +433,16 @@ export function CommentThread({
|
||||
onCommentDeleted,
|
||||
onCommentUpdated,
|
||||
}: CommentThreadProps) {
|
||||
const [topLevelBody, setTopLevelBody] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [topLevelError, setTopLevelError] = useState<string | null>(null);
|
||||
|
||||
const tree = useMemo(() => buildTree(comments), [comments]);
|
||||
const roots = tree.get("root") ?? [];
|
||||
|
||||
async function handleTopLevelSubmit(e?: React.SubmitEvent) {
|
||||
e?.preventDefault();
|
||||
if (
|
||||
!topLevelBody.trim() || !token ||
|
||||
topLevelBody.length > VALIDATION.COMMENT_BODY_MAX
|
||||
) 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 } satisfies CreateCommentRequest,
|
||||
),
|
||||
});
|
||||
const data = parseAPIResponse<RawComment>(await res.json());
|
||||
if (data.success) {
|
||||
onCommentCreated(deserializeComment(data.data));
|
||||
setTopLevelBody("");
|
||||
} else {
|
||||
setTopLevelError(data.error.message);
|
||||
}
|
||||
} catch {
|
||||
setTopLevelError(t`Could not reach the server. Please try again.`);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
async function handleTopLevelSubmit(body: string) {
|
||||
const res = await fetch(`${API_URL}/api/dumps/${dumpId}/comments`, {
|
||||
method: "POST",
|
||||
headers: authHeaders(token),
|
||||
body: JSON.stringify({ body } satisfies CreateCommentRequest),
|
||||
});
|
||||
onCommentCreated(deserializeComment(await expectOk<RawComment>(res)));
|
||||
}
|
||||
|
||||
const visibleCount = comments.filter((c) => !c.deleted).length;
|
||||
@@ -474,8 +454,9 @@ export function CommentThread({
|
||||
</h2>
|
||||
|
||||
{currentUser && (
|
||||
<form className="comment-top-form" onSubmit={handleTopLevelSubmit}>
|
||||
<div className="comment-top-form-inner">
|
||||
<CommentForm
|
||||
layout="top"
|
||||
leading={
|
||||
<div className="comment-avatar">
|
||||
<Avatar
|
||||
userId={currentUser.id}
|
||||
@@ -484,50 +465,15 @@ export function CommentThread({
|
||||
size={28}
|
||||
/>
|
||||
</div>
|
||||
<div className="comment-top-form-body">
|
||||
<TextEditor
|
||||
className="comment-reply-textarea"
|
||||
value={topLevelBody}
|
||||
onChange={setTopLevelBody}
|
||||
onSubmit={handleTopLevelSubmit}
|
||||
placeholder={t`Add a comment…`}
|
||||
autoResize
|
||||
rows={1}
|
||||
maxLength={VALIDATION.COMMENT_BODY_MAX}
|
||||
/>
|
||||
{topLevelError && (
|
||||
<ErrorCard
|
||||
title={t`Failed to post comment`}
|
||||
message={topLevelError}
|
||||
/>
|
||||
)}
|
||||
<div className="comment-form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="comment-submit-btn"
|
||||
disabled={submitting || !topLevelBody.trim() ||
|
||||
topLevelBody.length > VALIDATION.COMMENT_BODY_MAX}
|
||||
>
|
||||
{submitting
|
||||
? <Trans>Posting…</Trans>
|
||||
: <Trans>Post comment</Trans>}
|
||||
</button>
|
||||
{topLevelBody.trim() && (
|
||||
<button
|
||||
type="button"
|
||||
className="comment-cancel-btn"
|
||||
onClick={() => {
|
||||
setTopLevelBody("");
|
||||
setTopLevelError(null);
|
||||
}}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
onSubmit={handleTopLevelSubmit}
|
||||
submitLabel={<Trans>Post comment</Trans>}
|
||||
pendingLabel={<Trans>Posting…</Trans>}
|
||||
errorTitle={t`Failed to post comment`}
|
||||
placeholder={t`Add a comment…`}
|
||||
onCancel={() => {}}
|
||||
cancelWhenEmpty
|
||||
/>
|
||||
)}
|
||||
|
||||
{roots.length > 0 && (
|
||||
|
||||
Reference in New Issue
Block a user