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

@@ -15,9 +15,20 @@ import { friendlyFetchError } from "../utils/apiError.ts";
import { ConfirmModal } from "../components/ConfirmModal.tsx";
import RichContentCard from "../components/RichContentCard.tsx";
import FilePreview from "../components/FilePreview.tsx";
import { TextEditor } from "../components/TextEditor.tsx";
import { FileDropZone } from "../components/FileDropZone.tsx";
import { ImagePicker } from "../components/ImagePicker.tsx";
import {
expectOk,
FormError,
FormProvider,
RichTextField,
SubmitButton,
TextField,
useApiForm,
VisibilityToggle,
} from "../components/form/index.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { Controller } from "react-hook-form";
type DumpEditState =
| { status: "loading" }
@@ -30,11 +41,6 @@ export function DumpEdit() {
const { authFetch, token } = useRequiredAuth();
const [state, setState] = useState<DumpEditState>({ status: "loading" });
const [url, setUrl] = useState("");
const [title, setTitle] = useState("");
const [comment, setComment] = useState("");
const [isPrivate, setIsPrivate] = useState(false);
const [newFile, setNewFile] = useState<File | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [thumbUploading, setThumbUploading] = useState(false);
@@ -52,12 +58,7 @@ export function DumpEdit() {
const apiResponse = parseAPIResponse<RawDump>(await res.json());
if (apiResponse.success) {
const dump: Dump = deserializeDump(apiResponse.data);
setUrl(dump.url ?? "");
setTitle(dump.title);
setComment(dump.comment ?? "");
setIsPrivate(dump.isPrivate);
setState({ status: "loaded", dump });
setState({ status: "loaded", dump: deserializeDump(apiResponse.data) });
} else {
setState({ status: "error", error: apiResponse.error.message });
}
@@ -67,56 +68,6 @@ export function DumpEdit() {
})();
}, [selectedDump, token]);
const handleSave = async () => {
const trimmedTitle = title.trim();
if (
state.status !== "loaded" ||
comment.length > VALIDATION.DUMP_COMMENT_MAX ||
!trimmedTitle || trimmedTitle.length > VALIDATION.DUMP_TITLE_MAX
) return;
let res: Response;
if (state.dump.kind === "file" && newFile) {
const formData = new FormData();
formData.append("file", newFile);
formData.append("title", trimmedTitle);
if (comment.trim()) formData.append("comment", comment.trim());
res = await authFetch(`${API_URL}/api/dumps/${state.dump.id}/file`, {
method: "PUT",
body: formData,
});
} else {
const body: UpdateDumpRequest = state.dump.kind === "url"
? {
url: url.trim() || undefined,
title: trimmedTitle,
comment: comment.trim() || undefined,
isPrivate,
}
: {
title: trimmedTitle,
comment: comment.trim() || undefined,
isPrivate,
};
res = await authFetch(`${API_URL}/api/dumps/${state.dump.id}`, {
method: "PUT",
body: JSON.stringify(body),
});
}
const apiResponse = parseAPIResponse<RawDump>(await res.json());
if (!apiResponse.success) {
setState({ status: "error", error: apiResponse.error.message });
return;
}
const updatedDump: Dump = deserializeDump(apiResponse.data);
setState({ status: "loaded", dump: updatedDump });
setNewFile(null);
navigate(dumpUrl(updatedDump), { state: { dump: updatedDump } });
};
const handleRefreshMetadata = async () => {
if (state.status !== "loaded" || state.dump.kind !== "url") return;
@@ -128,8 +79,7 @@ export function DumpEdit() {
);
const apiResponse = await res.json();
if (apiResponse.success) {
const updatedDump: Dump = deserializeDump(apiResponse.data);
setState({ status: "loaded", dump: updatedDump });
setState({ status: "loaded", dump: deserializeDump(apiResponse.data) });
}
} finally {
setRefreshing(false);
@@ -274,138 +224,17 @@ export function DumpEdit() {
)}
</div>
<form
className="dump-form"
onSubmit={(e) => {
e.preventDefault();
handleSave();
}}
>
<div className="form-group">
<label>
<Trans>Thumbnail</Trans>
</label>
<div className="dump-edit-thumbnail-row">
<ImagePicker
src={currentThumbnailSrc}
size={64}
onChange={handleThumbnailChange}
uploading={thumbUploading}
/>
{dump.thumbnailMime && (
<button
type="button"
className="btn-border"
onClick={handleThumbnailReset}
disabled={thumbUploading}
>
<Trans>Reset to default</Trans>
</button>
)}
</div>
</div>
<div className="form-group">
<label htmlFor="title">
<Trans>Title</Trans>
</label>
<input
id="title"
type="text"
value={title}
onChange={(e) => setTitle(e.currentTarget.value)}
maxLength={VALIDATION.DUMP_TITLE_MAX}
required
/>
</div>
{dump.kind === "url"
? (
<div className="form-group">
<label htmlFor="url">
<Trans>URL</Trans>
</label>
<input
id="url"
type="url"
value={url}
onChange={(e) => setUrl(e.currentTarget.value)}
placeholder="https://..."
required
/>
</div>
)
: (
<div className="form-group">
<p className="dump-file-notice">
<strong>{dump.fileName}</strong>
{dump.fileSize != null && `${formatBytes(dump.fileSize)}`}
</p>
<FileDropZone
file={newFile}
onChange={setNewFile}
label={t`Replace file`}
hint={t`Drop a replacement here`}
showLimit={false}
/>
</div>
)}
<div className="form-group">
<label htmlFor="comment">
<Trans>Why?</Trans>
</label>
<TextEditor
id="comment"
value={comment}
onChange={setComment}
placeholder={t`What makes it worth it?`}
rows={3}
maxLength={VALIDATION.DUMP_COMMENT_MAX}
/>
</div>
<div className="visibility-toggle">
<button
type="button"
className={!isPrivate ? "active" : ""}
onClick={() => setIsPrivate(false)}
>
<Trans>Public</Trans>
</button>
<button
type="button"
className={isPrivate ? "active" : ""}
onClick={() => setIsPrivate(true)}
>
<Trans>Private</Trans>
</button>
</div>
<div className="form-actions">
<button
type="button"
onClick={() => setConfirmDelete(true)}
className="btn-danger"
>
<Trans>Delete dump</Trans>
</button>
<div className="form-actions-right">
<Link to={dumpUrl(dump)} className="form-cancel">
<Trans>Cancel</Trans>
</Link>
<button
type="submit"
className="btn-primary"
disabled={comment.length > VALIDATION.DUMP_COMMENT_MAX ||
!title.trim() ||
title.trim().length > VALIDATION.DUMP_TITLE_MAX}
>
<Trans>Save</Trans>
</button>
</div>
</div>
</form>
<DumpEditForm
key={dump.id}
dump={dump}
currentThumbnailSrc={currentThumbnailSrc}
thumbUploading={thumbUploading}
onThumbnailChange={handleThumbnailChange}
onThumbnailReset={handleThumbnailReset}
onRequestDelete={() => setConfirmDelete(true)}
onSaved={(updated) =>
navigate(dumpUrl(updated), { state: { dump: updated } })}
/>
</div>
{confirmDelete && (
<ConfirmModal
@@ -418,3 +247,180 @@ export function DumpEdit() {
</PageShell>
);
}
interface DumpEditFormProps {
dump: Dump;
currentThumbnailSrc: string | null;
thumbUploading: boolean;
onThumbnailChange: (file: File) => void;
onThumbnailReset: () => void;
onRequestDelete: () => void;
onSaved: (dump: Dump) => void;
}
interface EditValues {
title: string;
url: string;
comment: string;
isPublic: boolean;
newFile: File | null;
}
function DumpEditForm({
dump,
currentThumbnailSrc,
thumbUploading,
onThumbnailChange,
onThumbnailReset,
onRequestDelete,
onSaved,
}: DumpEditFormProps) {
const { authFetch } = useAuth();
const form = useApiForm<EditValues>({
defaultValues: {
title: dump.title,
url: dump.url ?? "",
comment: dump.comment ?? "",
isPublic: !dump.isPrivate,
newFile: null,
},
});
const onSubmit = form.submit(
async ({ title, url, comment, isPublic, newFile }) => {
const trimmedTitle = title.trim();
const isPrivate = !isPublic;
let res: Response;
if (dump.kind === "file" && newFile) {
const formData = new FormData();
formData.append("file", newFile);
formData.append("title", trimmedTitle);
if (comment.trim()) formData.append("comment", comment.trim());
res = await authFetch(`${API_URL}/api/dumps/${dump.id}/file`, {
method: "PUT",
body: formData,
});
} else {
const body: UpdateDumpRequest = dump.kind === "url"
? {
url: url.trim() || undefined,
title: trimmedTitle,
comment: comment.trim() || undefined,
isPrivate,
}
: {
title: trimmedTitle,
comment: comment.trim() || undefined,
isPrivate,
};
res = await authFetch(`${API_URL}/api/dumps/${dump.id}`, {
method: "PUT",
body: JSON.stringify(body),
});
}
onSaved(deserializeDump(await expectOk<RawDump>(res)));
},
);
return (
<FormProvider {...form}>
<form className="form" onSubmit={onSubmit}>
<FormError title={t`Could not save`} />
<div className="form-field">
<span className="form-label">
<Trans>Thumbnail</Trans>
</span>
<div className="dump-edit-thumbnail-row">
<ImagePicker
src={currentThumbnailSrc}
size={64}
onChange={onThumbnailChange}
uploading={thumbUploading}
/>
{dump.thumbnailMime && (
<button
type="button"
className="btn-border"
onClick={onThumbnailReset}
disabled={thumbUploading}
>
<Trans>Reset to default</Trans>
</button>
)}
</div>
</div>
<TextField<EditValues>
name="title"
label={t`Title`}
maxLength={VALIDATION.DUMP_TITLE_MAX}
rules={{ required: true }}
/>
{dump.kind === "url"
? (
<TextField<EditValues>
name="url"
type="url"
label={t`URL`}
placeholder="https://..."
rules={{ required: true }}
/>
)
: (
<div className="form-field">
<p className="dump-file-notice">
<strong>{dump.fileName}</strong>
{dump.fileSize != null && `${formatBytes(dump.fileSize)}`}
</p>
<Controller
name="newFile"
control={form.control}
render={({ field }) => (
<FileDropZone
file={field.value}
onChange={field.onChange}
label={t`Replace file`}
hint={t`Drop a replacement here`}
showLimit={false}
/>
)}
/>
</div>
)}
<RichTextField<EditValues>
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 }}
/>
<VisibilityToggle<EditValues> name="isPublic" />
<div className="form-actions">
<button
type="button"
onClick={onRequestDelete}
className="btn-danger"
>
<Trans>Delete dump</Trans>
</button>
<div className="form-actions-right">
<Link to={dumpUrl(dump)} className="form-cancel">
<Trans>Cancel</Trans>
</Link>
<SubmitButton>
<Trans>Save</Trans>
</SubmitButton>
</div>
</div>
</form>
</FormProvider>
);
}

