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:
@@ -3,183 +3,106 @@ import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { API_URL, VALIDATION } from "../config/api.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { ErrorCard } from "./ErrorCard.tsx";
|
||||
import { Modal } from "./Modal.tsx";
|
||||
import {
|
||||
expectOk,
|
||||
FormActions,
|
||||
FormError,
|
||||
FormProvider,
|
||||
SubmitButton,
|
||||
TextField,
|
||||
useApiForm,
|
||||
} from "./form/index.ts";
|
||||
|
||||
interface ChangePasswordModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface Values {
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
export function ChangePasswordModal({ onClose }: ChangePasswordModalProps) {
|
||||
const { authFetch } = useAuth();
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [done, setDone] = useState(false);
|
||||
const form = useApiForm<Values>({
|
||||
mode: "onChange",
|
||||
defaultValues: { currentPassword: "", newPassword: "", confirmPassword: "" },
|
||||
});
|
||||
|
||||
const mismatch = confirmPassword.length > 0 &&
|
||||
newPassword !== confirmPassword;
|
||||
const tooShort = newPassword.length > 0 &&
|
||||
newPassword.length < VALIDATION.PASSWORD_MIN;
|
||||
|
||||
const handleSubmit = async (e: React.SubmitEvent) => {
|
||||
e.preventDefault();
|
||||
if (mismatch || tooShort || !currentPassword || !newPassword) return;
|
||||
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await authFetch(
|
||||
`${API_URL}/api/users/me/change-password`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
},
|
||||
);
|
||||
const body = await res.json();
|
||||
if (!body.success) {
|
||||
setError(body.error?.message ?? t`Unknown error`);
|
||||
return;
|
||||
}
|
||||
setDone(true);
|
||||
} catch {
|
||||
setError(t`Failed to change password`);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
const onSubmit = form.submit(async ({ currentPassword, newPassword }) => {
|
||||
await expectOk(
|
||||
await authFetch(`${API_URL}/api/users/me/change-password`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
}),
|
||||
);
|
||||
setDone(true);
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal title={t`Change password`} onClose={onClose}>
|
||||
{done
|
||||
? (
|
||||
<div className="modal-new-playlist-form">
|
||||
<p style={{ color: "var(--color-success, green)" }}>
|
||||
<div className="form">
|
||||
<p className="form-success">
|
||||
<Trans>Password changed successfully.</Trans>
|
||||
</p>
|
||||
<div className="form-actions">
|
||||
<div className="form-actions-right">
|
||||
<button type="button" className="btn-primary" onClick={onClose}>
|
||||
<Trans>Close</Trans>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<FormActions>
|
||||
<button type="button" className="btn-primary" onClick={onClose}>
|
||||
<Trans>Close</Trans>
|
||||
</button>
|
||||
</FormActions>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<form className="modal-new-playlist-form" onSubmit={handleSubmit}>
|
||||
<label>
|
||||
<span
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: "0.25rem",
|
||||
fontSize: "0.85rem",
|
||||
opacity: 0.7,
|
||||
}}
|
||||
>
|
||||
<Trans>Current password</Trans>
|
||||
</span>
|
||||
<input
|
||||
<FormProvider {...form}>
|
||||
<form className="form" onSubmit={onSubmit}>
|
||||
<TextField<Values>
|
||||
name="currentPassword"
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
label={t`Current password`}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
autoFocus
|
||||
rules={{ required: true }}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: "0.25rem",
|
||||
fontSize: "0.85rem",
|
||||
opacity: 0.7,
|
||||
}}
|
||||
>
|
||||
<Trans>New password</Trans>
|
||||
</span>
|
||||
<input
|
||||
<TextField<Values>
|
||||
name="newPassword"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
label={t`New password`}
|
||||
autoComplete="new-password"
|
||||
minLength={VALIDATION.PASSWORD_MIN}
|
||||
maxLength={VALIDATION.PASSWORD_MAX}
|
||||
required
|
||||
/>
|
||||
{tooShort && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.8rem",
|
||||
color: "var(--color-error, red)",
|
||||
marginTop: "0.2rem",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
<Trans>At least {VALIDATION.PASSWORD_MIN} characters</Trans>
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<label>
|
||||
<span
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: "0.25rem",
|
||||
fontSize: "0.85rem",
|
||||
opacity: 0.7,
|
||||
rules={{
|
||||
required: true,
|
||||
minLength: {
|
||||
value: VALIDATION.PASSWORD_MIN,
|
||||
message: t`At least ${VALIDATION.PASSWORD_MIN} characters`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Trans>Confirm new password</Trans>
|
||||
</span>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
{mismatch && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.8rem",
|
||||
color: "var(--color-error, red)",
|
||||
marginTop: "0.2rem",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
<Trans>Passwords do not match</Trans>
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
{error && (
|
||||
<ErrorCard title={t`Could not change password`} message={error} />
|
||||
)}
|
||||
<div className="form-actions">
|
||||
<div className="form-actions-right">
|
||||
<button
|
||||
type="button"
|
||||
className="form-cancel"
|
||||
onClick={onClose}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={submitting || mismatch || tooShort ||
|
||||
!currentPassword || !newPassword || !confirmPassword}
|
||||
>
|
||||
{submitting
|
||||
? <Trans>Saving…</Trans>
|
||||
: <Trans>Change password</Trans>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<TextField<Values>
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
label={t`Confirm new password`}
|
||||
autoComplete="new-password"
|
||||
rules={{
|
||||
required: true,
|
||||
validate: (value, values) =>
|
||||
value === values.newPassword ||
|
||||
t`Passwords do not match`,
|
||||
}}
|
||||
/>
|
||||
<FormError title={t`Could not change password`} />
|
||||
<FormActions onCancel={onClose}>
|
||||
<SubmitButton pendingLabel={<Trans>Saving…</Trans>}>
|
||||
<Trans>Change password</Trans>
|
||||
</SubmitButton>
|
||||
</FormActions>
|
||||
</form>
|
||||
</FormProvider>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -28,7 +28,7 @@ export function ConfirmModal(
|
||||
<div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<p className="confirm-modal-message">{message}</p>
|
||||
<div className="confirm-modal-actions">
|
||||
<button type="button" onClick={onCancel}>
|
||||
<button type="button" className="btn-secondary" onClick={onCancel}>
|
||||
<Trans>Cancel</Trans>
|
||||
</button>
|
||||
<button type="button" className="btn-danger" onClick={onConfirm}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { Controller } from "react-hook-form";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
@@ -14,7 +15,6 @@ import type {
|
||||
import {
|
||||
deserializeDump,
|
||||
deserializePlaylistMembership,
|
||||
parseAPIResponse,
|
||||
} from "../model.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
@@ -23,26 +23,36 @@ import { MAX_FILE_SIZE } from "../config/upload.ts";
|
||||
import RichContentCard from "./RichContentCard.tsx";
|
||||
import { MediaPlayer } from "./MediaPlayer.tsx";
|
||||
import type { RichContent } from "../model.ts";
|
||||
import { ErrorCard } from "./ErrorCard.tsx";
|
||||
import { FileDropZone } from "./FileDropZone.tsx";
|
||||
import { TextEditor } from "./TextEditor.tsx";
|
||||
import { Modal } from "./Modal.tsx";
|
||||
import { PlaylistMembershipPanel } from "./PlaylistMembershipPanel.tsx";
|
||||
import { friendlyFetchError } from "../utils/apiError.ts";
|
||||
import {
|
||||
expectOk,
|
||||
FormActions,
|
||||
FormError,
|
||||
FormProvider,
|
||||
RichTextField,
|
||||
SubmitButton,
|
||||
useApiForm,
|
||||
VisibilityToggle,
|
||||
} from "./form/index.ts";
|
||||
|
||||
type Mode = "url" | "file";
|
||||
type Phase = "create" | "playlist";
|
||||
|
||||
type SubmitState =
|
||||
| { status: "idle" }
|
||||
| { status: "submitting" }
|
||||
| { status: "error"; error: string };
|
||||
|
||||
type UrlPreview =
|
||||
| { status: "idle" }
|
||||
| { status: "loading" }
|
||||
| { status: "done"; richContent: RichContent | null };
|
||||
|
||||
interface CreateValues {
|
||||
url: string;
|
||||
file: File | null;
|
||||
title: string;
|
||||
comment: string;
|
||||
isPublic: boolean;
|
||||
}
|
||||
|
||||
function LocalFilePreview({ file }: { file: File }) {
|
||||
const [src, setSrc] = useState<string | null>(null);
|
||||
|
||||
@@ -86,28 +96,34 @@ export function DumpCreateModal(
|
||||
|
||||
// Create phase state
|
||||
const [mode, setMode] = useState<Mode>("url");
|
||||
const [url, setUrl] = useState(initialUrl);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [title, setTitle] = useState("");
|
||||
const [titleEditing, setTitleEditing] = useState(false);
|
||||
const titleInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [comment, setComment] = useState("");
|
||||
const [isPrivate, setIsPrivate] = useState(false);
|
||||
const [submitState, setSubmitState] = useState<SubmitState>({
|
||||
status: "idle",
|
||||
});
|
||||
const [urlFetchState, setUrlFetchState] = useState<UrlPreview>({
|
||||
status: "idle",
|
||||
});
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const form = useApiForm<CreateValues>({
|
||||
defaultValues: {
|
||||
url: initialUrl,
|
||||
file: null,
|
||||
title: "",
|
||||
comment: "",
|
||||
isPublic: true,
|
||||
},
|
||||
});
|
||||
const { register, setValue, watch, clearErrors, control } = form;
|
||||
const submitting = form.formState.isSubmitting;
|
||||
const url = watch("url");
|
||||
const file = watch("file");
|
||||
|
||||
// Playlist phase state
|
||||
const [memberships, setMemberships] = useState<PlaylistMembership[]>([]);
|
||||
const [playlistsLoading, setPlaylistsLoading] = useState(false);
|
||||
|
||||
const selectFile = (f: File | null) => {
|
||||
setFile(f);
|
||||
setTitle(f?.name ?? "");
|
||||
setValue("file", f);
|
||||
setValue("title", f?.name ?? "");
|
||||
setTitleEditing(false);
|
||||
};
|
||||
|
||||
@@ -166,9 +182,9 @@ export function DumpCreateModal(
|
||||
const pastedFile = e.clipboardData?.files[0];
|
||||
if (pastedFile) {
|
||||
setMode("file");
|
||||
setUrl("");
|
||||
setValue("url", "");
|
||||
selectFile(pastedFile);
|
||||
setSubmitState({ status: "idle" });
|
||||
clearErrors("root");
|
||||
return;
|
||||
}
|
||||
const tag = (e.target as HTMLElement).tagName;
|
||||
@@ -179,95 +195,71 @@ export function DumpCreateModal(
|
||||
if (u.protocol === "http:" || u.protocol === "https:") {
|
||||
setMode("url");
|
||||
selectFile(null);
|
||||
setUrl(u.toString());
|
||||
setSubmitState({ status: "idle" });
|
||||
setValue("url", u.toString());
|
||||
clearErrors("root");
|
||||
}
|
||||
} catch { /* not a URL */ }
|
||||
};
|
||||
globalThis.addEventListener("paste", handler);
|
||||
return () => globalThis.removeEventListener("paste", handler);
|
||||
// setValue/clearErrors are stable RHF refs; selectFile closes over them.
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.SubmitEvent) => {
|
||||
e.preventDefault();
|
||||
if (comment.length > VALIDATION.DUMP_COMMENT_MAX) return;
|
||||
setSubmitState({ status: "submitting" });
|
||||
const onSubmit = form.submit(async (
|
||||
{ url, file, title, comment, isPublic },
|
||||
) => {
|
||||
const isPrivate = !isPublic;
|
||||
let res: Response;
|
||||
|
||||
try {
|
||||
let res: Response;
|
||||
|
||||
if (mode === "url") {
|
||||
const normalizedUrl = normalizeUrl(url);
|
||||
if (!normalizedUrl) {
|
||||
setSubmitState({ status: "error", error: t`URL is required.` });
|
||||
return;
|
||||
}
|
||||
setUrl(normalizedUrl);
|
||||
const body: CreateUrlDumpRequest = {
|
||||
url: normalizedUrl,
|
||||
comment: comment.trim() || undefined,
|
||||
isPrivate,
|
||||
};
|
||||
res = await authFetch(`${API_URL}/api/dumps`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} else {
|
||||
if (!file) {
|
||||
setSubmitState({
|
||||
status: "error",
|
||||
error: t`Please select a file.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
setSubmitState({
|
||||
status: "error",
|
||||
error: t`File too large (max 50 MB).`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
if (comment.trim()) formData.append("comment", comment.trim());
|
||||
if (title.trim()) formData.append("title", title.trim());
|
||||
formData.append("isPrivate", String(isPrivate));
|
||||
res = await authFetch(`${API_URL}/api/dumps`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
if (mode === "url") {
|
||||
const normalizedUrl = normalizeUrl(url);
|
||||
if (!normalizedUrl) throw new Error(t`URL is required.`);
|
||||
setValue("url", normalizedUrl);
|
||||
const body: CreateUrlDumpRequest = {
|
||||
url: normalizedUrl,
|
||||
comment: comment.trim() || undefined,
|
||||
isPrivate,
|
||||
};
|
||||
res = await authFetch(`${API_URL}/api/dumps`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} else {
|
||||
if (!file) throw new Error(t`Please select a file.`);
|
||||
if (!title.trim()) throw new Error(t`Title is required.`);
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
throw new Error(t`File too large (max 50 MB).`);
|
||||
}
|
||||
|
||||
const apiResponse = parseAPIResponse<RawDump>(await res.json());
|
||||
if (apiResponse.success) {
|
||||
const dump = deserializeDump(apiResponse.data);
|
||||
injectDump(dump);
|
||||
setCreatedDump(dump);
|
||||
setPhase("playlist");
|
||||
setPlaylistsLoading(true);
|
||||
authFetch(`${API_URL}/api/playlists/by-dump/${dump.id}/memberships`)
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
if (body.success) {
|
||||
setMemberships(
|
||||
(body.data as RawPlaylistMembership[]).map(
|
||||
deserializePlaylistMembership,
|
||||
),
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setPlaylistsLoading(false));
|
||||
} else {
|
||||
setSubmitState({
|
||||
status: "error",
|
||||
error: apiResponse.error.message,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
setSubmitState({ status: "error", error: friendlyFetchError(err) });
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
if (comment.trim()) formData.append("comment", comment.trim());
|
||||
if (title.trim()) formData.append("title", title.trim());
|
||||
formData.append("isPrivate", String(isPrivate));
|
||||
res = await authFetch(`${API_URL}/api/dumps`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const dump = deserializeDump(await expectOk<RawDump>(res));
|
||||
injectDump(dump);
|
||||
setCreatedDump(dump);
|
||||
setPhase("playlist");
|
||||
setPlaylistsLoading(true);
|
||||
authFetch(`${API_URL}/api/playlists/by-dump/${dump.id}/memberships`)
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
if (body.success) {
|
||||
setMemberships(
|
||||
(body.data as RawPlaylistMembership[]).map(
|
||||
deserializePlaylistMembership,
|
||||
),
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setPlaylistsLoading(false));
|
||||
});
|
||||
|
||||
const toggleMembership = async (membership: PlaylistMembership) => {
|
||||
if (!createdDump) return;
|
||||
@@ -295,8 +287,6 @@ export function DumpCreateModal(
|
||||
}
|
||||
};
|
||||
|
||||
const submitting = submitState.status === "submitting";
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={phase === "create" ? t`New dump` : t`Add to playlist`}
|
||||
@@ -313,7 +303,7 @@ export function DumpCreateModal(
|
||||
onClick={() => {
|
||||
setMode("url");
|
||||
selectFile(null);
|
||||
setSubmitState({ status: "idle" });
|
||||
clearErrors("root");
|
||||
}}
|
||||
disabled={submitting}
|
||||
>
|
||||
@@ -324,8 +314,8 @@ export function DumpCreateModal(
|
||||
className={mode === "file" ? "active" : ""}
|
||||
onClick={() => {
|
||||
setMode("file");
|
||||
setUrl("");
|
||||
setSubmitState({ status: "idle" });
|
||||
setValue("url", "");
|
||||
clearErrors("root");
|
||||
}}
|
||||
disabled={submitting}
|
||||
>
|
||||
@@ -333,161 +323,134 @@ export function DumpCreateModal(
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="dump-form">
|
||||
{submitState.status === "error" && (
|
||||
<ErrorCard
|
||||
title={t`Failed to post`}
|
||||
message={submitState.error}
|
||||
/>
|
||||
)}
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={onSubmit} className="form">
|
||||
<FormError title={t`Failed to post`} />
|
||||
|
||||
{mode === "url"
|
||||
? (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label htmlFor="dc-url">
|
||||
<Trans>URL</Trans>
|
||||
</label>
|
||||
<input
|
||||
id="dc-url"
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onBlur={(e) => setUrl(normalizeUrl(e.target.value))}
|
||||
onPaste={(e) => {
|
||||
const pastedFile = e.clipboardData.files[0];
|
||||
if (pastedFile) {
|
||||
e.preventDefault();
|
||||
setMode("file");
|
||||
setUrl("");
|
||||
selectFile(pastedFile);
|
||||
setSubmitState({ status: "idle" });
|
||||
}
|
||||
}}
|
||||
disabled={submitting}
|
||||
placeholder="https://..."
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
{urlPreview.status === "loading" && (
|
||||
<p className="preview-loading">
|
||||
<Trans>Fetching preview…</Trans>
|
||||
</p>
|
||||
)}
|
||||
{urlPreview.status === "done" &&
|
||||
urlPreview.richContent && (
|
||||
<RichContentCard
|
||||
richContent={urlPreview.richContent}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<FileDropZone
|
||||
file={file}
|
||||
onChange={selectFile}
|
||||
disabled={submitting}
|
||||
/>
|
||||
{file && <LocalFilePreview file={file} />}
|
||||
{file && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="dc-title">
|
||||
<Trans>Title</Trans>
|
||||
{mode === "url"
|
||||
? (
|
||||
<>
|
||||
<div className="form-field">
|
||||
<label className="form-label" htmlFor="dc-url">
|
||||
<Trans>URL</Trans>
|
||||
</label>
|
||||
<div className="dump-create-title-row">
|
||||
<input
|
||||
id="dc-title"
|
||||
ref={titleInputRef}
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
disabled={submitting || !titleEditing}
|
||||
maxLength={VALIDATION.DUMP_TITLE_MAX}
|
||||
required
|
||||
/>
|
||||
{!titleEditing && (
|
||||
<button
|
||||
type="button"
|
||||
className="dump-create-title-edit-btn"
|
||||
onClick={() => {
|
||||
setTitleEditing(true);
|
||||
setTimeout(
|
||||
() => titleInputRef.current?.focus(),
|
||||
0,
|
||||
);
|
||||
}}
|
||||
disabled={submitting}
|
||||
aria-label={t`Edit title`}
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
id="dc-url"
|
||||
type="url"
|
||||
{...register("url")}
|
||||
onBlur={(e) =>
|
||||
setValue("url", normalizeUrl(e.target.value))}
|
||||
onPaste={(e) => {
|
||||
const pastedFile = e.clipboardData.files[0];
|
||||
if (pastedFile) {
|
||||
e.preventDefault();
|
||||
setMode("file");
|
||||
setValue("url", "");
|
||||
selectFile(pastedFile);
|
||||
clearErrors("root");
|
||||
}
|
||||
}}
|
||||
disabled={submitting}
|
||||
placeholder="https://..."
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{urlPreview.status === "loading" && (
|
||||
<p className="preview-loading">
|
||||
<Trans>Fetching preview…</Trans>
|
||||
</p>
|
||||
)}
|
||||
{urlPreview.status === "done" &&
|
||||
urlPreview.richContent && (
|
||||
<RichContentCard richContent={urlPreview.richContent} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<Controller
|
||||
name="file"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FileDropZone
|
||||
file={field.value}
|
||||
onChange={selectFile}
|
||||
disabled={submitting}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{file && <LocalFilePreview file={file} />}
|
||||
{file && (
|
||||
<div className="form-field">
|
||||
<label className="form-label" htmlFor="dc-title">
|
||||
<Trans>Title</Trans>
|
||||
</label>
|
||||
<div className="dump-create-title-row">
|
||||
{(() => {
|
||||
const { ref, ...titleReg } = register("title");
|
||||
return (
|
||||
<input
|
||||
id="dc-title"
|
||||
type="text"
|
||||
{...titleReg}
|
||||
ref={(el) => {
|
||||
ref(el);
|
||||
titleInputRef.current = el;
|
||||
}}
|
||||
disabled={submitting || !titleEditing}
|
||||
maxLength={VALIDATION.DUMP_TITLE_MAX}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
{!titleEditing && (
|
||||
<button
|
||||
type="button"
|
||||
className="dump-create-title-edit-btn"
|
||||
onClick={() => {
|
||||
setTitleEditing(true);
|
||||
setTimeout(
|
||||
() => titleInputRef.current?.focus(),
|
||||
0,
|
||||
);
|
||||
}}
|
||||
disabled={submitting}
|
||||
aria-label={t`Edit title`}
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="dc-comment">
|
||||
<Trans>Why?</Trans>
|
||||
</label>
|
||||
<TextEditor
|
||||
id="dc-comment"
|
||||
value={comment}
|
||||
onChange={setComment}
|
||||
disabled={submitting}
|
||||
<RichTextField<CreateValues>
|
||||
name="comment"
|
||||
label={t`Why?`}
|
||||
placeholder={t`What makes it worth it?`}
|
||||
rows={3}
|
||||
maxLength={VALIDATION.DUMP_COMMENT_MAX}
|
||||
rules={{ maxLength: VALIDATION.DUMP_COMMENT_MAX }}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="visibility-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className={!isPrivate ? "active" : ""}
|
||||
<VisibilityToggle<CreateValues>
|
||||
name="isPublic"
|
||||
disabled={submitting}
|
||||
onClick={() => setIsPrivate(false)}
|
||||
>
|
||||
<Trans>Public</Trans>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={isPrivate ? "active" : ""}
|
||||
disabled={submitting}
|
||||
onClick={() => setIsPrivate(true)}
|
||||
>
|
||||
<Trans>Private</Trans>
|
||||
</button>
|
||||
</div>
|
||||
/>
|
||||
|
||||
<div className="form-actions">
|
||||
<div className="form-actions-right">
|
||||
<button
|
||||
type="button"
|
||||
className="form-cancel"
|
||||
onClick={onClose}
|
||||
<FormActions onCancel={onClose}>
|
||||
<SubmitButton
|
||||
pendingLabel={mode === "url"
|
||||
? <Trans>Fetching…</Trans>
|
||||
: <Trans>Uploading…</Trans>}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={submitting ||
|
||||
comment.length > VALIDATION.DUMP_COMMENT_MAX}
|
||||
>
|
||||
{submitting
|
||||
? (mode === "url"
|
||||
? <Trans>Fetching…</Trans>
|
||||
: <Trans>Uploading…</Trans>)
|
||||
: <Trans>Dump it</Trans>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<Trans>Dump it</Trans>
|
||||
</SubmitButton>
|
||||
</FormActions>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
@@ -512,11 +475,7 @@ export function DumpCreateModal(
|
||||
|
||||
<div className="form-actions">
|
||||
<div className="form-actions-right">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
onClick={onClose}
|
||||
>
|
||||
<button type="button" className="btn-primary" onClick={onClose}>
|
||||
<Trans>Done</Trans>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { useState } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { API_URL, VALIDATION } from "../config/api.ts";
|
||||
import { CountedInput } from "./CountedInput.tsx";
|
||||
import type { CreatePlaylistRequest, Playlist, RawPlaylist } from "../model.ts";
|
||||
import { deserializePlaylist, parseAPIResponse } from "../model.ts";
|
||||
import { deserializePlaylist } from "../model.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { ErrorCard } from "./ErrorCard.tsx";
|
||||
import { TextEditor } from "./TextEditor.tsx";
|
||||
import {
|
||||
expectOk,
|
||||
FormActions,
|
||||
FormError,
|
||||
FormProvider,
|
||||
RichTextField,
|
||||
SubmitButton,
|
||||
TextField,
|
||||
useApiForm,
|
||||
VisibilityToggle,
|
||||
} from "./form/index.ts";
|
||||
|
||||
interface PlaylistCreateFormProps {
|
||||
/** If provided, the new playlist will have this dump added to it. */
|
||||
@@ -16,115 +23,71 @@ interface PlaylistCreateFormProps {
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
interface Values {
|
||||
title: string;
|
||||
description: string;
|
||||
isPublic: boolean;
|
||||
}
|
||||
|
||||
export function PlaylistCreateForm(
|
||||
{ dumpId, onCreated, onCancel }: PlaylistCreateFormProps,
|
||||
) {
|
||||
const { authFetch } = useAuth();
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [isPublic, setIsPublic] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const form = useApiForm<Values>({
|
||||
defaultValues: { title: "", description: "", isPublic: true },
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.SubmitEvent) => {
|
||||
e.preventDefault();
|
||||
if (
|
||||
!title.trim() || description.length > VALIDATION.PLAYLIST_DESCRIPTION_MAX
|
||||
) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await authFetch(`${API_URL}/api/playlists`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(
|
||||
{
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
isPublic,
|
||||
} satisfies CreatePlaylistRequest,
|
||||
),
|
||||
});
|
||||
const body = parseAPIResponse<RawPlaylist>(await res.json());
|
||||
if (!body.success) {
|
||||
setError(body.error.message);
|
||||
return;
|
||||
}
|
||||
const playlist = deserializePlaylist(body.data as RawPlaylist);
|
||||
if (dumpId) {
|
||||
await authFetch(
|
||||
`${API_URL}/api/playlists/${playlist.id}/dumps/${dumpId}`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
}
|
||||
onCreated(playlist);
|
||||
} catch {
|
||||
setError(t`Failed to create playlist`);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
const onSubmit = form.submit(async ({ title, description, isPublic }) => {
|
||||
const playlist = deserializePlaylist(
|
||||
await expectOk<RawPlaylist>(
|
||||
await authFetch(`${API_URL}/api/playlists`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(
|
||||
{
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
isPublic,
|
||||
} satisfies CreatePlaylistRequest,
|
||||
),
|
||||
}),
|
||||
),
|
||||
);
|
||||
if (dumpId) {
|
||||
await authFetch(
|
||||
`${API_URL}/api/playlists/${playlist.id}/dumps/${dumpId}`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
}
|
||||
};
|
||||
onCreated(playlist);
|
||||
});
|
||||
|
||||
return (
|
||||
<form className="modal-new-playlist-form" onSubmit={handleSubmit}>
|
||||
<CountedInput
|
||||
type="text"
|
||||
placeholder={t`Title`}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
autoFocus
|
||||
required
|
||||
maxLength={VALIDATION.PLAYLIST_TITLE_MAX}
|
||||
/>
|
||||
<TextEditor
|
||||
placeholder={t`Description (optional)`}
|
||||
value={description}
|
||||
onChange={setDescription}
|
||||
rows={3}
|
||||
maxLength={VALIDATION.PLAYLIST_DESCRIPTION_MAX}
|
||||
/>
|
||||
<div className="visibility-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className={isPublic ? "active" : ""}
|
||||
onClick={() => setIsPublic(true)}
|
||||
>
|
||||
<Trans>Public</Trans>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={!isPublic ? "active" : ""}
|
||||
onClick={() => setIsPublic(false)}
|
||||
>
|
||||
<Trans>Private</Trans>
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<ErrorCard title={t`Failed to create playlist`} message={error} />
|
||||
)}
|
||||
<div className="form-actions">
|
||||
<div className="form-actions-right">
|
||||
<button
|
||||
type="button"
|
||||
className="form-cancel"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={submitting ||
|
||||
description.length > VALIDATION.PLAYLIST_DESCRIPTION_MAX}
|
||||
>
|
||||
{submitting
|
||||
? <Trans>Creating…</Trans>
|
||||
: dumpId
|
||||
? <Trans>Create & Add</Trans>
|
||||
: <Trans>Create</Trans>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<FormProvider {...form}>
|
||||
<form className="form" onSubmit={onSubmit}>
|
||||
<TextField<Values>
|
||||
name="title"
|
||||
placeholder={t`Title`}
|
||||
autoFocus
|
||||
rules={{ required: true }}
|
||||
maxLength={VALIDATION.PLAYLIST_TITLE_MAX}
|
||||
showCount
|
||||
/>
|
||||
<RichTextField<Values>
|
||||
name="description"
|
||||
placeholder={t`Description (optional)`}
|
||||
rows={3}
|
||||
maxLength={VALIDATION.PLAYLIST_DESCRIPTION_MAX}
|
||||
rules={{ maxLength: VALIDATION.PLAYLIST_DESCRIPTION_MAX }}
|
||||
/>
|
||||
<VisibilityToggle<Values> name="isPublic" />
|
||||
<FormError title={t`Failed to create playlist`} />
|
||||
<FormActions onCancel={onCancel}>
|
||||
<SubmitButton pendingLabel={<Trans>Creating…</Trans>}>
|
||||
{dumpId ? <Trans>Create & Add</Trans> : <Trans>Create</Trans>}
|
||||
</SubmitButton>
|
||||
</FormActions>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
56
src/components/form/FileField.tsx
Normal file
56
src/components/form/FileField.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
Controller,
|
||||
type FieldValues,
|
||||
type Path,
|
||||
type RegisterOptions,
|
||||
useFormContext,
|
||||
} from "react-hook-form";
|
||||
|
||||
import { FileDropZone } from "../FileDropZone.tsx";
|
||||
|
||||
interface FileFieldProps<T extends FieldValues> {
|
||||
name: Path<T>;
|
||||
label?: string;
|
||||
hint?: string;
|
||||
showLimit?: boolean;
|
||||
disabled?: boolean;
|
||||
rules?: RegisterOptions<T, Path<T>>;
|
||||
}
|
||||
|
||||
/** `Controller`-wrapped {@link FileDropZone} bound to react-hook-form. */
|
||||
export function FileField<T extends FieldValues>({
|
||||
name,
|
||||
label,
|
||||
hint,
|
||||
showLimit,
|
||||
disabled,
|
||||
rules,
|
||||
}: FileFieldProps<T>) {
|
||||
const { control, formState: { errors } } = useFormContext<T>();
|
||||
const error = errors[name];
|
||||
|
||||
return (
|
||||
<div className="form-field">
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
rules={rules}
|
||||
render={({ field }) => (
|
||||
<FileDropZone
|
||||
file={(field.value as File | null) ?? null}
|
||||
onChange={field.onChange}
|
||||
label={label}
|
||||
hint={hint}
|
||||
showLimit={showLimit}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{error?.message && (
|
||||
<span className="form-hint form-hint--error">
|
||||
{String(error.message)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
src/components/form/FormActions.tsx
Normal file
39
src/components/form/FormActions.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
interface FormActionsProps {
|
||||
children: ReactNode;
|
||||
onCancel?: () => void;
|
||||
cancelLabel?: ReactNode;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Right-aligned form action row: an optional Cancel button followed by the
|
||||
* submit control(s). Standardizes the `.form-actions > .form-actions-right`
|
||||
* markup duplicated across forms.
|
||||
*/
|
||||
export function FormActions({
|
||||
children,
|
||||
onCancel,
|
||||
cancelLabel,
|
||||
disabled,
|
||||
}: FormActionsProps) {
|
||||
return (
|
||||
<div className="form-actions">
|
||||
<div className="form-actions-right">
|
||||
{onCancel && (
|
||||
<button
|
||||
type="button"
|
||||
className="form-cancel"
|
||||
onClick={onCancel}
|
||||
disabled={disabled}
|
||||
>
|
||||
{cancelLabel ?? <Trans>Cancel</Trans>}
|
||||
</button>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
src/components/form/FormError.tsx
Normal file
16
src/components/form/FormError.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useFormState } from "react-hook-form";
|
||||
|
||||
import { ErrorCard } from "../ErrorCard.tsx";
|
||||
|
||||
/**
|
||||
* Renders the form's root error (set by {@link useApiForm}'s `submit` on a
|
||||
* thrown/failed request) through the shared {@link ErrorCard}. One consistent
|
||||
* place for server/submit errors across every form.
|
||||
*/
|
||||
export function FormError({ title }: { title?: string }) {
|
||||
const { errors } = useFormState();
|
||||
const message = errors.root?.message;
|
||||
if (!message) return null;
|
||||
return <ErrorCard title={title ?? t`Something went wrong`} message={message} />;
|
||||
}
|
||||
81
src/components/form/RichTextField.tsx
Normal file
81
src/components/form/RichTextField.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useId } from "react";
|
||||
import {
|
||||
Controller,
|
||||
type FieldValues,
|
||||
type Path,
|
||||
type RegisterOptions,
|
||||
useFormContext,
|
||||
} from "react-hook-form";
|
||||
|
||||
import { TextEditor, type TextEditorHandle } from "../TextEditor.tsx";
|
||||
|
||||
interface RichTextFieldProps<T extends FieldValues> {
|
||||
name: Path<T>;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
maxLength?: number;
|
||||
autoResize?: boolean;
|
||||
disabled?: boolean;
|
||||
rules?: RegisterOptions<T, Path<T>>;
|
||||
/** Forwarded to the underlying TextEditor so callers can focus it. */
|
||||
editorRef?: React.Ref<TextEditorHandle>;
|
||||
/** Ctrl/Cmd+Enter handler (e.g. submit the comment). */
|
||||
onSubmit?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rich-text field: a `Controller`-wrapped {@link TextEditor} (mentions, emoji,
|
||||
* image upload, char count) bound to react-hook-form via the surrounding
|
||||
* `FormProvider`.
|
||||
*/
|
||||
export function RichTextField<T extends FieldValues>({
|
||||
name,
|
||||
label,
|
||||
placeholder,
|
||||
rows,
|
||||
maxLength,
|
||||
autoResize,
|
||||
disabled,
|
||||
rules,
|
||||
editorRef,
|
||||
onSubmit,
|
||||
}: RichTextFieldProps<T>) {
|
||||
const id = useId();
|
||||
const { control, formState: { errors } } = useFormContext<T>();
|
||||
const error = errors[name];
|
||||
|
||||
return (
|
||||
<div className="form-field">
|
||||
{label && (
|
||||
<label className="form-label" htmlFor={id}>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
rules={rules}
|
||||
render={({ field }) => (
|
||||
<TextEditor
|
||||
ref={editorRef}
|
||||
id={id}
|
||||
value={field.value ?? ""}
|
||||
onChange={field.onChange}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
maxLength={maxLength}
|
||||
autoResize={autoResize}
|
||||
disabled={disabled}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{error?.message && (
|
||||
<span className="form-hint form-hint--error">
|
||||
{String(error.message)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
src/components/form/SegmentedField.tsx
Normal file
82
src/components/form/SegmentedField.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
Controller,
|
||||
type FieldValues,
|
||||
type Path,
|
||||
useFormContext,
|
||||
} from "react-hook-form";
|
||||
|
||||
interface SegmentedOption<V> {
|
||||
value: V;
|
||||
label: ReactNode;
|
||||
}
|
||||
|
||||
interface SegmentedFieldProps<T extends FieldValues, V> {
|
||||
name: Path<T>;
|
||||
options: SegmentedOption<V>[];
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Segmented control (the `.visibility-toggle` style) bound to react-hook-form.
|
||||
* Generic over the field value, so it works for booleans, string unions, etc.
|
||||
*/
|
||||
export function SegmentedField<T extends FieldValues, V>({
|
||||
name,
|
||||
options,
|
||||
label,
|
||||
disabled,
|
||||
}: SegmentedFieldProps<T, V>) {
|
||||
const { control } = useFormContext<T>();
|
||||
|
||||
return (
|
||||
<div className="form-field">
|
||||
{label && <span className="form-label">{label}</span>}
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className="visibility-toggle">
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={String(opt.value)}
|
||||
type="button"
|
||||
className={field.value === opt.value ? "active" : ""}
|
||||
disabled={disabled}
|
||||
onClick={() => field.onChange(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Public/Private toggle on a boolean field (default `isPublic`). Forms that
|
||||
* persist `isPrivate` should still bind this to an `isPublic` field and invert
|
||||
* at submit time.
|
||||
*/
|
||||
export function VisibilityToggle<T extends FieldValues>({
|
||||
name,
|
||||
disabled,
|
||||
}: {
|
||||
name: Path<T>;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<SegmentedField<T, boolean>
|
||||
name={name}
|
||||
disabled={disabled}
|
||||
options={[
|
||||
{ value: true, label: <Trans>Public</Trans> },
|
||||
{ value: false, label: <Trans>Private</Trans> },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
30
src/components/form/SubmitButton.tsx
Normal file
30
src/components/form/SubmitButton.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useFormState } from "react-hook-form";
|
||||
|
||||
interface SubmitButtonProps {
|
||||
children: ReactNode;
|
||||
/** Replaces the label while the form is submitting (e.g. "Saving…"). */
|
||||
pendingLabel?: ReactNode;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit button that reflects react-hook-form's `isSubmitting` from the
|
||||
* surrounding `FormProvider`: it disables itself while submitting and swaps in
|
||||
* `pendingLabel`. Standardizes the hand-rolled "Save → Saving…" pattern.
|
||||
*/
|
||||
export function SubmitButton({
|
||||
children,
|
||||
pendingLabel,
|
||||
disabled,
|
||||
className = "btn-primary",
|
||||
}: SubmitButtonProps) {
|
||||
const { isSubmitting } = useFormState();
|
||||
|
||||
return (
|
||||
<button type="submit" className={className} disabled={isSubmitting || disabled}>
|
||||
{isSubmitting && pendingLabel != null ? pendingLabel : children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
90
src/components/form/TextField.tsx
Normal file
90
src/components/form/TextField.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useId } from "react";
|
||||
import {
|
||||
type FieldValues,
|
||||
type Path,
|
||||
type RegisterOptions,
|
||||
useFormContext,
|
||||
useWatch,
|
||||
} from "react-hook-form";
|
||||
|
||||
import { charCountClass } from "../../utils/charCount.ts";
|
||||
|
||||
interface TextFieldProps<T extends FieldValues> {
|
||||
name: Path<T>;
|
||||
label?: string;
|
||||
type?: "text" | "email" | "password" | "url" | "search";
|
||||
placeholder?: string;
|
||||
autoComplete?: string;
|
||||
autoFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
rules?: RegisterOptions<T, Path<T>>;
|
||||
/** Cap input length at `max` (native `maxLength`). */
|
||||
maxLength?: number;
|
||||
/** Show a live `n / maxLength` counter. Requires `maxLength`. */
|
||||
showCount?: boolean;
|
||||
}
|
||||
|
||||
/** Live `n / max` counter; isolated so only counted fields subscribe to value. */
|
||||
function CharCounter<T extends FieldValues>(
|
||||
{ name, max }: { name: Path<T>; max: number },
|
||||
) {
|
||||
const value = useWatch({ name }) as string | undefined;
|
||||
const len = value?.length ?? 0;
|
||||
return (
|
||||
<span className={`text-editor-count${charCountClass(len, max)}`}>
|
||||
{len} / {max}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Native text input wired to react-hook-form via the surrounding `FormProvider`.
|
||||
* Renders a label, the input (with an optional character counter), and a
|
||||
* validation hint sourced from `formState.errors[name]`.
|
||||
*/
|
||||
export function TextField<T extends FieldValues>({
|
||||
name,
|
||||
label,
|
||||
type = "text",
|
||||
placeholder,
|
||||
autoComplete,
|
||||
autoFocus,
|
||||
disabled,
|
||||
rules,
|
||||
maxLength,
|
||||
showCount = false,
|
||||
}: TextFieldProps<T>) {
|
||||
const id = useId();
|
||||
const { register, formState: { errors } } = useFormContext<T>();
|
||||
const error = errors[name];
|
||||
const counted = showCount && maxLength != null;
|
||||
|
||||
return (
|
||||
<div className="form-field">
|
||||
{label && (
|
||||
<label className="form-label" htmlFor={id}>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className={counted ? "input-with-count" : undefined}>
|
||||
<input
|
||||
id={id}
|
||||
type={type}
|
||||
placeholder={placeholder}
|
||||
autoComplete={autoComplete}
|
||||
autoFocus={autoFocus}
|
||||
disabled={disabled}
|
||||
maxLength={maxLength}
|
||||
aria-invalid={error ? true : undefined}
|
||||
{...register(name, rules)}
|
||||
/>
|
||||
{counted && <CharCounter<T> name={name} max={maxLength} />}
|
||||
</div>
|
||||
{error?.message && (
|
||||
<span className="form-hint form-hint--error">
|
||||
{String(error.message)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
src/components/form/index.ts
Normal file
9
src/components/form/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { expectOk, useApiForm } from "./useApiForm.ts";
|
||||
export { TextField } from "./TextField.tsx";
|
||||
export { RichTextField } from "./RichTextField.tsx";
|
||||
export { SegmentedField, VisibilityToggle } from "./SegmentedField.tsx";
|
||||
export { FileField } from "./FileField.tsx";
|
||||
export { SubmitButton } from "./SubmitButton.tsx";
|
||||
export { FormActions } from "./FormActions.tsx";
|
||||
export { FormError } from "./FormError.tsx";
|
||||
export { FormProvider } from "react-hook-form";
|
||||
59
src/components/form/useApiForm.ts
Normal file
59
src/components/form/useApiForm.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useCallback } from "react";
|
||||
import {
|
||||
type FieldValues,
|
||||
type SubmitHandler,
|
||||
useForm,
|
||||
type UseFormProps,
|
||||
type UseFormReturn,
|
||||
} from "react-hook-form";
|
||||
|
||||
import { friendlyFetchError } from "../../utils/apiError.ts";
|
||||
import { parseAPIResponse } from "../../model.ts";
|
||||
|
||||
export interface UseApiFormReturn<T extends FieldValues> extends UseFormReturn<T> {
|
||||
/**
|
||||
* Wraps an async submit handler with the shared API-form lifecycle: clears the
|
||||
* previous root error, runs the handler while RHF keeps `formState.isSubmitting`
|
||||
* true, and on a thrown error surfaces a single root error via
|
||||
* `friendlyFetchError`. Pair it with {@link expectOk} so API-level failures and
|
||||
* network failures both become root errors through one code path. Returns the
|
||||
* `onSubmit` handler to spread onto `<form onSubmit={...}>`.
|
||||
*/
|
||||
submit: (
|
||||
handler: SubmitHandler<T>,
|
||||
) => (e?: React.BaseSyntheticEvent) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useApiForm<T extends FieldValues>(
|
||||
options?: UseFormProps<T>,
|
||||
): UseApiFormReturn<T> {
|
||||
const form = useForm<T>(options);
|
||||
const { handleSubmit, setError, clearErrors } = form;
|
||||
|
||||
const submit = useCallback(
|
||||
(handler: SubmitHandler<T>) =>
|
||||
handleSubmit(async (values, event) => {
|
||||
clearErrors("root");
|
||||
try {
|
||||
await handler(values, event);
|
||||
} catch (err) {
|
||||
setError("root", { message: friendlyFetchError(err) });
|
||||
}
|
||||
}),
|
||||
[handleSubmit, setError, clearErrors],
|
||||
);
|
||||
|
||||
return { ...form, submit };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a fetch `Response` through the standard API envelope and returns the
|
||||
* payload, throwing the server's message on `success: false`. A thrown error
|
||||
* here (or a network `TypeError` from the `fetch` itself) is caught by
|
||||
* {@link useApiForm}'s `submit` and rendered as the form's root error.
|
||||
*/
|
||||
export async function expectOk<T>(res: Response): Promise<T> {
|
||||
const body = parseAPIResponse<T>(await res.json());
|
||||
if (!body.success) throw new Error(body.error.message);
|
||||
return body.data;
|
||||
}
|
||||
Reference in New Issue
Block a user