diff --git a/README.md b/README.md index fbb6c3f..fe27d63 100644 --- a/README.md +++ b/README.md @@ -111,21 +111,29 @@ api/ middleware/ # errorMiddleware, authMiddleware routes/ # HTTP routes + WebSocket services/ # Business logic - model/ # Database schema, row types, type guards - lib/ # JWT, pagination, upload helpers, … - sql/ + providers/ # Link metadata providers (YouTube, SoundCloud, Bandcamp, …) + model/ # Row types, type guards + lib/ # JWT, pagination, slugify, upload, static helpers, … + db/ schema.sql # Database schema init.ts # First-run database initialisation + migrate.ts # Migration runner + migrations/ # Versioned schema migrations + sql/ gerbeur.db # SQLite database (not committed) - uploads/ # User-uploaded files (not committed) + uploads/ # User-uploaded files — avatars, dumps, thumbnails, … (not committed) src/ # React frontend (Vite) - config/api.ts # API base URL, validation constants + config/ # API base URL, validation constants, feed tabs, upload limits pages/ # Route-level components + index/ # Feed views (hot, new, followed, journal) components/ # Shared UI components + form/ # Reusable form controls contexts/ # Auth, WebSocket, player, follows, theme hooks/ # Data fetching and UI hooks + utils/ # Formatting, URL, hot-score, waveform, … helpers locales/ # Lingui message catalogues (en, fr) themes/ # Per-theme CSS files + model.ts # Shared frontend types i18n.ts # Lingui runtime setup -public/ # Static assets (favicon, icon sprite) +public/ # Static assets (favicon, manifest, service worker) ``` diff --git a/api/routes/files.ts b/api/routes/files.ts index d703822..16f5f55 100644 --- a/api/routes/files.ts +++ b/api/routes/files.ts @@ -5,6 +5,23 @@ import { DUMPS_DIR } from "../lib/upload.ts"; const router = new Router({ prefix: "/api/files" }); +/** + * Builds an RFC 6266 `Content-Disposition` value that is always a valid + * ByteString. `headers.set` throws a TypeError if the value contains any + * character > U+00FF, so a filename with an emoji / accent / CJK character + * (e.g. an uploaded file) would otherwise crash the response. We provide an + * ASCII-only `filename="…"` fallback plus an RFC 5987 `filename*` with the + * full UTF-8 name for modern browsers. + */ +function contentDisposition(name: string): string { + const asciiFallback = name + .replace(/["\\]/g, "_") + .replace(/[^\x20-\x7e]/g, "_"); + return `inline; filename="${asciiFallback}"; filename*=UTF-8''${ + encodeURIComponent(name) + }`; +} + router.get("/:dumpId", async (ctx) => { const { dumpId } = ctx.params; @@ -36,7 +53,7 @@ router.get("/:dumpId", async (ctx) => { ctx.response.headers.set("Content-Type", dump.fileMime); ctx.response.headers.set( "Content-Disposition", - `inline; filename="${dump.fileName}"`, + contentDisposition(dump.fileName), ); ctx.response.headers.set("Accept-Ranges", "bytes"); diff --git a/deno.lock b/deno.lock index f08913a..4d29c03 100644 --- a/deno.lock +++ b/deno.lock @@ -43,6 +43,7 @@ "npm:nodemailer@^9.0.1": "9.0.1", "npm:path-to-regexp@^6.3.0": "6.3.0", "npm:react-dom@^19.2.7": "19.2.7_react@19.2.7", + "npm:react-hook-form@^7.80.0": "7.80.0_react@19.2.7", "npm:react-markdown@^10.1.0": "10.1.0_@types+react@19.2.17_react@19.2.7", "npm:react-router@^8.0.1": "8.0.1_react@19.2.7_react-dom@19.2.7__react@19.2.7", "npm:react@^19.2.7": "19.2.7", @@ -2259,6 +2260,12 @@ "scheduler" ] }, + "react-hook-form@7.80.0_react@19.2.7": { + "integrity": "sha512-4P+fk6oXsxY+6xSj7Euhc2sumQD8zQqCuVHoJwoyp9EchP+IUW9OESB7uHFJOKsIBQ4MQqYE84INJFqUCYNoOg==", + "dependencies": [ + "react" + ] + }, "react-is@18.3.1": { "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" }, @@ -2665,6 +2672,7 @@ "npm:globals@^17.6.0", "npm:jiti@^2.7.0", "npm:react-dom@^19.2.7", + "npm:react-hook-form@^7.80.0", "npm:react-markdown@^10.1.0", "npm:react-router@^8.0.1", "npm:react@^19.2.7", diff --git a/package.json b/package.json index ab60c23..8fdc9e6 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "jiti": "^2.7.0", "react": "^19.2.7", "react-dom": "^19.2.7", + "react-hook-form": "^7.80.0", "react-markdown": "^10.1.0", "react-router": "^8.0.1", "remark-gfm": "^4.0.1" diff --git a/src/App.css b/src/App.css index 6cca193..16827a9 100644 --- a/src/App.css +++ b/src/App.css @@ -235,30 +235,31 @@ } /* ── Forms ── */ -.auth-form { + +/* Unified form system: `.form` container + `.form-field` / `.form-label` / + `.form-hint` building blocks, shared by every form. */ +.form { display: flex; flex-direction: column; gap: 1rem; } -.dump-form .form-group, -.auth-form .form-group { +.form-field { display: flex; flex-direction: column; gap: 0.4rem; } -.dump-form label, -.auth-form label { +.form-label { font-size: 0.85rem; font-weight: 600; opacity: 0.6; } -.dump-form input, -.dump-form textarea, -.auth-form input, -.auth-form textarea { +.form input, +.form textarea { + width: 100%; + box-sizing: border-box; padding: 0.65rem 1rem; border-radius: 8px; border: 2px solid var(--color-border); @@ -274,15 +275,21 @@ outline: none; } -.dump-form textarea, -.auth-form textarea { +/* A submit/secondary button placed as a direct child of `.form` (the auth + forms, which have no action row) spans the full width like the inputs. + Buttons inside `.form-actions` keep their intrinsic width. */ +.form > .btn-primary, +.form > .btn-secondary, +.form > .btn-danger { + width: 100%; +} + +.form textarea { resize: vertical; } -.dump-form input:hover, -.dump-form textarea:hover, -.auth-form input:hover, -.auth-form textarea:hover { +.form input:hover, +.form textarea:hover { border-color: color-mix( in srgb, var(--color-accent) 45%, @@ -290,10 +297,8 @@ ); } -.dump-form input:focus, -.dump-form textarea:focus, -.auth-form input:focus, -.auth-form textarea:focus { +.form input:focus, +.form textarea:focus { border-color: var(--color-accent); background-color: color-mix( in srgb, @@ -304,13 +309,27 @@ color-mix(in srgb, var(--color-accent) 18%, transparent); } -.dump-form input:disabled, -.dump-form textarea:disabled, -.auth-form input:disabled, -.auth-form textarea:disabled { +.form input:disabled, +.form textarea:disabled { opacity: 0.6; } +.form-hint { + font-size: 0.8rem; + opacity: 0.7; + margin-top: 0.1rem; +} + +.form-hint--error { + color: var(--color-error, #c0392b); + opacity: 1; +} + +.form-success { + color: var(--color-success, green); + margin: 0; +} + .dump-create-title-row { display: flex; align-items: center; @@ -2308,16 +2327,6 @@ body.has-player .fab-new { opacity: 0.85; } -.auth-field-hint { - display: block; - font-size: 0.8rem; - margin-top: 0.25rem; -} - -.auth-field-hint--error { - color: var(--color-error, #e53e3e); -} - /* ── Form pages (DumpCreate / DumpEdit) ── */ @keyframes page-enter { from { @@ -2348,7 +2357,9 @@ body.has-player .fab-new { .form-page--two-col .dump-edit-preview { border-radius: 0 0 0 12px; } - .form-page--two-col .dump-form { + /* Higher specificity than the generic `.form-page .form` below so the + form's bottom-left stays square where it meets the preview. */ + .form-page.form-page--two-col .form { border-radius: 0 0 12px 0; } } @@ -2395,10 +2406,7 @@ body.has-player .fab-new { gap: 0.75rem; } -.dump-form { - display: flex; - flex-direction: column; - gap: 1rem; +.form-page .form { background: var(--color-surface); border-radius: 0 0 12px 12px; padding: 1.25rem; @@ -2421,7 +2429,13 @@ body.has-player .fab-new { margin-left: auto; } +/* Subtle text action. Resets the global - - + + + ) : ( -
- - - - {error && ( - - )} -
-
- - -
-
- + + 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`, + }} + /> + + + Saving…}> + Change password + + + + )} ); diff --git a/src/components/CommentThread.tsx b/src/components/CommentThread.tsx index 9d304c3..dcb2273 100644 --- a/src/components/CommentThread.tsx +++ b/src/components/CommentThread.tsx @@ -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 = {}; + 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; + 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; + /** `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 = ( + <> + ( + void submit()} + placeholder={placeholder} + autoResize + rows={1} + maxLength={VALIDATION.COMMENT_BODY_MAX} + /> + )} + /> + +
+ + {submitLabel} + + {showCancel && ( + + )} +
+ + ); + + return ( + + {layout === "top" + ? ( +
+
+ {leading} +
{inner}
+
+
+ ) + : ( +
+ {inner} +
+ )} +
+ ); +} 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(null); const [editOpen, setEditOpen] = useState(false); - const [editBody, setEditBody] = useState(""); const [confirmDelete, setConfirmDelete] = useState(false); - const [editSubmitting, setEditSubmitting] = useState(false); - const [editError, setEditError] = useState(null); const replyEditorRef = useRef(null); const editEditorRef = useRef(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(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(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(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(res))); + setEditOpen(false); } const canDelete = !comment.deleted && !!currentUser && @@ -245,47 +319,15 @@ function CommentNode({ {editOpen ? ( -
- - {editError && ( - - )} -
- - -
- + Save} + pendingLabel={Saving…} + errorTitle={t`Failed to save edit`} + onCancel={() => setEditOpen(false)} + /> ) : {comment.body}}
@@ -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({ )}
{replyOpen && ( -
- - {replyError && ( - - )} -
- - -
- + Post reply} + pendingLabel={Posting…} + errorTitle={t`Failed to post reply`} + placeholder={t`Write a reply…`} + onCancel={() => setReplyOpen(false)} + /> )} @@ -425,44 +433,16 @@ export function CommentThread({ onCommentDeleted, onCommentUpdated, }: CommentThreadProps) { - const [topLevelBody, setTopLevelBody] = useState(""); - const [submitting, setSubmitting] = useState(false); - const [topLevelError, setTopLevelError] = useState(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(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(res))); } const visibleCount = comments.filter((c) => !c.deleted).length; @@ -474,8 +454,9 @@ export function CommentThread({ {currentUser && ( -
-
+
-
- - {topLevelError && ( - - )} -
- - {topLevelBody.trim() && ( - - )} -
-
- -
+ } + onSubmit={handleTopLevelSubmit} + submitLabel={Post comment} + pendingLabel={Posting…} + errorTitle={t`Failed to post comment`} + placeholder={t`Add a comment…`} + onCancel={() => {}} + cancelWhenEmpty + /> )} {roots.length > 0 && ( diff --git a/src/components/ConfirmModal.tsx b/src/components/ConfirmModal.tsx index a3a39d8..cdac335 100644 --- a/src/components/ConfirmModal.tsx +++ b/src/components/ConfirmModal.tsx @@ -28,7 +28,7 @@ export function ConfirmModal(
e.stopPropagation()}>

{message}

-
-
- {submitState.status === "error" && ( - - )} + + + - {mode === "url" - ? ( - <> -
- - 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 - /> -
- {urlPreview.status === "loading" && ( -

- Fetching preview… -

- )} - {urlPreview.status === "done" && - urlPreview.richContent && ( - - )} - - ) - : ( - <> - - {file && } - {file && ( -
-
{confirmDelete && ( ); } + +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({ + 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(res))); + }, + ); + + return ( + +
+ + +
+ + Thumbnail + +
+ + {dump.thumbnailMime && ( + + )} +
+
+ + + name="title" + label={t`Title`} + maxLength={VALIDATION.DUMP_TITLE_MAX} + rules={{ required: true }} + /> + + {dump.kind === "url" + ? ( + + name="url" + type="url" + label={t`URL`} + placeholder="https://..." + rules={{ required: true }} + /> + ) + : ( +
+

+ {dump.fileName} + {dump.fileSize != null && ` — ${formatBytes(dump.fileSize)}`} +

+ ( + + )} + /> +
+ )} + + + 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 }} + /> + + name="isPublic" /> + +
+ +
+ + Cancel + + + Save + +
+
+ +
+ ); +} diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index 4a6ef41..ee736e4 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -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(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(null); const [confirmDelete, setConfirmDelete] = useState(false); - const [imageFile, setImageFile] = useState(null); - const [imagePreview, setImagePreview] = useState(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(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() {
{editOpen ? ( - { - setImageFile(file); - setImagePreview(URL.createObjectURL(file)); - }} + setEditOpen(false)} + onRequestDelete={() => setConfirmDelete(true)} + onSaved={handleEditSaved} /> ) - : playlist.imageMime && ( - - )} - -
- {editOpen - ? ( -
- setEditTitle(e.target.value)} - autoFocus - maxLength={VALIDATION.PLAYLIST_TITLE_MAX} + : ( + <> + {playlist.imageMime && ( + - - - -
- ) - : ( -
-

{playlist.title}

- {!isOwner && ( - - )} - {isOwner && ( - - )} -
- )} - - {editOpen - ? ( - - ) - : playlist.description && ( - - {playlist.description} - - )} - -
- {editOpen - ? ( -
- - + )} +
+
+

{playlist.title}

+ {!isOwner && ( + + )} + {isOwner && ( + + )}
- ) - : ( - <> + + {playlist.description && ( + + {playlist.description} + + )} + +
)} - - )} -
- {editError && ( - +
+
+ )} -
@@ -887,3 +751,173 @@ export function PlaylistDetail() { ); } + +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(null); + const form = useApiForm({ + 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( + 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 ( + + {/* `display: contents` keeps the image-beside-content flex layout while + still giving us a real
for submit + Enter handling. */} + + ( + { + field.onChange(file); + setImagePreview(URL.createObjectURL(file)); + }} + /> + )} + /> + +
+ ( + + )} + /> + + ( + + )} + /> + + ( +
+ + +
+ )} + /> + + + +
+ +
+ + Saving…} + disabled={description.length > + VALIDATION.PLAYLIST_DESCRIPTION_MAX} + > + Save + +
+
+
+ +
+ ); +} diff --git a/src/pages/ResetPassword.tsx b/src/pages/ResetPassword.tsx index 1e58514..4037191 100644 --- a/src/pages/ResetPassword.tsx +++ b/src/pages/ResetPassword.tsx @@ -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({ status: "idle" }); + const form = useApiForm({ + 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 (
@@ -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 (
@@ -102,57 +92,40 @@ export function ResetPassword() { Set new password - {state.status === "error" && ( - - )} - -
-
- + + + + 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 && ( - - At least {VALIDATION.PASSWORD_MIN} characters - - )} -
-
- + 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 && ( - - Passwords do not match - - )} -
- -
+ Saving…}> + Set new password + + +

diff --git a/src/pages/UserLogin.tsx b/src/pages/UserLogin.tsx index e90096c..cc9cf58 100644 --- a/src/pages/UserLogin.tsx +++ b/src/pages/UserLogin.tsx @@ -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({ status: "idle" }); const [showReset, setShowReset] = useState(false); - const [resetEmail, setResetEmail] = useState(""); - const [resetState, setResetState] = useState({ status: "idle" }); - const handleSubmit = async (e: SubmitEvent) => { - e.preventDefault(); - setLoginState({ status: "submitting" }); + const form = useApiForm({ + 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( + await fetch(`${API_URL}/api/users/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password } satisfies LoginRequest), - }); - - const apiResponse = parseAPIResponse(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 ( @@ -87,98 +57,38 @@ export function UserLogin() { Log in - {loginState.status === "error" && ( - - )} - -

- - - -
+ +
+ + + name="username" + placeholder={t`Username`} + autoFocus + rules={{ required: true }} + /> + + name="password" + type="password" + placeholder={t`Password`} + rules={{ required: true }} + /> + Logging in…}> + Log in + + +

- {showReset && ( -
- {resetState.status === "sent" - ? ( -

- - If that address is registered you'll receive a reset link - shortly. - -

- ) - : ( - <> - {resetState.status === "error" && ( - - )} -
- setResetEmail(e.target.value)} - required - autoFocus - disabled={resetState.status === "submitting"} - /> - -
- - )} -
- )} + {showReset && }

This is a mirage. @@ -187,3 +97,49 @@ export function UserLogin() { ); } + +function ResetRequestPanel() { + const [sent, setSent] = useState(false); + const form = useApiForm({ 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 ( +

+

+ + If that address is registered you'll receive a reset link shortly. + +

+
+ ); + } + + return ( +
+ +
+ + + name="email" + type="email" + placeholder={t`Your email address`} + autoFocus + rules={{ required: true }} + /> + Sending…}> + Send reset link + + +
+
+ ); +} diff --git a/src/pages/UserPublicProfile.tsx b/src/pages/UserPublicProfile.tsx index 35bbdaa..f458ac1 100644 --- a/src/pages/UserPublicProfile.tsx +++ b/src/pages/UserPublicProfile.tsx @@ -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(null); const [descEditing, setDescEditing] = useState(false); - const [descDraft, setDescDraft] = useState(""); - const [descSaving, setDescSaving] = useState(false); - const [descError, setDescError] = useState(null); - const [emailEditing, setEmailEditing] = useState(false); - const [emailDraft, setEmailDraft] = useState(""); - const [emailSaving, setEmailSaving] = useState(false); - const [emailError, setEmailError] = useState(null); const prevMyVotesRef = useRef | 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(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(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 ? ( -
{ - e.preventDefault(); - handleEmailSave(); - }} - > - setEmailDraft(e.currentTarget.value)} - onKeyDown={(e) => { - if (e.key === "Escape") setEmailEditing(false); - }} - disabled={emailSaving} - autoFocus - /> -
- - -
- {emailError && ( - - )} - + setEmailEditing(false)} + onSaved={handleEmailSaved} + /> ) : (

{ - 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() {

{descEditing ? ( -
- -
- - - {descError && ( - - )} -
-
+ setDescEditing(false)} + onSaved={handleDescSaved} + /> ) : (
{ - setDescDraft(profileUser.description ?? ""); - setDescError(null); - setDescEditing(true); - } + ? () => setDescEditing(true) : undefined} > {profileUser.description @@ -1548,3 +1424,136 @@ function FollowedUserCard({ user }: { user: PublicUser }) { ); } + +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( + 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 ( + +
+ { + if (e.key === "Escape") onCancel(); + }} + disabled={submitting} + autoFocus + /> +
+ Saving…} + disabled={!email.trim()} + > + Save + + +
+ + +
+ ); +} + +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( + await authFetch(`${API_URL}/api/users/me`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify( + { description: trimmed } satisfies UpdateUserRequest, + ), + }), + ); + onSaved(trimmed); + }); + + return ( + +
+ ( + void onSubmit()} + placeholder={t`Who am I?`} + autoResize + maxLength={VALIDATION.USER_DESCRIPTION_MAX} + /> + )} + /> +
+ Saving…} + disabled={description.length > VALIDATION.USER_DESCRIPTION_MAX} + > + Save + + + +
+ +
+ ); +} diff --git a/src/pages/UserRegister.tsx b/src/pages/UserRegister.tsx index ff096d4..a5ed614 100644 --- a/src/pages/UserRegister.tsx +++ b/src/pages/UserRegister.tsx @@ -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(() => token ? { status: "checking" } : { status: "invalid" } ); - const [formState, setFormState] = useState({ status: "idle" }); const [prevToken, setPrevToken] = useState(token); + const form = useApiForm({ + 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) => { - 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( + 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(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() { Register - {formState.status === "error" && ( - - )} - -
- - - - -
+ +
+ + + 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`, + }, + }} + /> + + name="email" + type="email" + placeholder={t`Email address`} + rules={{ required: true }} + /> + + 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`, + }, + }} + /> + Registering…}> + Register + + +

diff --git a/src/themes/brutalist.css b/src/themes/brutalist.css index 9a96403..603576f 100644 --- a/src/themes/brutalist.css +++ b/src/themes/brutalist.css @@ -176,7 +176,8 @@ [data-style="brutalist"] .rich-content-thumbnail-btn, [data-style="brutalist"] .user-menu-trigger, [data-style="brutalist"] .nav-search-btn, -[data-style="brutalist"] .auth-link-btn { +[data-style="brutalist"] .auth-link-btn, +[data-style="brutalist"] .form-cancel { border: none; box-shadow: none; } @@ -186,7 +187,9 @@ [data-style="brutalist"] .nav-search-btn:hover:not(:disabled), [data-style="brutalist"] .nav-search-btn:active, [data-style="brutalist"] .auth-link-btn:hover:not(:disabled), -[data-style="brutalist"] .auth-link-btn:active { +[data-style="brutalist"] .auth-link-btn:active, +[data-style="brutalist"] .form-cancel:hover:not(:disabled), +[data-style="brutalist"] .form-cancel:active { transform: none; box-shadow: none; } @@ -287,10 +290,8 @@ border-color: var(--color-border) !important; } -[data-style="brutalist"] .dump-form input:focus, -[data-style="brutalist"] .dump-form textarea:focus, -[data-style="brutalist"] .auth-form input:focus, -[data-style="brutalist"] .auth-form textarea:focus, +[data-style="brutalist"] .form input:focus, +[data-style="brutalist"] .form textarea:focus, [data-style="brutalist"] .mention-textarea-wrap textarea:focus { box-shadow: 2px 2px 0 var(--color-accent); }