View File

@@ -11,6 +11,7 @@ import { Trans } from "@lingui/react/macro";
import { API_URL, VALIDATION } from "../config/api.ts";
import { CountedInput } from "../components/CountedInput.tsx";
import type {
Playlist,
PlaylistWithDumps,
RawDump,
RawPlaylist,
@@ -22,7 +23,6 @@ import {
deserializeDump,
deserializePlaylist,
deserializePlaylistWithDumps,
parseAPIResponse,
} from "../model.ts";
import { playlistUrl } from "../utils/urls.ts";
import { useAuth } from "../hooks/useAuth.ts";
@@ -36,9 +36,16 @@ import { ImagePicker } from "../components/ImagePicker.tsx";
import { Markdown } from "../components/Markdown.tsx";
import { TextEditor } from "../components/TextEditor.tsx";
import { FollowPlaylistButton } from "../components/FollowButton.tsx";
import { ErrorCard } from "../components/ErrorCard.tsx";
import { Tooltip } from "../components/Tooltip.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
import { Controller } from "react-hook-form";
import {
expectOk,
FormError,
FormProvider,
SubmitButton,
useApiForm,
} from "../components/form/index.ts";
type LoadState =
| { status: "loading" }
@@ -88,14 +95,7 @@ export function PlaylistDetail() {
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const [editOpen, setEditOpen] = useState(false);
const [editTitle, setEditTitle] = useState("");
const [editDescription, setEditDescription] = useState("");
const [editIsPublic, setEditIsPublic] = useState(true);
const [editSaving, setEditSaving] = useState(false);
const [editError, setEditError] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const [imageFile, setImageFile] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
// Mirrors activeDumpIds for use in effects without adding it as a dep.
// Updated on every render via useLayoutEffect so it's always current.
@@ -522,68 +522,11 @@ export function PlaylistDetail() {
.catch(() => {});
};
const openEdit = () => {
if (state.status !== "loaded") return;
setEditTitle(state.playlist.title);
setEditDescription(state.playlist.description ?? "");
setEditIsPublic(state.playlist.isPublic);
setImageFile(null);
setImagePreview(null);
setEditError(null);
setEditOpen(true);
};
const openEdit = () => setEditOpen(true);
const handleEditSave = async () => {
if (
!playlistId || state.status !== "loaded" ||
editDescription.length > VALIDATION.PLAYLIST_DESCRIPTION_MAX
) return;
setEditSaving(true);
setEditError(null);
try {
const updateRes = await authFetch(
`${API_URL}/api/playlists/${playlistId}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
{
...(editTitle !== state.playlist.title
? { title: editTitle }
: {}),
...(editDescription !== (state.playlist.description ?? "")
? { description: editDescription || null }
: {}),
isPublic: editIsPublic,
} satisfies UpdatePlaylistRequest,
),
},
);
const updateJson = parseAPIResponse<RawPlaylist>(await updateRes.json());
const updatedPlaylist = updateJson.success
? deserializePlaylist(updateJson.data)
: null;
if (imageFile) {
const fd = new FormData();
fd.append("file", imageFile);
await authFetch(`${API_URL}/api/playlists/${playlistId}/image`, {
method: "POST",
body: fd,
});
}
setEditOpen(false);
if (updatedPlaylist) {
navigate(playlistUrl(updatedPlaylist), { replace: true });
} else {
fetchPlaylist();
}
} catch (err) {
setEditError(friendlyFetchError(err));
} finally {
setEditSaving(false);
}
const handleEditSaved = (updated: Playlist) => {
setEditOpen(false);
navigate(playlistUrl(updated), { replace: true });
};
const handleDelete = async () => {
@@ -636,125 +579,49 @@ export function PlaylistDetail() {
<div className="playlist-detail-header-top">
{editOpen
? (
<ImagePicker
src={imagePreview ??
(playlist.imageMime
? `${API_URL}/api/playlists/${playlist.id}/image`
: null)}
alt="Cover"
size={72}
onChange={(file) => {
setImageFile(file);
setImagePreview(URL.createObjectURL(file));
}}
<PlaylistEditForm
playlist={playlist}
onCancel={() => setEditOpen(false)}
onRequestDelete={() => setConfirmDelete(true)}
onSaved={handleEditSaved}
/>
)
: playlist.imageMime && (
<img
src={`${API_URL}/api/playlists/${playlist.id}/image`}
alt=""
className="playlist-detail-img"
/>
)}
<div className="playlist-detail-content">
{editOpen
? (
<div className="playlist-detail-title-row">
<CountedInput
className="playlist-edit-input"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
autoFocus
maxLength={VALIDATION.PLAYLIST_TITLE_MAX}
: (
<>
{playlist.imageMime && (
<img
src={`${API_URL}/api/playlists/${playlist.id}/image`}
alt=""
className="playlist-detail-img"
/>
<button
type="button"
className="btn-primary"
disabled={editSaving ||
editDescription.length >
VALIDATION.PLAYLIST_DESCRIPTION_MAX}
onClick={handleEditSave}
>
{editSaving ? <Trans>Saving</Trans> : <Trans>Save</Trans>}
</button>
<button
type="button"
className="form-cancel"
onClick={() => setEditOpen(false)}
>
<Trans>Cancel</Trans>
</button>
<button
type="button"
className="btn-danger"
onClick={() => setConfirmDelete(true)}
>
<Trans>Delete</Trans>
</button>
</div>
)
: (
<div className="playlist-detail-title-row">
<h1 className="playlist-detail-title">{playlist.title}</h1>
{!isOwner && (
<FollowPlaylistButton
targetPlaylistId={playlist.id}
isPublic={playlist.isPublic}
/>
)}
{isOwner && (
<button
type="button"
className="playlist-edit-btn"
onClick={openEdit}
>
<Trans>Edit</Trans>
</button>
)}
</div>
)}
{editOpen
? (
<TextEditor
className="playlist-edit-textarea"
value={editDescription}
onChange={setEditDescription}
placeholder={t`Description (optional)`}
autoResize
rows={1}
maxLength={VALIDATION.PLAYLIST_DESCRIPTION_MAX}
/>
)
: playlist.description && (
<Markdown className="playlist-detail-description">
{playlist.description}
</Markdown>
)}
<div className="playlist-detail-meta">
{editOpen
? (
<div className="visibility-toggle playlist-edit-toggle">
<button
type="button"
className={editIsPublic ? "active" : ""}
onClick={() => setEditIsPublic(true)}
>
<Trans>Public</Trans>
</button>
<button
type="button"
className={!editIsPublic ? "active" : ""}
onClick={() => setEditIsPublic(false)}
>
<Trans>Private</Trans>
</button>
)}
<div className="playlist-detail-content">
<div className="playlist-detail-title-row">
<h1 className="playlist-detail-title">{playlist.title}</h1>
{!isOwner && (
<FollowPlaylistButton
targetPlaylistId={playlist.id}
isPublic={playlist.isPublic}
/>
)}
{isOwner && (
<button
type="button"
className="playlist-edit-btn"
onClick={openEdit}
>
<Trans>Edit</Trans>
</button>
)}
</div>
)
: (
<>
{playlist.description && (
<Markdown className="playlist-detail-description">
{playlist.description}
</Markdown>
)}
<div className="playlist-detail-meta">
<span
className={`playlist-badge${
playlist.isPublic ? "" : " playlist-badge--private"
@@ -788,13 +655,10 @@ export function PlaylistDetail() {
</span>
</Tooltip>
)}
</>
)}
</div>
{editError && (
<ErrorCard title={t`Failed to save`} message={editError} />
</div>
</div>
</>
)}
</div>
</div>
</div>
@@ -887,3 +751,173 @@ export function PlaylistDetail() {
</PageShell>
);
}
interface PlaylistEditFormProps {
playlist: PlaylistWithDumps;
onCancel: () => void;
onRequestDelete: () => void;
onSaved: (playlist: Playlist) => void;
}
interface EditValues {
title: string;
description: string;
isPublic: boolean;
image: File | null;
}
function PlaylistEditForm(
{ playlist, onCancel, onRequestDelete, onSaved }: PlaylistEditFormProps,
) {
const { authFetch } = useAuth();
const [imagePreview, setImagePreview] = useState<string | null>(null);
const form = useApiForm<EditValues>({
defaultValues: {
title: playlist.title,
description: playlist.description ?? "",
isPublic: playlist.isPublic,
image: null,
},
});
const description = form.watch("description");
const onSubmit = form.submit(
async ({ title, description, isPublic, image }) => {
const updated = deserializePlaylist(
await expectOk<RawPlaylist>(
await authFetch(`${API_URL}/api/playlists/${playlist.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
{
...(title !== playlist.title ? { title } : {}),
...(description !== (playlist.description ?? "")
? { description: description || null }
: {}),
isPublic,
} satisfies UpdatePlaylistRequest,
),
}),
),
);
if (image) {
const fd = new FormData();
fd.append("file", image);
await authFetch(`${API_URL}/api/playlists/${playlist.id}/image`, {
method: "POST",
body: fd,
});
}
onSaved(updated);
},
);
return (
<FormProvider {...form}>
{/* `display: contents` keeps the image-beside-content flex layout while
still giving us a real <form> for submit + Enter handling. */}
<form style={{ display: "contents" }} onSubmit={onSubmit}>
<Controller
name="image"
control={form.control}
render={({ field }) => (
<ImagePicker
src={imagePreview ??
(playlist.imageMime
? `${API_URL}/api/playlists/${playlist.id}/image`
: null)}
alt="Cover"
size={72}
onChange={(file) => {
field.onChange(file);
setImagePreview(URL.createObjectURL(file));
}}
/>
)}
/>
<div className="playlist-detail-content playlist-edit-content">
<Controller
name="title"
control={form.control}
render={({ field }) => (
<CountedInput
className="playlist-edit-input"
value={field.value}
onChange={field.onChange}
autoFocus
placeholder={t`Playlist title`}
maxLength={VALIDATION.PLAYLIST_TITLE_MAX}
/>
)}
/>
<Controller
name="description"
control={form.control}
render={({ field }) => (
<TextEditor
className="playlist-edit-textarea"
value={field.value}
onChange={field.onChange}
placeholder={t`Description (optional)`}
autoResize
rows={2}
maxLength={VALIDATION.PLAYLIST_DESCRIPTION_MAX}
/>
)}
/>
<Controller
name="isPublic"
control={form.control}
render={({ field }) => (
<div className="visibility-toggle playlist-edit-toggle">
<button
type="button"
className={field.value ? "active" : ""}
onClick={() => field.onChange(true)}
>
<Trans>Public</Trans>
</button>
<button
type="button"
className={!field.value ? "active" : ""}
onClick={() => field.onChange(false)}
>
<Trans>Private</Trans>
</button>
</div>
)}
/>
<FormError title={t`Failed to save`} />
<div className="form-actions">
<button
type="button"
className="btn-danger"
onClick={onRequestDelete}
>
<Trans>Delete</Trans>
</button>
<div className="form-actions-right">
<button type="button" className="form-cancel" onClick={onCancel}>
<Trans>Cancel</Trans>
</button>
<SubmitButton
pendingLabel={<Trans>Saving</Trans>}
disabled={description.length >
VALIDATION.PLAYLIST_DESCRIPTION_MAX}
>
<Trans>Save</Trans>
</SubmitButton>
</div>
</div>
</div>
</form>
</FormProvider>
);
}

View File

@@ -4,27 +4,42 @@ import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL, VALIDATION } from "../config/api.ts";
import { ErrorCard } from "../components/ErrorCard.tsx";
import { PageShell } from "../components/PageShell.tsx";
import {
expectOk,
FormError,
FormProvider,
SubmitButton,
TextField,
useApiForm,
} from "../components/form/index.ts";
type State =
| { status: "idle" }
| { status: "submitting" }
| { status: "done" }
| { status: "error"; error: string };
interface Values {
newPassword: string;
confirm: string;
}
export function ResetPassword() {
const [params] = useSearchParams();
const navigate = useNavigate();
const token = params.get("token") ?? "";
const [done, setDone] = useState(false);
const [newPassword, setNewPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [state, setState] = useState<State>({ status: "idle" });
const form = useApiForm<Values>({
mode: "onChange",
defaultValues: { newPassword: "", confirm: "" },
});
const mismatch = confirm.length > 0 && newPassword !== confirm;
const tooShort = newPassword.length > 0 &&
newPassword.length < VALIDATION.PASSWORD_MIN;
const onSubmit = form.submit(async ({ newPassword }) => {
await expectOk(
await fetch(`${API_URL}/api/users/reset-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, newPassword }),
}),
);
setDone(true);
});
if (!token) {
return (
@@ -48,7 +63,7 @@ export function ResetPassword() {
);
}
if (state.status === "done") {
if (done) {
return (
<PageShell centered>
<div className="auth-card">
@@ -70,31 +85,6 @@ export function ResetPassword() {
);
}
const handleSubmit = async (e: React.SubmitEvent) => {
e.preventDefault();
if (mismatch || tooShort || !newPassword) return;
setState({ status: "submitting" });
try {
const res = await fetch(`${API_URL}/api/users/reset-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, newPassword }),
});
const body = await res.json();
if (body.success) {
setState({ status: "done" });
} else {
setState({
status: "error",
error: body.error?.message ?? t`Unknown error`,
});
}
} catch {
setState({ status: "error", error: t`Could not connect to server` });
}
};
return (
<PageShell centered>
<div className="auth-card">
@@ -102,57 +92,40 @@ export function ResetPassword() {
<Trans>Set new password</Trans>
</h1>
{state.status === "error" && (
<ErrorCard title={t`Reset failed`} message={state.error} />
)}
<form onSubmit={handleSubmit} className="auth-form">
<div className="form-group">
<input
<FormProvider {...form}>
<form onSubmit={onSubmit} className="form">
<FormError title={t`Reset failed`} />
<TextField<Values>
name="newPassword"
type="password"
placeholder={t`New password`}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password"
minLength={VALIDATION.PASSWORD_MIN}
maxLength={VALIDATION.PASSWORD_MAX}
required
autoFocus
disabled={state.status === "submitting"}
maxLength={VALIDATION.PASSWORD_MAX}
rules={{
required: true,
minLength: {
value: VALIDATION.PASSWORD_MIN,
message: t`At least ${VALIDATION.PASSWORD_MIN} characters`,
},
}}
/>
{tooShort && (
<span className="auth-field-hint auth-field-hint--error">
<Trans>At least {VALIDATION.PASSWORD_MIN} characters</Trans>
</span>
)}
</div>
<div className="form-group">
<input
<TextField<Values>
name="confirm"
type="password"
placeholder={t`Confirm new password`}
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
autoComplete="new-password"
required
disabled={state.status === "submitting"}
rules={{
required: true,
validate: (value, values) =>
value === values.newPassword || t`Passwords do not match`,
}}
/>
{mismatch && (
<span className="auth-field-hint auth-field-hint--error">
<Trans>Passwords do not match</Trans>
</span>
)}
</div>
<button
type="submit"
className="btn-primary"
disabled={state.status === "submitting" || mismatch || tooShort ||
!newPassword || !confirm}
>
{state.status === "submitting"
? <Trans>Saving</Trans>
: <Trans>Set new password</Trans>}
</button>
</form>
<SubmitButton pendingLabel={<Trans>Saving</Trans>}>
<Trans>Set new password</Trans>
</SubmitButton>
</form>
</FormProvider>
<p className="auth-card-footer">
<Link to="/login">

View File

@@ -1,5 +1,4 @@
import { useState } from "react";
import type { SubmitEvent } from "react";
import { useNavigate } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
@@ -8,77 +7,48 @@ import { API_URL } from "../config/api.ts";
import {
deserializeAuthResponse,
type LoginRequest,
parseAPIResponse,
type RawAuthResponse,
} from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { PageShell } from "../components/PageShell.tsx";
import { ErrorCard } from "../components/ErrorCard.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
import {
expectOk,
FormError,
FormProvider,
SubmitButton,
TextField,
useApiForm,
} from "../components/form/index.ts";
type LoginState =
| { status: "idle" }
| { status: "submitting" }
| { status: "error"; error: string };
interface LoginValues {
username: string;
password: string;
}
type ResetState =
| { status: "idle" }
| { status: "submitting" }
| { status: "sent" }
| { status: "error"; error: string };
interface ResetValues {
email: string;
}
export function UserLogin() {
const navigate = useNavigate();
const { login } = useAuth();
const [loginState, setLoginState] = useState<LoginState>({ status: "idle" });
const [showReset, setShowReset] = useState(false);
const [resetEmail, setResetEmail] = useState("");
const [resetState, setResetState] = useState<ResetState>({ status: "idle" });
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setLoginState({ status: "submitting" });
const form = useApiForm<LoginValues>({
defaultValues: { username: "", password: "" },
});
const formData = new FormData(e.currentTarget);
const username = formData.get("username") as string;
const password = formData.get("password") as string;
try {
const res = await fetch(`${API_URL}/api/users/login`, {
const onSubmit = form.submit(async ({ username, password }) => {
const data = await expectOk<RawAuthResponse>(
await fetch(`${API_URL}/api/users/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password } satisfies LoginRequest),
});
const apiResponse = parseAPIResponse<RawAuthResponse>(await res.json());
if (apiResponse.success) {
login(deserializeAuthResponse(apiResponse.data));
navigate("/");
} else {
setLoginState({ status: "error", error: apiResponse.error.message });
}
} catch (err) {
setLoginState({ status: "error", error: friendlyFetchError(err) });
}
};
const handleResetRequest = async (e: React.SubmitEvent) => {
e.preventDefault();
if (!resetEmail.trim()) return;
setResetState({ status: "submitting" });
try {
await fetch(`${API_URL}/api/users/request-password-reset`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: resetEmail.trim() }),
});
setResetState({ status: "sent" });
} catch {
setResetState({ status: "error", error: t`Could not connect to server` });
}
};
}),
);
login(deserializeAuthResponse(data));
navigate("/");
});
return (
<PageShell centered>
@@ -87,98 +57,38 @@ export function UserLogin() {
<Trans>Log in</Trans>
</h1>
{loginState.status === "error" && (
<ErrorCard title={t`Login failed`} message={loginState.error} />
)}
<form onSubmit={handleSubmit} className="auth-form">
<input
name="username"
type="text"
placeholder={t`Username`}
required
disabled={loginState.status === "submitting"}
autoFocus
/>
<input
name="password"
type="password"
placeholder={t`Password`}
required
disabled={loginState.status === "submitting"}
/>
<button
type="submit"
className="btn-primary"
disabled={loginState.status === "submitting"}
>
{loginState.status === "submitting"
? <Trans>Logging in</Trans>
: <Trans>Log in</Trans>}
</button>
</form>
<FormProvider {...form}>
<form onSubmit={onSubmit} className="form">
<FormError title={t`Login failed`} />
<TextField<LoginValues>
name="username"
placeholder={t`Username`}
autoFocus
rules={{ required: true }}
/>
<TextField<LoginValues>
name="password"
type="password"
placeholder={t`Password`}
rules={{ required: true }}
/>
<SubmitButton pendingLabel={<Trans>Logging in</Trans>}>
<Trans>Log in</Trans>
</SubmitButton>
</form>
</FormProvider>
<p className="auth-card-footer">
<button
type="button"
className="auth-link-btn"
onClick={() => {
setShowReset((v) => !v);
setResetState({ status: "idle" });
setResetEmail("");
}}
onClick={() => setShowReset((v) => !v)}
>
<Trans>Forgot password?</Trans>
</button>
</p>
{showReset && (
<div className="auth-reset-panel">
{resetState.status === "sent"
? (
<p className="auth-reset-sent">
<Trans>
If that address is registered you'll receive a reset link
shortly.
</Trans>
</p>
)
: (
<>
{resetState.status === "error" && (
<ErrorCard
title={t`Request failed`}
message={resetState.error}
/>
)}
<form
onSubmit={handleResetRequest}
className="auth-form auth-reset-form"
>
<input
type="email"
placeholder={t`Your email address`}
value={resetEmail}
onChange={(e) => setResetEmail(e.target.value)}
required
autoFocus
disabled={resetState.status === "submitting"}
/>
<button
type="submit"
className="btn-primary"
disabled={resetState.status === "submitting" ||
!resetEmail.trim()}
>
{resetState.status === "submitting"
? <Trans>Sending</Trans>
: <Trans>Send reset link</Trans>}
</button>
</form>
</>
)}
</div>
)}
{showReset && <ResetRequestPanel />}
<p className="auth-card-footer">
<Trans>This is a mirage.</Trans>
@@ -187,3 +97,49 @@ export function UserLogin() {
</PageShell>
);
}
function ResetRequestPanel() {
const [sent, setSent] = useState(false);
const form = useApiForm<ResetValues>({ defaultValues: { email: "" } });
const onSubmit = form.submit(async ({ email }) => {
await fetch(`${API_URL}/api/users/request-password-reset`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: email.trim() }),
});
setSent(true);
});
if (sent) {
return (
<div className="auth-reset-panel">
<p className="auth-reset-sent">
<Trans>
If that address is registered you'll receive a reset link shortly.
</Trans>
</p>
</div>
);
}
return (
<div className="auth-reset-panel">
<FormProvider {...form}>
<form onSubmit={onSubmit} className="form auth-reset-form">
<FormError title={t`Request failed`} />
<TextField<ResetValues>
name="email"
type="email"
placeholder={t`Your email address`}
autoFocus
rules={{ required: true }}
/>
<SubmitButton pendingLabel={<Trans>Sending</Trans>}>
<Trans>Send reset link</Trans>
</SubmitButton>
</form>
</FormProvider>
</div>
);
}

