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

This commit is contained in:
khannurien
2026-06-26 22:20:07 +00:00
parent 26f5abfa4e
commit 73e0114bf7
30 changed files with 2369 additions and 2120 deletions

View File

@@ -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>
);

View File

@@ -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 && (

View File

@@ -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}>

View File

@@ -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>

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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} />;
}

View 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>
);
}

View 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> },
]}
/>
);
}

View 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>
);
}

View 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>
);
}

View 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";

View 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;
}