View File

@@ -55,6 +55,14 @@ import { Markdown } from "../components/Markdown.tsx";
import { ChangePasswordModal } from "../components/ChangePasswordModal.tsx";
import { TabBar } from "../components/TabBar.tsx";
import { useTabParam } from "../hooks/useTabParam.ts";
import { Controller } from "react-hook-form";
import {
expectOk,
FormError,
FormProvider,
SubmitButton,
useApiForm,
} from "../components/form/index.ts";
function InviteButton() {
const { authFetch } = useAuth();
@@ -292,14 +300,7 @@ export function UserPublicProfile() {
const fileInputRef = useRef<HTMLInputElement>(null);
const [descEditing, setDescEditing] = useState(false);
const [descDraft, setDescDraft] = useState("");
const [descSaving, setDescSaving] = useState(false);
const [descError, setDescError] = useState<string | null>(null);
const [emailEditing, setEmailEditing] = useState(false);
const [emailDraft, setEmailDraft] = useState("");
const [emailSaving, setEmailSaving] = useState(false);
const [emailError, setEmailError] = useState<string | null>(null);
const prevMyVotesRef = useRef<Set<string> | null>(null);
const [changePasswordOpen, setChangePasswordOpen] = useState(false);
@@ -674,72 +675,22 @@ export function UserPublicProfile() {
}
};
const handleEmailSave = async () => {
if (state.status !== "loaded") return;
setEmailSaving(true);
setEmailError(null);
try {
const res = await authFetch(`${API_URL}/api/users/me`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
{ email: emailDraft.trim() } satisfies UpdateUserRequest,
),
});
const body = parseAPIResponse<RawPublicUser>(await res.json());
if (!body.success) {
setEmailError(body.error.message);
return;
}
const storedRaw = localStorage.getItem("authResponse");
if (storedRaw) {
const prev = deserializeAuthResponse(JSON.parse(storedRaw));
login({ ...prev, user: { ...prev.user, email: emailDraft.trim() } });
}
setEmailEditing(false);
} catch {
setEmailError(t`Failed to save`);
} finally {
setEmailSaving(false);
const handleEmailSaved = (email: string) => {
const storedRaw = localStorage.getItem("authResponse");
if (storedRaw) {
const prev = deserializeAuthResponse(JSON.parse(storedRaw));
login({ ...prev, user: { ...prev.user, email } });
}
setEmailEditing(false);
};
const handleDescSave = async () => {
if (
state.status !== "loaded" ||
descDraft.length > VALIDATION.USER_DESCRIPTION_MAX
) return;
setDescSaving(true);
setDescError(null);
try {
const res = await authFetch(`${API_URL}/api/users/me`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
{
description: descDraft.trim() || undefined,
} satisfies UpdateUserRequest,
),
});
const body = parseAPIResponse<RawPublicUser>(await res.json());
if (!body.success) {
setDescError(body.error.message);
return;
}
setState((s) =>
s.status === "loaded"
? {
...s,
user: { ...s.user, description: descDraft.trim() || undefined },
}
: s
);
setDescEditing(false);
} catch {
setDescError(t`Failed to save`);
} finally {
setDescSaving(false);
}
const handleDescSaved = (description: string | undefined) => {
setState((s) =>
s.status === "loaded"
? { ...s, user: { ...s.user, description } }
: s
);
setDescEditing(false);
};
if (state.status === "loading") {
@@ -826,59 +777,16 @@ export function UserPublicProfile() {
{isOwnProfile && (
emailEditing
? (
<form
className="profile-email-editor"
onSubmit={(e) => {
e.preventDefault();
handleEmailSave();
}}
>
<input
type="email"
className="profile-email-input"
value={emailDraft}
onChange={(e) => setEmailDraft(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Escape") setEmailEditing(false);
}}
disabled={emailSaving}
autoFocus
/>
<div className="profile-email-actions">
<button
type="submit"
className="profile-email-btn profile-email-btn--save"
disabled={emailSaving || !emailDraft.trim()}
>
{emailSaving
? <Trans>Saving</Trans>
: <Trans>Save</Trans>}
</button>
<button
type="button"
className="profile-email-btn profile-email-btn--cancel"
onClick={() => setEmailEditing(false)}
disabled={emailSaving}
>
<Trans>Cancel</Trans>
</button>
</div>
{emailError && (
<ErrorCard
title={t`Failed to save`}
message={emailError}
/>
)}
</form>
<EmailEditor
initialEmail={me?.email ?? ""}
onCancel={() => setEmailEditing(false)}
onSaved={handleEmailSaved}
/>
)
: (
<p
className="profile-email-display"
onClick={() => {
setEmailDraft(me?.email ?? "");
setEmailError(null);
setEmailEditing(true);
}}
onClick={() => setEmailEditing(true)}
title="Edit email"
>
{me?.email ?? t`Add email…`}
@@ -916,39 +824,11 @@ export function UserPublicProfile() {
<div className="profile-description">
{descEditing
? (
<div className="profile-description-editor">
<TextEditor
className="comment-reply-textarea"
value={descDraft}
onChange={setDescDraft}
onSubmit={handleDescSave}
placeholder={t`Who am I?`}
autoResize
maxLength={VALIDATION.USER_DESCRIPTION_MAX}
/>
<div className="profile-description-actions">
<button
type="button"
className="btn-primary"
onClick={handleDescSave}
disabled={descSaving ||
descDraft.length > VALIDATION.USER_DESCRIPTION_MAX}
>
{descSaving ? <Trans>Saving</Trans> : <Trans>Save</Trans>}
</button>
<button
type="button"
className="btn-border"
onClick={() => setDescEditing(false)}
disabled={descSaving}
>
<Trans>Cancel</Trans>
</button>
{descError && (
<ErrorCard title={t`Failed to save`} message={descError} />
)}
</div>
</div>
<DescriptionEditor
initialDescription={profileUser.description ?? ""}
onCancel={() => setDescEditing(false)}
onSaved={handleDescSaved}
/>
)
: (
<div
@@ -956,11 +836,7 @@ export function UserPublicProfile() {
isOwnProfile ? " profile-description-view--editable" : ""
}`}
onClick={isOwnProfile
? () => {
setDescDraft(profileUser.description ?? "");
setDescError(null);
setDescEditing(true);
}
? () => setDescEditing(true)
: undefined}
>
{profileUser.description
@@ -1548,3 +1424,136 @@ function FollowedUserCard({ user }: { user: PublicUser }) {
</li>
);
}
interface EmailEditorProps {
initialEmail: string;
onCancel: () => void;
onSaved: (email: string) => void;
}
function EmailEditor({ initialEmail, onCancel, onSaved }: EmailEditorProps) {
const { authFetch } = useAuth();
const form = useApiForm<{ email: string }>({
defaultValues: { email: initialEmail },
});
const email = form.watch("email");
const submitting = form.formState.isSubmitting;
const onSubmit = form.submit(async ({ email }) => {
await expectOk<RawPublicUser>(
await authFetch(`${API_URL}/api/users/me`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
{ email: email.trim() } satisfies UpdateUserRequest,
),
}),
);
onSaved(email.trim());
});
return (
<FormProvider {...form}>
<form className="profile-email-editor" onSubmit={onSubmit}>
<input
type="email"
className="profile-email-input"
{...form.register("email")}
onKeyDown={(e) => {
if (e.key === "Escape") onCancel();
}}
disabled={submitting}
autoFocus
/>
<div className="profile-email-actions">
<SubmitButton
className="profile-email-btn profile-email-btn--save"
pendingLabel={<Trans>Saving</Trans>}
disabled={!email.trim()}
>
<Trans>Save</Trans>
</SubmitButton>
<button
type="button"
className="profile-email-btn profile-email-btn--cancel"
onClick={onCancel}
disabled={submitting}
>
<Trans>Cancel</Trans>
</button>
</div>
<FormError title={t`Failed to save`} />
</form>
</FormProvider>
);
}
interface DescriptionEditorProps {
initialDescription: string;
onCancel: () => void;
onSaved: (description: string | undefined) => void;
}
function DescriptionEditor(
{ initialDescription, onCancel, onSaved }: DescriptionEditorProps,
) {
const { authFetch } = useAuth();
const form = useApiForm<{ description: string }>({
defaultValues: { description: initialDescription },
});
const description = form.watch("description");
const submitting = form.formState.isSubmitting;
const onSubmit = form.submit(async ({ description }) => {
const trimmed = description.trim() || undefined;
await expectOk<RawPublicUser>(
await authFetch(`${API_URL}/api/users/me`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
{ description: trimmed } satisfies UpdateUserRequest,
),
}),
);
onSaved(trimmed);
});
return (
<FormProvider {...form}>
<form className="profile-description-editor" onSubmit={onSubmit}>
<Controller
name="description"
control={form.control}
render={({ field }) => (
<TextEditor
className="comment-reply-textarea"
value={field.value}
onChange={field.onChange}
onSubmit={() => void onSubmit()}
placeholder={t`Who am I?`}
autoResize
maxLength={VALIDATION.USER_DESCRIPTION_MAX}
/>
)}
/>
<div className="profile-description-actions">
<SubmitButton
pendingLabel={<Trans>Saving</Trans>}
disabled={description.length > VALIDATION.USER_DESCRIPTION_MAX}
>
<Trans>Save</Trans>
</SubmitButton>
<button
type="button"
className="form-cancel"
onClick={onCancel}
disabled={submitting}
>
<Trans>Cancel</Trans>
</button>
<FormError title={t`Failed to save`} />
</div>
</form>
</FormProvider>
);
}

View File

@@ -1,5 +1,4 @@
import { useEffect, useState } from "react";
import type { SubmitEvent } from "react";
import { Link, useNavigate, useSearchParams } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
@@ -7,24 +6,31 @@ import { Trans } from "@lingui/react/macro";
import { API_URL, VALIDATION } from "../config/api.ts";
import {
deserializeAuthResponse,
parseAPIResponse,
type RawAuthResponse,
type RegisterRequest,
} from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { PageShell } from "../components/PageShell.tsx";
import { ErrorCard } from "../components/ErrorCard.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
import {
expectOk,
FormError,
FormProvider,
SubmitButton,
TextField,
useApiForm,
} from "../components/form/index.ts";
type TokenState =
| { status: "checking" }
| { status: "invalid" }
| { status: "valid" };
type FormState =
| { status: "idle" }
| { status: "submitting" }
| { status: "error"; error: string };
interface Values {
username: string;
email: string;
password: string;
}
export function UserRegister() {
const navigate = useNavigate();
@@ -35,9 +41,12 @@ export function UserRegister() {
const [tokenState, setTokenState] = useState<TokenState>(() =>
token ? { status: "checking" } : { status: "invalid" }
);
const [formState, setFormState] = useState<FormState>({ status: "idle" });
const [prevToken, setPrevToken] = useState(token);
const form = useApiForm<Values>({
defaultValues: { username: "", email: "", password: "" },
});
if (prevToken !== token) {
setPrevToken(token);
setTokenState(token ? { status: "checking" } : { status: "invalid" });
@@ -52,41 +61,20 @@ export function UserRegister() {
.catch(() => setTokenState({ status: "invalid" }));
}, [token]);
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setFormState({ status: "submitting" });
const formData = new FormData(e.currentTarget);
const username = formData.get("username") as string;
const password = formData.get("password") as string;
const email = formData.get("email") as string;
try {
const res = await fetch(`${API_URL}/api/users/register`, {
const onSubmit = form.submit(async ({ username, email, password }) => {
const data = await expectOk<RawAuthResponse>(
await fetch(`${API_URL}/api/users/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
{
username,
password,
inviteToken: token,
email,
} satisfies RegisterRequest,
{ username, password, inviteToken: token, email } satisfies
RegisterRequest,
),
});
const apiResponse = parseAPIResponse<RawAuthResponse>(await res.json());
if (apiResponse.success) {
login(deserializeAuthResponse(apiResponse.data));
navigate("/");
} else {
setFormState({ status: "error", error: apiResponse.error.message });
}
} catch (err) {
setFormState({ status: "error", error: friendlyFetchError(err) });
}
};
}),
);
login(deserializeAuthResponse(data));
navigate("/");
});
if (tokenState.status === "checking") {
return (
@@ -118,48 +106,49 @@ export function UserRegister() {
<Trans>Register</Trans>
</h1>
{formState.status === "error" && (
<ErrorCard title={t`Registration failed`} message={formState.error} />
)}
<form onSubmit={handleSubmit} className="auth-form">
<input
name="username"
type="text"
placeholder={t`Username`}
required
pattern={`[a-zA-Z0-9_]{${VALIDATION.USERNAME_MIN},${VALIDATION.USERNAME_MAX}}`}
title={t`${VALIDATION.USERNAME_MIN}${VALIDATION.USERNAME_MAX} characters: letters, numbers, or underscores`}
maxLength={VALIDATION.USERNAME_MAX}
disabled={formState.status === "submitting"}
autoFocus
/>
<input
name="email"
type="email"
placeholder={t`Email address`}
required
disabled={formState.status === "submitting"}
/>
<input
name="password"
type="password"
placeholder={t`Password (min. ${VALIDATION.PASSWORD_MIN} characters)`}
required
minLength={VALIDATION.PASSWORD_MIN}
maxLength={VALIDATION.PASSWORD_MAX}
disabled={formState.status === "submitting"}
/>
<button
type="submit"
className="btn-primary"
disabled={formState.status === "submitting"}
>
{formState.status === "submitting"
? <Trans>Registering</Trans>
: <Trans>Register</Trans>}
</button>
</form>
<FormProvider {...form}>
<form onSubmit={onSubmit} className="form">
<FormError title={t`Registration failed`} />
<TextField<Values>
name="username"
placeholder={t`Username`}
autoFocus
maxLength={VALIDATION.USERNAME_MAX}
rules={{
required: true,
pattern: {
value: new RegExp(
`^[a-zA-Z0-9_]{${VALIDATION.USERNAME_MIN},${VALIDATION.USERNAME_MAX}}$`,
),
message:
t`${VALIDATION.USERNAME_MIN}${VALIDATION.USERNAME_MAX} characters: letters, numbers, or underscores`,
},
}}
/>
<TextField<Values>
name="email"
type="email"
placeholder={t`Email address`}
rules={{ required: true }}
/>
<TextField<Values>
name="password"
type="password"
placeholder={t`Password (min. ${VALIDATION.PASSWORD_MIN} characters)`}
maxLength={VALIDATION.PASSWORD_MAX}
rules={{
required: true,
minLength: {
value: VALIDATION.PASSWORD_MIN,
message: t`At least ${VALIDATION.PASSWORD_MIN} characters`,
},
}}
/>
<SubmitButton pendingLabel={<Trans>Registering</Trans>}>
<Trans>Register</Trans>
</SubmitButton>
</form>
</FormProvider>
<p className="auth-card-footer">
<Trans>