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

@@ -111,21 +111,29 @@ api/
middleware/ # errorMiddleware, authMiddleware middleware/ # errorMiddleware, authMiddleware
routes/ # HTTP routes + WebSocket routes/ # HTTP routes + WebSocket
services/ # Business logic services/ # Business logic
model/ # Database schema, row types, type guards providers/ # Link metadata providers (YouTube, SoundCloud, Bandcamp, …)
lib/ # JWT, pagination, upload helpers, … model/ # Row types, type guards
sql/ lib/ # JWT, pagination, slugify, upload, static helpers, …
db/
schema.sql # Database schema schema.sql # Database schema
init.ts # First-run database initialisation init.ts # First-run database initialisation
migrate.ts # Migration runner
migrations/ # Versioned schema migrations
sql/
gerbeur.db # SQLite database (not committed) 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) 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 pages/ # Route-level components
index/ # Feed views (hot, new, followed, journal)
components/ # Shared UI components components/ # Shared UI components
form/ # Reusable form controls
contexts/ # Auth, WebSocket, player, follows, theme contexts/ # Auth, WebSocket, player, follows, theme
hooks/ # Data fetching and UI hooks hooks/ # Data fetching and UI hooks
utils/ # Formatting, URL, hot-score, waveform, … helpers
locales/ # Lingui message catalogues (en, fr) locales/ # Lingui message catalogues (en, fr)
themes/ # Per-theme CSS files themes/ # Per-theme CSS files
model.ts # Shared frontend types
i18n.ts # Lingui runtime setup i18n.ts # Lingui runtime setup
public/ # Static assets (favicon, icon sprite) public/ # Static assets (favicon, manifest, service worker)
``` ```

View File

@@ -5,6 +5,23 @@ import { DUMPS_DIR } from "../lib/upload.ts";
const router = new Router({ prefix: "/api/files" }); 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) => { router.get("/:dumpId", async (ctx) => {
const { dumpId } = ctx.params; 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-Type", dump.fileMime);
ctx.response.headers.set( ctx.response.headers.set(
"Content-Disposition", "Content-Disposition",
`inline; filename="${dump.fileName}"`, contentDisposition(dump.fileName),
); );
ctx.response.headers.set("Accept-Ranges", "bytes"); ctx.response.headers.set("Accept-Ranges", "bytes");

8
deno.lock generated
View File

@@ -43,6 +43,7 @@
"npm:nodemailer@^9.0.1": "9.0.1", "npm:nodemailer@^9.0.1": "9.0.1",
"npm:path-to-regexp@^6.3.0": "6.3.0", "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-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-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-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", "npm:react@^19.2.7": "19.2.7",
@@ -2259,6 +2260,12 @@
"scheduler" "scheduler"
] ]
}, },
"react-hook-form@7.80.0_react@19.2.7": {
"integrity": "sha512-4P+fk6oXsxY+6xSj7Euhc2sumQD8zQqCuVHoJwoyp9EchP+IUW9OESB7uHFJOKsIBQ4MQqYE84INJFqUCYNoOg==",
"dependencies": [
"react"
]
},
"react-is@18.3.1": { "react-is@18.3.1": {
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="
}, },
@@ -2665,6 +2672,7 @@
"npm:globals@^17.6.0", "npm:globals@^17.6.0",
"npm:jiti@^2.7.0", "npm:jiti@^2.7.0",
"npm:react-dom@^19.2.7", "npm:react-dom@^19.2.7",
"npm:react-hook-form@^7.80.0",
"npm:react-markdown@^10.1.0", "npm:react-markdown@^10.1.0",
"npm:react-router@^8.0.1", "npm:react-router@^8.0.1",
"npm:react@^19.2.7", "npm:react@^19.2.7",

View File

@@ -22,6 +22,7 @@
"jiti": "^2.7.0", "jiti": "^2.7.0",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-hook-form": "^7.80.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router": "^8.0.1", "react-router": "^8.0.1",
"remark-gfm": "^4.0.1" "remark-gfm": "^4.0.1"

View File

@@ -235,30 +235,31 @@
} }
/* ── Forms ── */ /* ── Forms ── */
.auth-form {
/* Unified form system: `.form` container + `.form-field` / `.form-label` /
`.form-hint` building blocks, shared by every form. */
.form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
} }
.dump-form .form-group, .form-field {
.auth-form .form-group {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.4rem; gap: 0.4rem;
} }
.dump-form label, .form-label {
.auth-form label {
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 600; font-weight: 600;
opacity: 0.6; opacity: 0.6;
} }
.dump-form input, .form input,
.dump-form textarea, .form textarea {
.auth-form input, width: 100%;
.auth-form textarea { box-sizing: border-box;
padding: 0.65rem 1rem; padding: 0.65rem 1rem;
border-radius: 8px; border-radius: 8px;
border: 2px solid var(--color-border); border: 2px solid var(--color-border);
@@ -274,15 +275,21 @@
outline: none; outline: none;
} }
.dump-form textarea, /* A submit/secondary button placed as a direct child of `.form` (the auth
.auth-form textarea { 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; resize: vertical;
} }
.dump-form input:hover, .form input:hover,
.dump-form textarea:hover, .form textarea:hover {
.auth-form input:hover,
.auth-form textarea:hover {
border-color: color-mix( border-color: color-mix(
in srgb, in srgb,
var(--color-accent) 45%, var(--color-accent) 45%,
@@ -290,10 +297,8 @@
); );
} }
.dump-form input:focus, .form input:focus,
.dump-form textarea:focus, .form textarea:focus {
.auth-form input:focus,
.auth-form textarea:focus {
border-color: var(--color-accent); border-color: var(--color-accent);
background-color: color-mix( background-color: color-mix(
in srgb, in srgb,
@@ -304,13 +309,27 @@
color-mix(in srgb, var(--color-accent) 18%, transparent); color-mix(in srgb, var(--color-accent) 18%, transparent);
} }
.dump-form input:disabled, .form input:disabled,
.dump-form textarea:disabled, .form textarea:disabled {
.auth-form input:disabled,
.auth-form textarea:disabled {
opacity: 0.6; 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 { .dump-create-title-row {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -2308,16 +2327,6 @@ body.has-player .fab-new {
opacity: 0.85; 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) ── */ /* ── Form pages (DumpCreate / DumpEdit) ── */
@keyframes page-enter { @keyframes page-enter {
from { from {
@@ -2348,7 +2357,9 @@ body.has-player .fab-new {
.form-page--two-col .dump-edit-preview { .form-page--two-col .dump-edit-preview {
border-radius: 0 0 0 12px; 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; border-radius: 0 0 12px 0;
} }
} }
@@ -2395,10 +2406,7 @@ body.has-player .fab-new {
gap: 0.75rem; gap: 0.75rem;
} }
.dump-form { .form-page .form {
display: flex;
flex-direction: column;
gap: 1rem;
background: var(--color-surface); background: var(--color-surface);
border-radius: 0 0 12px 12px; border-radius: 0 0 12px 12px;
padding: 1.25rem; padding: 1.25rem;
@@ -2421,7 +2429,13 @@ body.has-player .fab-new {
margin-left: auto; margin-left: auto;
} }
/* Subtle text action. Resets the global <button> chrome so it looks the same
whether rendered as a <button> (modal/page forms) or a <Link> (DumpEdit). */
.form-cancel { .form-cancel {
background: none;
border: none;
padding: 0;
cursor: pointer;
font-size: 0.9rem; font-size: 0.9rem;
color: var(--color-text); color: var(--color-text);
opacity: 0.6; opacity: 0.6;
@@ -3189,30 +3203,6 @@ body.has-player .fab-new {
background: color-mix(in srgb, var(--color-accent) 8%, transparent); background: color-mix(in srgb, var(--color-accent) 8%, transparent);
} }
.modal-new-playlist-form {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.modal-new-playlist-form input,
.modal-new-playlist-form textarea {
padding: 0.6rem 0.9rem;
border-radius: 8px;
border: 2px solid var(--color-border-subtle);
background: var(--color-bg);
color: var(--color-text);
font-size: 0.95rem;
font-family: inherit;
outline: none;
transition: border-color 0.2s;
resize: vertical;
}
.modal-new-playlist-form input:focus,
.modal-new-playlist-form textarea:focus {
border-color: var(--color-accent);
}
/* ── Playlist detail page ── */ /* ── Playlist detail page ── */
.playlist-detail-header { .playlist-detail-header {
@@ -3341,6 +3331,12 @@ body.has-player .fab-new {
font-weight: 700; font-weight: 700;
} }
/* Inline edit form: stack the fields with a little more breathing room than
the tight display layout, and let the action row sit at the bottom. */
.playlist-edit-content {
gap: 0.7rem;
}
.playlist-edit-input:focus, .playlist-edit-input:focus,
.playlist-edit-textarea:focus { .playlist-edit-textarea:focus {
border-color: var(--color-accent); border-color: var(--color-accent);

View File

@@ -3,183 +3,106 @@ import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { API_URL, VALIDATION } from "../config/api.ts"; import { API_URL, VALIDATION } from "../config/api.ts";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
import { ErrorCard } from "./ErrorCard.tsx";
import { Modal } from "./Modal.tsx"; import { Modal } from "./Modal.tsx";
import {
expectOk,
FormActions,
FormError,
FormProvider,
SubmitButton,
TextField,
useApiForm,
} from "./form/index.ts";
interface ChangePasswordModalProps { interface ChangePasswordModalProps {
onClose: () => void; onClose: () => void;
} }
interface Values {
currentPassword: string;
newPassword: string;
confirmPassword: string;
}
export function ChangePasswordModal({ onClose }: ChangePasswordModalProps) { export function ChangePasswordModal({ onClose }: ChangePasswordModalProps) {
const { authFetch } = useAuth(); const { authFetch } = useAuth();
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false); const [done, setDone] = useState(false);
const form = useApiForm<Values>({
mode: "onChange",
defaultValues: { currentPassword: "", newPassword: "", confirmPassword: "" },
});
const mismatch = confirmPassword.length > 0 && const onSubmit = form.submit(async ({ currentPassword, newPassword }) => {
newPassword !== confirmPassword; await expectOk(
const tooShort = newPassword.length > 0 && await authFetch(`${API_URL}/api/users/me/change-password`, {
newPassword.length < VALIDATION.PASSWORD_MIN; method: "POST",
headers: { "Content-Type": "application/json" },
const handleSubmit = async (e: React.SubmitEvent) => { body: JSON.stringify({ currentPassword, newPassword }),
e.preventDefault(); }),
if (mismatch || tooShort || !currentPassword || !newPassword) return; );
setDone(true);
setSubmitting(true); });
setError(null);
try {
const res = await authFetch(
`${API_URL}/api/users/me/change-password`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ currentPassword, newPassword }),
},
);
const body = await res.json();
if (!body.success) {
setError(body.error?.message ?? t`Unknown error`);
return;
}
setDone(true);
} catch {
setError(t`Failed to change password`);
} finally {
setSubmitting(false);
}
};
return ( return (
<Modal title={t`Change password`} onClose={onClose}> <Modal title={t`Change password`} onClose={onClose}>
{done {done
? ( ? (
<div className="modal-new-playlist-form"> <div className="form">
<p style={{ color: "var(--color-success, green)" }}> <p className="form-success">
<Trans>Password changed successfully.</Trans> <Trans>Password changed successfully.</Trans>
</p> </p>
<div className="form-actions"> <FormActions>
<div className="form-actions-right"> <button type="button" className="btn-primary" onClick={onClose}>
<button type="button" className="btn-primary" onClick={onClose}> <Trans>Close</Trans>
<Trans>Close</Trans> </button>
</button> </FormActions>
</div>
</div>
</div> </div>
) )
: ( : (
<form className="modal-new-playlist-form" onSubmit={handleSubmit}> <FormProvider {...form}>
<label> <form className="form" onSubmit={onSubmit}>
<span <TextField<Values>
style={{ name="currentPassword"
display: "block",
marginBottom: "0.25rem",
fontSize: "0.85rem",
opacity: 0.7,
}}
>
<Trans>Current password</Trans>
</span>
<input
type="password" type="password"
value={currentPassword} label={t`Current password`}
onChange={(e) => setCurrentPassword(e.target.value)}
autoComplete="current-password" autoComplete="current-password"
required
autoFocus autoFocus
rules={{ required: true }}
/> />
</label> <TextField<Values>
<label> name="newPassword"
<span
style={{
display: "block",
marginBottom: "0.25rem",
fontSize: "0.85rem",
opacity: 0.7,
}}
>
<Trans>New password</Trans>
</span>
<input
type="password" type="password"
value={newPassword} label={t`New password`}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password" autoComplete="new-password"
minLength={VALIDATION.PASSWORD_MIN}
maxLength={VALIDATION.PASSWORD_MAX} maxLength={VALIDATION.PASSWORD_MAX}
required rules={{
/> required: true,
{tooShort && ( minLength: {
<span value: VALIDATION.PASSWORD_MIN,
style={{ message: t`At least ${VALIDATION.PASSWORD_MIN} characters`,
fontSize: "0.8rem", },
color: "var(--color-error, red)",
marginTop: "0.2rem",
display: "block",
}}
>
<Trans>At least {VALIDATION.PASSWORD_MIN} characters</Trans>
</span>
)}
</label>
<label>
<span
style={{
display: "block",
marginBottom: "0.25rem",
fontSize: "0.85rem",
opacity: 0.7,
}} }}
>
<Trans>Confirm new password</Trans>
</span>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
required
/> />
{mismatch && ( <TextField<Values>
<span name="confirmPassword"
style={{ type="password"
fontSize: "0.8rem", label={t`Confirm new password`}
color: "var(--color-error, red)", autoComplete="new-password"
marginTop: "0.2rem", rules={{
display: "block", required: true,
}} validate: (value, values) =>
> value === values.newPassword ||
<Trans>Passwords do not match</Trans> t`Passwords do not match`,
</span> }}
)} />
</label> <FormError title={t`Could not change password`} />
{error && ( <FormActions onCancel={onClose}>
<ErrorCard title={t`Could not change password`} message={error} /> <SubmitButton pendingLabel={<Trans>Saving</Trans>}>
)} <Trans>Change password</Trans>
<div className="form-actions"> </SubmitButton>
<div className="form-actions-right"> </FormActions>
<button </form>
type="button" </FormProvider>
className="form-cancel"
onClick={onClose}
>
<Trans>Cancel</Trans>
</button>
<button
type="submit"
className="btn-primary"
disabled={submitting || mismatch || tooShort ||
!currentPassword || !newPassword || !confirmPassword}
>
{submitting
? <Trans>Saving</Trans>
: <Trans>Change password</Trans>}
</button>
</div>
</div>
</form>
)} )}
</Modal> </Modal>
); );

View File

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

View File

@@ -28,7 +28,7 @@ export function ConfirmModal(
<div className="confirm-modal" onClick={(e) => e.stopPropagation()}> <div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
<p className="confirm-modal-message">{message}</p> <p className="confirm-modal-message">{message}</p>
<div className="confirm-modal-actions"> <div className="confirm-modal-actions">
<button type="button" onClick={onCancel}> <button type="button" className="btn-secondary" onClick={onCancel}>
<Trans>Cancel</Trans> <Trans>Cancel</Trans>
</button> </button>
<button type="button" className="btn-danger" onClick={onConfirm}> <button type="button" className="btn-danger" onClick={onConfirm}>

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { Controller } from "react-hook-form";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
@@ -14,7 +15,6 @@ import type {
import { import {
deserializeDump, deserializeDump,
deserializePlaylistMembership, deserializePlaylistMembership,
parseAPIResponse,
} from "../model.ts"; } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts"; import { useWS } from "../hooks/useWS.ts";
@@ -23,26 +23,36 @@ import { MAX_FILE_SIZE } from "../config/upload.ts";
import RichContentCard from "./RichContentCard.tsx"; import RichContentCard from "./RichContentCard.tsx";
import { MediaPlayer } from "./MediaPlayer.tsx"; import { MediaPlayer } from "./MediaPlayer.tsx";
import type { RichContent } from "../model.ts"; import type { RichContent } from "../model.ts";
import { ErrorCard } from "./ErrorCard.tsx";
import { FileDropZone } from "./FileDropZone.tsx"; import { FileDropZone } from "./FileDropZone.tsx";
import { TextEditor } from "./TextEditor.tsx";
import { Modal } from "./Modal.tsx"; import { Modal } from "./Modal.tsx";
import { PlaylistMembershipPanel } from "./PlaylistMembershipPanel.tsx"; import { PlaylistMembershipPanel } from "./PlaylistMembershipPanel.tsx";
import { friendlyFetchError } from "../utils/apiError.ts"; import {
expectOk,
FormActions,
FormError,
FormProvider,
RichTextField,
SubmitButton,
useApiForm,
VisibilityToggle,
} from "./form/index.ts";
type Mode = "url" | "file"; type Mode = "url" | "file";
type Phase = "create" | "playlist"; type Phase = "create" | "playlist";
type SubmitState =
| { status: "idle" }
| { status: "submitting" }
| { status: "error"; error: string };
type UrlPreview = type UrlPreview =
| { status: "idle" } | { status: "idle" }
| { status: "loading" } | { status: "loading" }
| { status: "done"; richContent: RichContent | null }; | { status: "done"; richContent: RichContent | null };
interface CreateValues {
url: string;
file: File | null;
title: string;
comment: string;
isPublic: boolean;
}
function LocalFilePreview({ file }: { file: File }) { function LocalFilePreview({ file }: { file: File }) {
const [src, setSrc] = useState<string | null>(null); const [src, setSrc] = useState<string | null>(null);
@@ -86,28 +96,34 @@ export function DumpCreateModal(
// Create phase state // Create phase state
const [mode, setMode] = useState<Mode>("url"); const [mode, setMode] = useState<Mode>("url");
const [url, setUrl] = useState(initialUrl);
const [file, setFile] = useState<File | null>(null);
const [title, setTitle] = useState("");
const [titleEditing, setTitleEditing] = useState(false); const [titleEditing, setTitleEditing] = useState(false);
const titleInputRef = useRef<HTMLInputElement | null>(null); const titleInputRef = useRef<HTMLInputElement | null>(null);
const [comment, setComment] = useState("");
const [isPrivate, setIsPrivate] = useState(false);
const [submitState, setSubmitState] = useState<SubmitState>({
status: "idle",
});
const [urlFetchState, setUrlFetchState] = useState<UrlPreview>({ const [urlFetchState, setUrlFetchState] = useState<UrlPreview>({
status: "idle", status: "idle",
}); });
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const form = useApiForm<CreateValues>({
defaultValues: {
url: initialUrl,
file: null,
title: "",
comment: "",
isPublic: true,
},
});
const { register, setValue, watch, clearErrors, control } = form;
const submitting = form.formState.isSubmitting;
const url = watch("url");
const file = watch("file");
// Playlist phase state // Playlist phase state
const [memberships, setMemberships] = useState<PlaylistMembership[]>([]); const [memberships, setMemberships] = useState<PlaylistMembership[]>([]);
const [playlistsLoading, setPlaylistsLoading] = useState(false); const [playlistsLoading, setPlaylistsLoading] = useState(false);
const selectFile = (f: File | null) => { const selectFile = (f: File | null) => {
setFile(f); setValue("file", f);
setTitle(f?.name ?? ""); setValue("title", f?.name ?? "");
setTitleEditing(false); setTitleEditing(false);
}; };
@@ -166,9 +182,9 @@ export function DumpCreateModal(
const pastedFile = e.clipboardData?.files[0]; const pastedFile = e.clipboardData?.files[0];
if (pastedFile) { if (pastedFile) {
setMode("file"); setMode("file");
setUrl(""); setValue("url", "");
selectFile(pastedFile); selectFile(pastedFile);
setSubmitState({ status: "idle" }); clearErrors("root");
return; return;
} }
const tag = (e.target as HTMLElement).tagName; const tag = (e.target as HTMLElement).tagName;
@@ -179,95 +195,71 @@ export function DumpCreateModal(
if (u.protocol === "http:" || u.protocol === "https:") { if (u.protocol === "http:" || u.protocol === "https:") {
setMode("url"); setMode("url");
selectFile(null); selectFile(null);
setUrl(u.toString()); setValue("url", u.toString());
setSubmitState({ status: "idle" }); clearErrors("root");
} }
} catch { /* not a URL */ } } catch { /* not a URL */ }
}; };
globalThis.addEventListener("paste", handler); globalThis.addEventListener("paste", handler);
return () => globalThis.removeEventListener("paste", handler); return () => globalThis.removeEventListener("paste", handler);
// setValue/clearErrors are stable RHF refs; selectFile closes over them.
}, []); }, []);
const handleSubmit = async (e: React.SubmitEvent) => { const onSubmit = form.submit(async (
e.preventDefault(); { url, file, title, comment, isPublic },
if (comment.length > VALIDATION.DUMP_COMMENT_MAX) return; ) => {
setSubmitState({ status: "submitting" }); const isPrivate = !isPublic;
let res: Response;
try { if (mode === "url") {
let res: Response; const normalizedUrl = normalizeUrl(url);
if (!normalizedUrl) throw new Error(t`URL is required.`);
if (mode === "url") { setValue("url", normalizedUrl);
const normalizedUrl = normalizeUrl(url); const body: CreateUrlDumpRequest = {
if (!normalizedUrl) { url: normalizedUrl,
setSubmitState({ status: "error", error: t`URL is required.` }); comment: comment.trim() || undefined,
return; isPrivate,
} };
setUrl(normalizedUrl); res = await authFetch(`${API_URL}/api/dumps`, {
const body: CreateUrlDumpRequest = { method: "POST",
url: normalizedUrl, body: JSON.stringify(body),
comment: comment.trim() || undefined, });
isPrivate, } else {
}; if (!file) throw new Error(t`Please select a file.`);
res = await authFetch(`${API_URL}/api/dumps`, { if (!title.trim()) throw new Error(t`Title is required.`);
method: "POST", if (file.size > MAX_FILE_SIZE) {
body: JSON.stringify(body), throw new Error(t`File too large (max 50 MB).`);
});
} else {
if (!file) {
setSubmitState({
status: "error",
error: t`Please select a file.`,
});
return;
}
if (file.size > MAX_FILE_SIZE) {
setSubmitState({
status: "error",
error: t`File too large (max 50 MB).`,
});
return;
}
const formData = new FormData();
formData.append("file", file);
if (comment.trim()) formData.append("comment", comment.trim());
if (title.trim()) formData.append("title", title.trim());
formData.append("isPrivate", String(isPrivate));
res = await authFetch(`${API_URL}/api/dumps`, {
method: "POST",
body: formData,
});
} }
const formData = new FormData();
const apiResponse = parseAPIResponse<RawDump>(await res.json()); formData.append("file", file);
if (apiResponse.success) { if (comment.trim()) formData.append("comment", comment.trim());
const dump = deserializeDump(apiResponse.data); if (title.trim()) formData.append("title", title.trim());
injectDump(dump); formData.append("isPrivate", String(isPrivate));
setCreatedDump(dump); res = await authFetch(`${API_URL}/api/dumps`, {
setPhase("playlist"); method: "POST",
setPlaylistsLoading(true); body: formData,
authFetch(`${API_URL}/api/playlists/by-dump/${dump.id}/memberships`) });
.then((r) => r.json())
.then((body) => {
if (body.success) {
setMemberships(
(body.data as RawPlaylistMembership[]).map(
deserializePlaylistMembership,
),
);
}
})
.catch(() => {})
.finally(() => setPlaylistsLoading(false));
} else {
setSubmitState({
status: "error",
error: apiResponse.error.message,
});
}
} catch (err) {
setSubmitState({ status: "error", error: friendlyFetchError(err) });
} }
};
const dump = deserializeDump(await expectOk<RawDump>(res));
injectDump(dump);
setCreatedDump(dump);
setPhase("playlist");
setPlaylistsLoading(true);
authFetch(`${API_URL}/api/playlists/by-dump/${dump.id}/memberships`)
.then((r) => r.json())
.then((body) => {
if (body.success) {
setMemberships(
(body.data as RawPlaylistMembership[]).map(
deserializePlaylistMembership,
),
);
}
})
.catch(() => {})
.finally(() => setPlaylistsLoading(false));
});
const toggleMembership = async (membership: PlaylistMembership) => { const toggleMembership = async (membership: PlaylistMembership) => {
if (!createdDump) return; if (!createdDump) return;
@@ -295,8 +287,6 @@ export function DumpCreateModal(
} }
}; };
const submitting = submitState.status === "submitting";
return ( return (
<Modal <Modal
title={phase === "create" ? t`New dump` : t`Add to playlist`} title={phase === "create" ? t`New dump` : t`Add to playlist`}
@@ -313,7 +303,7 @@ export function DumpCreateModal(
onClick={() => { onClick={() => {
setMode("url"); setMode("url");
selectFile(null); selectFile(null);
setSubmitState({ status: "idle" }); clearErrors("root");
}} }}
disabled={submitting} disabled={submitting}
> >
@@ -324,8 +314,8 @@ export function DumpCreateModal(
className={mode === "file" ? "active" : ""} className={mode === "file" ? "active" : ""}
onClick={() => { onClick={() => {
setMode("file"); setMode("file");
setUrl(""); setValue("url", "");
setSubmitState({ status: "idle" }); clearErrors("root");
}} }}
disabled={submitting} disabled={submitting}
> >
@@ -333,161 +323,134 @@ export function DumpCreateModal(
</button> </button>
</div> </div>
<form onSubmit={handleSubmit} className="dump-form"> <FormProvider {...form}>
{submitState.status === "error" && ( <form onSubmit={onSubmit} className="form">
<ErrorCard <FormError title={t`Failed to post`} />
title={t`Failed to post`}
message={submitState.error}
/>
)}
{mode === "url" {mode === "url"
? ( ? (
<> <>
<div className="form-group"> <div className="form-field">
<label htmlFor="dc-url"> <label className="form-label" htmlFor="dc-url">
<Trans>URL</Trans> <Trans>URL</Trans>
</label>
<input
id="dc-url"
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
onBlur={(e) => setUrl(normalizeUrl(e.target.value))}
onPaste={(e) => {
const pastedFile = e.clipboardData.files[0];
if (pastedFile) {
e.preventDefault();
setMode("file");
setUrl("");
selectFile(pastedFile);
setSubmitState({ status: "idle" });
}
}}
disabled={submitting}
placeholder="https://..."
required
autoFocus
/>
</div>
{urlPreview.status === "loading" && (
<p className="preview-loading">
<Trans>Fetching preview</Trans>
</p>
)}
{urlPreview.status === "done" &&
urlPreview.richContent && (
<RichContentCard
richContent={urlPreview.richContent}
/>
)}
</>
)
: (
<>
<FileDropZone
file={file}
onChange={selectFile}
disabled={submitting}
/>
{file && <LocalFilePreview file={file} />}
{file && (
<div className="form-group">
<label htmlFor="dc-title">
<Trans>Title</Trans>
</label> </label>
<div className="dump-create-title-row"> <input
<input id="dc-url"
id="dc-title" type="url"
ref={titleInputRef} {...register("url")}
type="text" onBlur={(e) =>
value={title} setValue("url", normalizeUrl(e.target.value))}
onChange={(e) => setTitle(e.target.value)} onPaste={(e) => {
disabled={submitting || !titleEditing} const pastedFile = e.clipboardData.files[0];
maxLength={VALIDATION.DUMP_TITLE_MAX} if (pastedFile) {
required e.preventDefault();
/> setMode("file");
{!titleEditing && ( setValue("url", "");
<button selectFile(pastedFile);
type="button" clearErrors("root");
className="dump-create-title-edit-btn" }
onClick={() => { }}
setTitleEditing(true); disabled={submitting}
setTimeout( placeholder="https://..."
() => titleInputRef.current?.focus(), autoFocus
0, />
);
}}
disabled={submitting}
aria-label={t`Edit title`}
>
</button>
)}
</div>
</div> </div>
)} {urlPreview.status === "loading" && (
</> <p className="preview-loading">
)} <Trans>Fetching preview</Trans>
</p>
)}
{urlPreview.status === "done" &&
urlPreview.richContent && (
<RichContentCard richContent={urlPreview.richContent} />
)}
</>
)
: (
<>
<Controller
name="file"
control={control}
render={({ field }) => (
<FileDropZone
file={field.value}
onChange={selectFile}
disabled={submitting}
/>
)}
/>
{file && <LocalFilePreview file={file} />}
{file && (
<div className="form-field">
<label className="form-label" htmlFor="dc-title">
<Trans>Title</Trans>
</label>
<div className="dump-create-title-row">
{(() => {
const { ref, ...titleReg } = register("title");
return (
<input
id="dc-title"
type="text"
{...titleReg}
ref={(el) => {
ref(el);
titleInputRef.current = el;
}}
disabled={submitting || !titleEditing}
maxLength={VALIDATION.DUMP_TITLE_MAX}
/>
);
})()}
{!titleEditing && (
<button
type="button"
className="dump-create-title-edit-btn"
onClick={() => {
setTitleEditing(true);
setTimeout(
() => titleInputRef.current?.focus(),
0,
);
}}
disabled={submitting}
aria-label={t`Edit title`}
>
</button>
)}
</div>
</div>
)}
</>
)}
<div className="form-group"> <RichTextField<CreateValues>
<label htmlFor="dc-comment"> name="comment"
<Trans>Why?</Trans> label={t`Why?`}
</label>
<TextEditor
id="dc-comment"
value={comment}
onChange={setComment}
disabled={submitting}
placeholder={t`What makes it worth it?`} placeholder={t`What makes it worth it?`}
rows={3} rows={3}
maxLength={VALIDATION.DUMP_COMMENT_MAX} maxLength={VALIDATION.DUMP_COMMENT_MAX}
rules={{ maxLength: VALIDATION.DUMP_COMMENT_MAX }}
disabled={submitting}
/> />
</div>
<div className="visibility-toggle"> <VisibilityToggle<CreateValues>
<button name="isPublic"
type="button"
className={!isPrivate ? "active" : ""}
disabled={submitting} disabled={submitting}
onClick={() => setIsPrivate(false)} />
>
<Trans>Public</Trans>
</button>
<button
type="button"
className={isPrivate ? "active" : ""}
disabled={submitting}
onClick={() => setIsPrivate(true)}
>
<Trans>Private</Trans>
</button>
</div>
<div className="form-actions"> <FormActions onCancel={onClose}>
<div className="form-actions-right"> <SubmitButton
<button pendingLabel={mode === "url"
type="button" ? <Trans>Fetching</Trans>
className="form-cancel" : <Trans>Uploading</Trans>}
onClick={onClose}
> >
<Trans>Cancel</Trans> <Trans>Dump it</Trans>
</button> </SubmitButton>
<button </FormActions>
type="submit" </form>
className="btn-primary" </FormProvider>
disabled={submitting ||
comment.length > VALIDATION.DUMP_COMMENT_MAX}
>
{submitting
? (mode === "url"
? <Trans>Fetching</Trans>
: <Trans>Uploading</Trans>)
: <Trans>Dump it</Trans>}
</button>
</div>
</div>
</form>
</> </>
) )
: ( : (
@@ -512,11 +475,7 @@ export function DumpCreateModal(
<div className="form-actions"> <div className="form-actions">
<div className="form-actions-right"> <div className="form-actions-right">
<button <button type="button" className="btn-primary" onClick={onClose}>
type="button"
className="btn-primary"
onClick={onClose}
>
<Trans>Done</Trans> <Trans>Done</Trans>
</button> </button>
</div> </div>

View File

@@ -1,13 +1,20 @@
import { useState } from "react";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { API_URL, VALIDATION } from "../config/api.ts"; import { API_URL, VALIDATION } from "../config/api.ts";
import { CountedInput } from "./CountedInput.tsx";
import type { CreatePlaylistRequest, Playlist, RawPlaylist } from "../model.ts"; import type { CreatePlaylistRequest, Playlist, RawPlaylist } from "../model.ts";
import { deserializePlaylist, parseAPIResponse } from "../model.ts"; import { deserializePlaylist } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
import { ErrorCard } from "./ErrorCard.tsx"; import {
import { TextEditor } from "./TextEditor.tsx"; expectOk,
FormActions,
FormError,
FormProvider,
RichTextField,
SubmitButton,
TextField,
useApiForm,
VisibilityToggle,
} from "./form/index.ts";
interface PlaylistCreateFormProps { interface PlaylistCreateFormProps {
/** If provided, the new playlist will have this dump added to it. */ /** If provided, the new playlist will have this dump added to it. */
@@ -16,115 +23,71 @@ interface PlaylistCreateFormProps {
onCancel: () => void; onCancel: () => void;
} }
interface Values {
title: string;
description: string;
isPublic: boolean;
}
export function PlaylistCreateForm( export function PlaylistCreateForm(
{ dumpId, onCreated, onCancel }: PlaylistCreateFormProps, { dumpId, onCreated, onCancel }: PlaylistCreateFormProps,
) { ) {
const { authFetch } = useAuth(); const { authFetch } = useAuth();
const [title, setTitle] = useState(""); const form = useApiForm<Values>({
const [description, setDescription] = useState(""); defaultValues: { title: "", description: "", isPublic: true },
const [isPublic, setIsPublic] = useState(true); });
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.SubmitEvent) => { const onSubmit = form.submit(async ({ title, description, isPublic }) => {
e.preventDefault(); const playlist = deserializePlaylist(
if ( await expectOk<RawPlaylist>(
!title.trim() || description.length > VALIDATION.PLAYLIST_DESCRIPTION_MAX await authFetch(`${API_URL}/api/playlists`, {
) return; method: "POST",
setSubmitting(true); headers: { "Content-Type": "application/json" },
setError(null); body: JSON.stringify(
try { {
const res = await authFetch(`${API_URL}/api/playlists`, { title: title.trim(),
method: "POST", description: description.trim() || undefined,
headers: { "Content-Type": "application/json" }, isPublic,
body: JSON.stringify( } satisfies CreatePlaylistRequest,
{ ),
title: title.trim(), }),
description: description.trim() || undefined, ),
isPublic, );
} satisfies CreatePlaylistRequest, if (dumpId) {
), await authFetch(
}); `${API_URL}/api/playlists/${playlist.id}/dumps/${dumpId}`,
const body = parseAPIResponse<RawPlaylist>(await res.json()); { method: "POST" },
if (!body.success) { );
setError(body.error.message);
return;
}
const playlist = deserializePlaylist(body.data as RawPlaylist);
if (dumpId) {
await authFetch(
`${API_URL}/api/playlists/${playlist.id}/dumps/${dumpId}`,
{ method: "POST" },
);
}
onCreated(playlist);
} catch {
setError(t`Failed to create playlist`);
} finally {
setSubmitting(false);
} }
}; onCreated(playlist);
});
return ( return (
<form className="modal-new-playlist-form" onSubmit={handleSubmit}> <FormProvider {...form}>
<CountedInput <form className="form" onSubmit={onSubmit}>
type="text" <TextField<Values>
placeholder={t`Title`} name="title"
value={title} placeholder={t`Title`}
onChange={(e) => setTitle(e.target.value)} autoFocus
autoFocus rules={{ required: true }}
required maxLength={VALIDATION.PLAYLIST_TITLE_MAX}
maxLength={VALIDATION.PLAYLIST_TITLE_MAX} showCount
/> />
<TextEditor <RichTextField<Values>
placeholder={t`Description (optional)`} name="description"
value={description} placeholder={t`Description (optional)`}
onChange={setDescription} rows={3}
rows={3} maxLength={VALIDATION.PLAYLIST_DESCRIPTION_MAX}
maxLength={VALIDATION.PLAYLIST_DESCRIPTION_MAX} rules={{ maxLength: VALIDATION.PLAYLIST_DESCRIPTION_MAX }}
/> />
<div className="visibility-toggle"> <VisibilityToggle<Values> name="isPublic" />
<button <FormError title={t`Failed to create playlist`} />
type="button" <FormActions onCancel={onCancel}>
className={isPublic ? "active" : ""} <SubmitButton pendingLabel={<Trans>Creating</Trans>}>
onClick={() => setIsPublic(true)} {dumpId ? <Trans>Create & Add</Trans> : <Trans>Create</Trans>}
> </SubmitButton>
<Trans>Public</Trans> </FormActions>
</button> </form>
<button </FormProvider>
type="button"
className={!isPublic ? "active" : ""}
onClick={() => setIsPublic(false)}
>
<Trans>Private</Trans>
</button>
</div>
{error && (
<ErrorCard title={t`Failed to create playlist`} message={error} />
)}
<div className="form-actions">
<div className="form-actions-right">
<button
type="button"
className="form-cancel"
onClick={onCancel}
>
<Trans>Cancel</Trans>
</button>
<button
type="submit"
className="btn-primary"
disabled={submitting ||
description.length > VALIDATION.PLAYLIST_DESCRIPTION_MAX}
>
{submitting
? <Trans>Creating</Trans>
: dumpId
? <Trans>Create & Add</Trans>
: <Trans>Create</Trans>}
</button>
</div>
</div>
</form>
); );
} }

View File

@@ -0,0 +1,56 @@
import {
Controller,
type FieldValues,
type Path,
type RegisterOptions,
useFormContext,
} from "react-hook-form";
import { FileDropZone } from "../FileDropZone.tsx";
interface FileFieldProps<T extends FieldValues> {
name: Path<T>;
label?: string;
hint?: string;
showLimit?: boolean;
disabled?: boolean;
rules?: RegisterOptions<T, Path<T>>;
}
/** `Controller`-wrapped {@link FileDropZone} bound to react-hook-form. */
export function FileField<T extends FieldValues>({
name,
label,
hint,
showLimit,
disabled,
rules,
}: FileFieldProps<T>) {
const { control, formState: { errors } } = useFormContext<T>();
const error = errors[name];
return (
<div className="form-field">
<Controller
name={name}
control={control}
rules={rules}
render={({ field }) => (
<FileDropZone
file={(field.value as File | null) ?? null}
onChange={field.onChange}
label={label}
hint={hint}
showLimit={showLimit}
disabled={disabled}
/>
)}
/>
{error?.message && (
<span className="form-hint form-hint--error">
{String(error.message)}
</span>
)}
</div>
);
}

View File

@@ -0,0 +1,39 @@
import type { ReactNode } from "react";
import { Trans } from "@lingui/react/macro";
interface FormActionsProps {
children: ReactNode;
onCancel?: () => void;
cancelLabel?: ReactNode;
disabled?: boolean;
}
/**
* Right-aligned form action row: an optional Cancel button followed by the
* submit control(s). Standardizes the `.form-actions > .form-actions-right`
* markup duplicated across forms.
*/
export function FormActions({
children,
onCancel,
cancelLabel,
disabled,
}: FormActionsProps) {
return (
<div className="form-actions">
<div className="form-actions-right">
{onCancel && (
<button
type="button"
className="form-cancel"
onClick={onCancel}
disabled={disabled}
>
{cancelLabel ?? <Trans>Cancel</Trans>}
</button>
)}
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { t } from "@lingui/core/macro";
import { useFormState } from "react-hook-form";
import { ErrorCard } from "../ErrorCard.tsx";
/**
* Renders the form's root error (set by {@link useApiForm}'s `submit` on a
* thrown/failed request) through the shared {@link ErrorCard}. One consistent
* place for server/submit errors across every form.
*/
export function FormError({ title }: { title?: string }) {
const { errors } = useFormState();
const message = errors.root?.message;
if (!message) return null;
return <ErrorCard title={title ?? t`Something went wrong`} message={message} />;
}

View File

@@ -0,0 +1,81 @@
import { useId } from "react";
import {
Controller,
type FieldValues,
type Path,
type RegisterOptions,
useFormContext,
} from "react-hook-form";
import { TextEditor, type TextEditorHandle } from "../TextEditor.tsx";
interface RichTextFieldProps<T extends FieldValues> {
name: Path<T>;
label?: string;
placeholder?: string;
rows?: number;
maxLength?: number;
autoResize?: boolean;
disabled?: boolean;
rules?: RegisterOptions<T, Path<T>>;
/** Forwarded to the underlying TextEditor so callers can focus it. */
editorRef?: React.Ref<TextEditorHandle>;
/** Ctrl/Cmd+Enter handler (e.g. submit the comment). */
onSubmit?: () => void;
}
/**
* Rich-text field: a `Controller`-wrapped {@link TextEditor} (mentions, emoji,
* image upload, char count) bound to react-hook-form via the surrounding
* `FormProvider`.
*/
export function RichTextField<T extends FieldValues>({
name,
label,
placeholder,
rows,
maxLength,
autoResize,
disabled,
rules,
editorRef,
onSubmit,
}: RichTextFieldProps<T>) {
const id = useId();
const { control, formState: { errors } } = useFormContext<T>();
const error = errors[name];
return (
<div className="form-field">
{label && (
<label className="form-label" htmlFor={id}>
{label}
</label>
)}
<Controller
name={name}
control={control}
rules={rules}
render={({ field }) => (
<TextEditor
ref={editorRef}
id={id}
value={field.value ?? ""}
onChange={field.onChange}
placeholder={placeholder}
rows={rows}
maxLength={maxLength}
autoResize={autoResize}
disabled={disabled}
onSubmit={onSubmit}
/>
)}
/>
{error?.message && (
<span className="form-hint form-hint--error">
{String(error.message)}
</span>
)}
</div>
);
}

View File

@@ -0,0 +1,82 @@
import type { ReactNode } from "react";
import { Trans } from "@lingui/react/macro";
import {
Controller,
type FieldValues,
type Path,
useFormContext,
} from "react-hook-form";
interface SegmentedOption<V> {
value: V;
label: ReactNode;
}
interface SegmentedFieldProps<T extends FieldValues, V> {
name: Path<T>;
options: SegmentedOption<V>[];
label?: string;
disabled?: boolean;
}
/**
* Segmented control (the `.visibility-toggle` style) bound to react-hook-form.
* Generic over the field value, so it works for booleans, string unions, etc.
*/
export function SegmentedField<T extends FieldValues, V>({
name,
options,
label,
disabled,
}: SegmentedFieldProps<T, V>) {
const { control } = useFormContext<T>();
return (
<div className="form-field">
{label && <span className="form-label">{label}</span>}
<Controller
name={name}
control={control}
render={({ field }) => (
<div className="visibility-toggle">
{options.map((opt) => (
<button
key={String(opt.value)}
type="button"
className={field.value === opt.value ? "active" : ""}
disabled={disabled}
onClick={() => field.onChange(opt.value)}
>
{opt.label}
</button>
))}
</div>
)}
/>
</div>
);
}
/**
* Public/Private toggle on a boolean field (default `isPublic`). Forms that
* persist `isPrivate` should still bind this to an `isPublic` field and invert
* at submit time.
*/
export function VisibilityToggle<T extends FieldValues>({
name,
disabled,
}: {
name: Path<T>;
disabled?: boolean;
}) {
return (
<SegmentedField<T, boolean>
name={name}
disabled={disabled}
options={[
{ value: true, label: <Trans>Public</Trans> },
{ value: false, label: <Trans>Private</Trans> },
]}
/>
);
}

View File

@@ -0,0 +1,30 @@
import type { ReactNode } from "react";
import { useFormState } from "react-hook-form";
interface SubmitButtonProps {
children: ReactNode;
/** Replaces the label while the form is submitting (e.g. "Saving…"). */
pendingLabel?: ReactNode;
disabled?: boolean;
className?: string;
}
/**
* Submit button that reflects react-hook-form's `isSubmitting` from the
* surrounding `FormProvider`: it disables itself while submitting and swaps in
* `pendingLabel`. Standardizes the hand-rolled "Save → Saving…" pattern.
*/
export function SubmitButton({
children,
pendingLabel,
disabled,
className = "btn-primary",
}: SubmitButtonProps) {
const { isSubmitting } = useFormState();
return (
<button type="submit" className={className} disabled={isSubmitting || disabled}>
{isSubmitting && pendingLabel != null ? pendingLabel : children}
</button>
);
}

View File

@@ -0,0 +1,90 @@
import { useId } from "react";
import {
type FieldValues,
type Path,
type RegisterOptions,
useFormContext,
useWatch,
} from "react-hook-form";
import { charCountClass } from "../../utils/charCount.ts";
interface TextFieldProps<T extends FieldValues> {
name: Path<T>;
label?: string;
type?: "text" | "email" | "password" | "url" | "search";
placeholder?: string;
autoComplete?: string;
autoFocus?: boolean;
disabled?: boolean;
rules?: RegisterOptions<T, Path<T>>;
/** Cap input length at `max` (native `maxLength`). */
maxLength?: number;
/** Show a live `n / maxLength` counter. Requires `maxLength`. */
showCount?: boolean;
}
/** Live `n / max` counter; isolated so only counted fields subscribe to value. */
function CharCounter<T extends FieldValues>(
{ name, max }: { name: Path<T>; max: number },
) {
const value = useWatch({ name }) as string | undefined;
const len = value?.length ?? 0;
return (
<span className={`text-editor-count${charCountClass(len, max)}`}>
{len} / {max}
</span>
);
}
/**
* Native text input wired to react-hook-form via the surrounding `FormProvider`.
* Renders a label, the input (with an optional character counter), and a
* validation hint sourced from `formState.errors[name]`.
*/
export function TextField<T extends FieldValues>({
name,
label,
type = "text",
placeholder,
autoComplete,
autoFocus,
disabled,
rules,
maxLength,
showCount = false,
}: TextFieldProps<T>) {
const id = useId();
const { register, formState: { errors } } = useFormContext<T>();
const error = errors[name];
const counted = showCount && maxLength != null;
return (
<div className="form-field">
{label && (
<label className="form-label" htmlFor={id}>
{label}
</label>
)}
<div className={counted ? "input-with-count" : undefined}>
<input
id={id}
type={type}
placeholder={placeholder}
autoComplete={autoComplete}
autoFocus={autoFocus}
disabled={disabled}
maxLength={maxLength}
aria-invalid={error ? true : undefined}
{...register(name, rules)}
/>
{counted && <CharCounter<T> name={name} max={maxLength} />}
</div>
{error?.message && (
<span className="form-hint form-hint--error">
{String(error.message)}
</span>
)}
</div>
);
}

View File

@@ -0,0 +1,9 @@
export { expectOk, useApiForm } from "./useApiForm.ts";
export { TextField } from "./TextField.tsx";
export { RichTextField } from "./RichTextField.tsx";
export { SegmentedField, VisibilityToggle } from "./SegmentedField.tsx";
export { FileField } from "./FileField.tsx";
export { SubmitButton } from "./SubmitButton.tsx";
export { FormActions } from "./FormActions.tsx";
export { FormError } from "./FormError.tsx";
export { FormProvider } from "react-hook-form";

View File

@@ -0,0 +1,59 @@
import { useCallback } from "react";
import {
type FieldValues,
type SubmitHandler,
useForm,
type UseFormProps,
type UseFormReturn,
} from "react-hook-form";
import { friendlyFetchError } from "../../utils/apiError.ts";
import { parseAPIResponse } from "../../model.ts";
export interface UseApiFormReturn<T extends FieldValues> extends UseFormReturn<T> {
/**
* Wraps an async submit handler with the shared API-form lifecycle: clears the
* previous root error, runs the handler while RHF keeps `formState.isSubmitting`
* true, and on a thrown error surfaces a single root error via
* `friendlyFetchError`. Pair it with {@link expectOk} so API-level failures and
* network failures both become root errors through one code path. Returns the
* `onSubmit` handler to spread onto `<form onSubmit={...}>`.
*/
submit: (
handler: SubmitHandler<T>,
) => (e?: React.BaseSyntheticEvent) => Promise<void>;
}
export function useApiForm<T extends FieldValues>(
options?: UseFormProps<T>,
): UseApiFormReturn<T> {
const form = useForm<T>(options);
const { handleSubmit, setError, clearErrors } = form;
const submit = useCallback(
(handler: SubmitHandler<T>) =>
handleSubmit(async (values, event) => {
clearErrors("root");
try {
await handler(values, event);
} catch (err) {
setError("root", { message: friendlyFetchError(err) });
}
}),
[handleSubmit, setError, clearErrors],
);
return { ...form, submit };
}
/**
* Reads a fetch `Response` through the standard API envelope and returns the
* payload, throwing the server's message on `success: false`. A thrown error
* here (or a network `TypeError` from the `fetch` itself) is caught by
* {@link useApiForm}'s `submit` and rendered as the form's root error.
*/
export async function expectOk<T>(res: Response): Promise<T> {
const body = parseAPIResponse<T>(await res.json());
if (!body.success) throw new Error(body.error.message);
return body.data;
}

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -15,9 +15,20 @@ import { friendlyFetchError } from "../utils/apiError.ts";
import { ConfirmModal } from "../components/ConfirmModal.tsx"; import { ConfirmModal } from "../components/ConfirmModal.tsx";
import RichContentCard from "../components/RichContentCard.tsx"; import RichContentCard from "../components/RichContentCard.tsx";
import FilePreview from "../components/FilePreview.tsx"; import FilePreview from "../components/FilePreview.tsx";
import { TextEditor } from "../components/TextEditor.tsx";
import { FileDropZone } from "../components/FileDropZone.tsx"; import { FileDropZone } from "../components/FileDropZone.tsx";
import { ImagePicker } from "../components/ImagePicker.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 = type DumpEditState =
| { status: "loading" } | { status: "loading" }
@@ -30,11 +41,6 @@ export function DumpEdit() {
const { authFetch, token } = useRequiredAuth(); const { authFetch, token } = useRequiredAuth();
const [state, setState] = useState<DumpEditState>({ status: "loading" }); 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 [confirmDelete, setConfirmDelete] = useState(false);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [thumbUploading, setThumbUploading] = useState(false); const [thumbUploading, setThumbUploading] = useState(false);
@@ -52,12 +58,7 @@ export function DumpEdit() {
const apiResponse = parseAPIResponse<RawDump>(await res.json()); const apiResponse = parseAPIResponse<RawDump>(await res.json());
if (apiResponse.success) { if (apiResponse.success) {
const dump: Dump = deserializeDump(apiResponse.data); setState({ status: "loaded", dump: deserializeDump(apiResponse.data) });
setUrl(dump.url ?? "");
setTitle(dump.title);
setComment(dump.comment ?? "");
setIsPrivate(dump.isPrivate);
setState({ status: "loaded", dump });
} else { } else {
setState({ status: "error", error: apiResponse.error.message }); setState({ status: "error", error: apiResponse.error.message });
} }
@@ -67,56 +68,6 @@ export function DumpEdit() {
})(); })();
}, [selectedDump, token]); }, [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 () => { const handleRefreshMetadata = async () => {
if (state.status !== "loaded" || state.dump.kind !== "url") return; if (state.status !== "loaded" || state.dump.kind !== "url") return;
@@ -128,8 +79,7 @@ export function DumpEdit() {
); );
const apiResponse = await res.json(); const apiResponse = await res.json();
if (apiResponse.success) { if (apiResponse.success) {
const updatedDump: Dump = deserializeDump(apiResponse.data); setState({ status: "loaded", dump: deserializeDump(apiResponse.data) });
setState({ status: "loaded", dump: updatedDump });
} }
} finally { } finally {
setRefreshing(false); setRefreshing(false);
@@ -274,138 +224,17 @@ export function DumpEdit() {
)} )}
</div> </div>
<form <DumpEditForm
className="dump-form" key={dump.id}
onSubmit={(e) => { dump={dump}
e.preventDefault(); currentThumbnailSrc={currentThumbnailSrc}
handleSave(); thumbUploading={thumbUploading}
}} onThumbnailChange={handleThumbnailChange}
> onThumbnailReset={handleThumbnailReset}
<div className="form-group"> onRequestDelete={() => setConfirmDelete(true)}
<label> onSaved={(updated) =>
<Trans>Thumbnail</Trans> navigate(dumpUrl(updated), { state: { dump: updated } })}
</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>
</div> </div>
{confirmDelete && ( {confirmDelete && (
<ConfirmModal <ConfirmModal
@@ -418,3 +247,180 @@ export function DumpEdit() {
</PageShell> </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 { API_URL, VALIDATION } from "../config/api.ts";
import { CountedInput } from "../components/CountedInput.tsx"; import { CountedInput } from "../components/CountedInput.tsx";
import type { import type {
Playlist,
PlaylistWithDumps, PlaylistWithDumps,
RawDump, RawDump,
RawPlaylist, RawPlaylist,
@@ -22,7 +23,6 @@ import {
deserializeDump, deserializeDump,
deserializePlaylist, deserializePlaylist,
deserializePlaylistWithDumps, deserializePlaylistWithDumps,
parseAPIResponse,
} from "../model.ts"; } from "../model.ts";
import { playlistUrl } from "../utils/urls.ts"; import { playlistUrl } from "../utils/urls.ts";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
@@ -36,9 +36,16 @@ import { ImagePicker } from "../components/ImagePicker.tsx";
import { Markdown } from "../components/Markdown.tsx"; import { Markdown } from "../components/Markdown.tsx";
import { TextEditor } from "../components/TextEditor.tsx"; import { TextEditor } from "../components/TextEditor.tsx";
import { FollowPlaylistButton } from "../components/FollowButton.tsx"; import { FollowPlaylistButton } from "../components/FollowButton.tsx";
import { ErrorCard } from "../components/ErrorCard.tsx";
import { Tooltip } from "../components/Tooltip.tsx"; import { Tooltip } from "../components/Tooltip.tsx";
import { friendlyFetchError } from "../utils/apiError.ts"; 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 = type LoadState =
| { status: "loading" } | { status: "loading" }
@@ -88,14 +95,7 @@ export function PlaylistDetail() {
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null); const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const [editOpen, setEditOpen] = useState(false); 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 [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. // Mirrors activeDumpIds for use in effects without adding it as a dep.
// Updated on every render via useLayoutEffect so it's always current. // Updated on every render via useLayoutEffect so it's always current.
@@ -522,68 +522,11 @@ export function PlaylistDetail() {
.catch(() => {}); .catch(() => {});
}; };
const openEdit = () => { const openEdit = () => setEditOpen(true);
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 handleEditSave = async () => { const handleEditSaved = (updated: Playlist) => {
if ( setEditOpen(false);
!playlistId || state.status !== "loaded" || navigate(playlistUrl(updated), { replace: true });
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 handleDelete = async () => { const handleDelete = async () => {
@@ -636,125 +579,49 @@ export function PlaylistDetail() {
<div className="playlist-detail-header-top"> <div className="playlist-detail-header-top">
{editOpen {editOpen
? ( ? (
<ImagePicker <PlaylistEditForm
src={imagePreview ?? playlist={playlist}
(playlist.imageMime onCancel={() => setEditOpen(false)}
? `${API_URL}/api/playlists/${playlist.id}/image` onRequestDelete={() => setConfirmDelete(true)}
: null)} onSaved={handleEditSaved}
alt="Cover"
size={72}
onChange={(file) => {
setImageFile(file);
setImagePreview(URL.createObjectURL(file));
}}
/> />
) )
: playlist.imageMime && ( : (
<img <>
src={`${API_URL}/api/playlists/${playlist.id}/image`} {playlist.imageMime && (
alt="" <img
className="playlist-detail-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}
/> />
<button )}
type="button" <div className="playlist-detail-content">
className="btn-primary" <div className="playlist-detail-title-row">
disabled={editSaving || <h1 className="playlist-detail-title">{playlist.title}</h1>
editDescription.length > {!isOwner && (
VALIDATION.PLAYLIST_DESCRIPTION_MAX} <FollowPlaylistButton
onClick={handleEditSave} targetPlaylistId={playlist.id}
> isPublic={playlist.isPublic}
{editSaving ? <Trans>Saving</Trans> : <Trans>Save</Trans>} />
</button> )}
<button {isOwner && (
type="button" <button
className="form-cancel" type="button"
onClick={() => setEditOpen(false)} className="playlist-edit-btn"
> onClick={openEdit}
<Trans>Cancel</Trans> >
</button> <Trans>Edit</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> </div>
)
: ( {playlist.description && (
<> <Markdown className="playlist-detail-description">
{playlist.description}
</Markdown>
)}
<div className="playlist-detail-meta">
<span <span
className={`playlist-badge${ className={`playlist-badge${
playlist.isPublic ? "" : " playlist-badge--private" playlist.isPublic ? "" : " playlist-badge--private"
@@ -788,13 +655,10 @@ export function PlaylistDetail() {
</span> </span>
</Tooltip> </Tooltip>
)} )}
</> </div>
)} </div>
</div> </>
{editError && (
<ErrorCard title={t`Failed to save`} message={editError} />
)} )}
</div>
</div> </div>
</div> </div>
@@ -887,3 +751,173 @@ export function PlaylistDetail() {
</PageShell> </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 { Trans } from "@lingui/react/macro";
import { API_URL, VALIDATION } from "../config/api.ts"; import { API_URL, VALIDATION } from "../config/api.ts";
import { ErrorCard } from "../components/ErrorCard.tsx";
import { PageShell } from "../components/PageShell.tsx"; import { PageShell } from "../components/PageShell.tsx";
import {
expectOk,
FormError,
FormProvider,
SubmitButton,
TextField,
useApiForm,
} from "../components/form/index.ts";
type State = interface Values {
| { status: "idle" } newPassword: string;
| { status: "submitting" } confirm: string;
| { status: "done" } }
| { status: "error"; error: string };
export function ResetPassword() { export function ResetPassword() {
const [params] = useSearchParams(); const [params] = useSearchParams();
const navigate = useNavigate(); const navigate = useNavigate();
const token = params.get("token") ?? ""; const token = params.get("token") ?? "";
const [done, setDone] = useState(false);
const [newPassword, setNewPassword] = useState(""); const form = useApiForm<Values>({
const [confirm, setConfirm] = useState(""); mode: "onChange",
const [state, setState] = useState<State>({ status: "idle" }); defaultValues: { newPassword: "", confirm: "" },
});
const mismatch = confirm.length > 0 && newPassword !== confirm; const onSubmit = form.submit(async ({ newPassword }) => {
const tooShort = newPassword.length > 0 && await expectOk(
newPassword.length < VALIDATION.PASSWORD_MIN; await fetch(`${API_URL}/api/users/reset-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, newPassword }),
}),
);
setDone(true);
});
if (!token) { if (!token) {
return ( return (
@@ -48,7 +63,7 @@ export function ResetPassword() {
); );
} }
if (state.status === "done") { if (done) {
return ( return (
<PageShell centered> <PageShell centered>
<div className="auth-card"> <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 ( return (
<PageShell centered> <PageShell centered>
<div className="auth-card"> <div className="auth-card">
@@ -102,57 +92,40 @@ export function ResetPassword() {
<Trans>Set new password</Trans> <Trans>Set new password</Trans>
</h1> </h1>
{state.status === "error" && ( <FormProvider {...form}>
<ErrorCard title={t`Reset failed`} message={state.error} /> <form onSubmit={onSubmit} className="form">
)} <FormError title={t`Reset failed`} />
<TextField<Values>
<form onSubmit={handleSubmit} className="auth-form"> name="newPassword"
<div className="form-group">
<input
type="password" type="password"
placeholder={t`New password`} placeholder={t`New password`}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password" autoComplete="new-password"
minLength={VALIDATION.PASSWORD_MIN}
maxLength={VALIDATION.PASSWORD_MAX}
required
autoFocus 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 && ( <TextField<Values>
<span className="auth-field-hint auth-field-hint--error"> name="confirm"
<Trans>At least {VALIDATION.PASSWORD_MIN} characters</Trans>
</span>
)}
</div>
<div className="form-group">
<input
type="password" type="password"
placeholder={t`Confirm new password`} placeholder={t`Confirm new password`}
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
autoComplete="new-password" autoComplete="new-password"
required rules={{
disabled={state.status === "submitting"} required: true,
validate: (value, values) =>
value === values.newPassword || t`Passwords do not match`,
}}
/> />
{mismatch && ( <SubmitButton pendingLabel={<Trans>Saving</Trans>}>
<span className="auth-field-hint auth-field-hint--error"> <Trans>Set new password</Trans>
<Trans>Passwords do not match</Trans> </SubmitButton>
</span> </form>
)} </FormProvider>
</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>
<p className="auth-card-footer"> <p className="auth-card-footer">
<Link to="/login"> <Link to="/login">

View File

@@ -1,5 +1,4 @@
import { useState } from "react"; import { useState } from "react";
import type { SubmitEvent } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
@@ -8,77 +7,48 @@ import { API_URL } from "../config/api.ts";
import { import {
deserializeAuthResponse, deserializeAuthResponse,
type LoginRequest, type LoginRequest,
parseAPIResponse,
type RawAuthResponse, type RawAuthResponse,
} from "../model.ts"; } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
import { PageShell } from "../components/PageShell.tsx"; import { PageShell } from "../components/PageShell.tsx";
import { ErrorCard } from "../components/ErrorCard.tsx"; import {
import { friendlyFetchError } from "../utils/apiError.ts"; expectOk,
FormError,
FormProvider,
SubmitButton,
TextField,
useApiForm,
} from "../components/form/index.ts";
type LoginState = interface LoginValues {
| { status: "idle" } username: string;
| { status: "submitting" } password: string;
| { status: "error"; error: string }; }
type ResetState = interface ResetValues {
| { status: "idle" } email: string;
| { status: "submitting" } }
| { status: "sent" }
| { status: "error"; error: string };
export function UserLogin() { export function UserLogin() {
const navigate = useNavigate(); const navigate = useNavigate();
const { login } = useAuth(); const { login } = useAuth();
const [loginState, setLoginState] = useState<LoginState>({ status: "idle" });
const [showReset, setShowReset] = useState(false); const [showReset, setShowReset] = useState(false);
const [resetEmail, setResetEmail] = useState("");
const [resetState, setResetState] = useState<ResetState>({ status: "idle" });
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => { const form = useApiForm<LoginValues>({
e.preventDefault(); defaultValues: { username: "", password: "" },
setLoginState({ status: "submitting" }); });
const formData = new FormData(e.currentTarget); const onSubmit = form.submit(async ({ username, password }) => {
const username = formData.get("username") as string; const data = await expectOk<RawAuthResponse>(
const password = formData.get("password") as string; await fetch(`${API_URL}/api/users/login`, {
try {
const res = await fetch(`${API_URL}/api/users/login`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password } satisfies LoginRequest), body: JSON.stringify({ username, password } satisfies LoginRequest),
}); }),
);
const apiResponse = parseAPIResponse<RawAuthResponse>(await res.json()); login(deserializeAuthResponse(data));
navigate("/");
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` });
}
};
return ( return (
<PageShell centered> <PageShell centered>
@@ -87,98 +57,38 @@ export function UserLogin() {
<Trans>Log in</Trans> <Trans>Log in</Trans>
</h1> </h1>
{loginState.status === "error" && ( <FormProvider {...form}>
<ErrorCard title={t`Login failed`} message={loginState.error} /> <form onSubmit={onSubmit} className="form">
)} <FormError title={t`Login failed`} />
<TextField<LoginValues>
<form onSubmit={handleSubmit} className="auth-form"> name="username"
<input placeholder={t`Username`}
name="username" autoFocus
type="text" rules={{ required: true }}
placeholder={t`Username`} />
required <TextField<LoginValues>
disabled={loginState.status === "submitting"} name="password"
autoFocus type="password"
/> placeholder={t`Password`}
<input rules={{ required: true }}
name="password" />
type="password" <SubmitButton pendingLabel={<Trans>Logging in</Trans>}>
placeholder={t`Password`} <Trans>Log in</Trans>
required </SubmitButton>
disabled={loginState.status === "submitting"} </form>
/> </FormProvider>
<button
type="submit"
className="btn-primary"
disabled={loginState.status === "submitting"}
>
{loginState.status === "submitting"
? <Trans>Logging in</Trans>
: <Trans>Log in</Trans>}
</button>
</form>
<p className="auth-card-footer"> <p className="auth-card-footer">
<button <button
type="button" type="button"
className="auth-link-btn" className="auth-link-btn"
onClick={() => { onClick={() => setShowReset((v) => !v)}
setShowReset((v) => !v);
setResetState({ status: "idle" });
setResetEmail("");
}}
> >
<Trans>Forgot password?</Trans> <Trans>Forgot password?</Trans>
</button> </button>
</p> </p>
{showReset && ( {showReset && <ResetRequestPanel />}
<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>
)}
<p className="auth-card-footer"> <p className="auth-card-footer">
<Trans>This is a mirage.</Trans> <Trans>This is a mirage.</Trans>
@@ -187,3 +97,49 @@ export function UserLogin() {
</PageShell> </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 { ChangePasswordModal } from "../components/ChangePasswordModal.tsx";
import { TabBar } from "../components/TabBar.tsx"; import { TabBar } from "../components/TabBar.tsx";
import { useTabParam } from "../hooks/useTabParam.ts"; 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() { function InviteButton() {
const { authFetch } = useAuth(); const { authFetch } = useAuth();
@@ -292,14 +300,7 @@ export function UserPublicProfile() {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [descEditing, setDescEditing] = useState(false); 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 [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 prevMyVotesRef = useRef<Set<string> | null>(null);
const [changePasswordOpen, setChangePasswordOpen] = useState(false); const [changePasswordOpen, setChangePasswordOpen] = useState(false);
@@ -674,72 +675,22 @@ export function UserPublicProfile() {
} }
}; };
const handleEmailSave = async () => { const handleEmailSaved = (email: string) => {
if (state.status !== "loaded") return; const storedRaw = localStorage.getItem("authResponse");
setEmailSaving(true); if (storedRaw) {
setEmailError(null); const prev = deserializeAuthResponse(JSON.parse(storedRaw));
try { login({ ...prev, user: { ...prev.user, email } });
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);
} }
setEmailEditing(false);
}; };
const handleDescSave = async () => { const handleDescSaved = (description: string | undefined) => {
if ( setState((s) =>
state.status !== "loaded" || s.status === "loaded"
descDraft.length > VALIDATION.USER_DESCRIPTION_MAX ? { ...s, user: { ...s.user, description } }
) return; : s
setDescSaving(true); );
setDescError(null); setDescEditing(false);
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);
}
}; };
if (state.status === "loading") { if (state.status === "loading") {
@@ -826,59 +777,16 @@ export function UserPublicProfile() {
{isOwnProfile && ( {isOwnProfile && (
emailEditing emailEditing
? ( ? (
<form <EmailEditor
className="profile-email-editor" initialEmail={me?.email ?? ""}
onSubmit={(e) => { onCancel={() => setEmailEditing(false)}
e.preventDefault(); onSaved={handleEmailSaved}
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>
) )
: ( : (
<p <p
className="profile-email-display" className="profile-email-display"
onClick={() => { onClick={() => setEmailEditing(true)}
setEmailDraft(me?.email ?? "");
setEmailError(null);
setEmailEditing(true);
}}
title="Edit email" title="Edit email"
> >
{me?.email ?? t`Add email…`} {me?.email ?? t`Add email…`}
@@ -916,39 +824,11 @@ export function UserPublicProfile() {
<div className="profile-description"> <div className="profile-description">
{descEditing {descEditing
? ( ? (
<div className="profile-description-editor"> <DescriptionEditor
<TextEditor initialDescription={profileUser.description ?? ""}
className="comment-reply-textarea" onCancel={() => setDescEditing(false)}
value={descDraft} onSaved={handleDescSaved}
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>
) )
: ( : (
<div <div
@@ -956,11 +836,7 @@ export function UserPublicProfile() {
isOwnProfile ? " profile-description-view--editable" : "" isOwnProfile ? " profile-description-view--editable" : ""
}`} }`}
onClick={isOwnProfile onClick={isOwnProfile
? () => { ? () => setDescEditing(true)
setDescDraft(profileUser.description ?? "");
setDescError(null);
setDescEditing(true);
}
: undefined} : undefined}
> >
{profileUser.description {profileUser.description
@@ -1548,3 +1424,136 @@ function FollowedUserCard({ user }: { user: PublicUser }) {
</li> </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 { useEffect, useState } from "react";
import type { SubmitEvent } from "react";
import { Link, useNavigate, useSearchParams } from "react-router"; import { Link, useNavigate, useSearchParams } from "react-router";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/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 { API_URL, VALIDATION } from "../config/api.ts";
import { import {
deserializeAuthResponse, deserializeAuthResponse,
parseAPIResponse,
type RawAuthResponse, type RawAuthResponse,
type RegisterRequest, type RegisterRequest,
} from "../model.ts"; } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
import { PageShell } from "../components/PageShell.tsx"; import { PageShell } from "../components/PageShell.tsx";
import { ErrorCard } from "../components/ErrorCard.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 = type TokenState =
| { status: "checking" } | { status: "checking" }
| { status: "invalid" } | { status: "invalid" }
| { status: "valid" }; | { status: "valid" };
type FormState = interface Values {
| { status: "idle" } username: string;
| { status: "submitting" } email: string;
| { status: "error"; error: string }; password: string;
}
export function UserRegister() { export function UserRegister() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -35,9 +41,12 @@ export function UserRegister() {
const [tokenState, setTokenState] = useState<TokenState>(() => const [tokenState, setTokenState] = useState<TokenState>(() =>
token ? { status: "checking" } : { status: "invalid" } token ? { status: "checking" } : { status: "invalid" }
); );
const [formState, setFormState] = useState<FormState>({ status: "idle" });
const [prevToken, setPrevToken] = useState(token); const [prevToken, setPrevToken] = useState(token);
const form = useApiForm<Values>({
defaultValues: { username: "", email: "", password: "" },
});
if (prevToken !== token) { if (prevToken !== token) {
setPrevToken(token); setPrevToken(token);
setTokenState(token ? { status: "checking" } : { status: "invalid" }); setTokenState(token ? { status: "checking" } : { status: "invalid" });
@@ -52,41 +61,20 @@ export function UserRegister() {
.catch(() => setTokenState({ status: "invalid" })); .catch(() => setTokenState({ status: "invalid" }));
}, [token]); }, [token]);
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => { const onSubmit = form.submit(async ({ username, email, password }) => {
e.preventDefault(); const data = await expectOk<RawAuthResponse>(
setFormState({ status: "submitting" }); await fetch(`${API_URL}/api/users/register`, {
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`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify( body: JSON.stringify(
{ { username, password, inviteToken: token, email } satisfies
username, RegisterRequest,
password,
inviteToken: token,
email,
} satisfies RegisterRequest,
), ),
}); }),
);
const apiResponse = parseAPIResponse<RawAuthResponse>(await res.json()); login(deserializeAuthResponse(data));
navigate("/");
if (apiResponse.success) { });
login(deserializeAuthResponse(apiResponse.data));
navigate("/");
} else {
setFormState({ status: "error", error: apiResponse.error.message });
}
} catch (err) {
setFormState({ status: "error", error: friendlyFetchError(err) });
}
};
if (tokenState.status === "checking") { if (tokenState.status === "checking") {
return ( return (
@@ -118,48 +106,49 @@ export function UserRegister() {
<Trans>Register</Trans> <Trans>Register</Trans>
</h1> </h1>
{formState.status === "error" && ( <FormProvider {...form}>
<ErrorCard title={t`Registration failed`} message={formState.error} /> <form onSubmit={onSubmit} className="form">
)} <FormError title={t`Registration failed`} />
<TextField<Values>
<form onSubmit={handleSubmit} className="auth-form"> name="username"
<input placeholder={t`Username`}
name="username" autoFocus
type="text" maxLength={VALIDATION.USERNAME_MAX}
placeholder={t`Username`} rules={{
required required: true,
pattern={`[a-zA-Z0-9_]{${VALIDATION.USERNAME_MIN},${VALIDATION.USERNAME_MAX}}`} pattern: {
title={t`${VALIDATION.USERNAME_MIN}${VALIDATION.USERNAME_MAX} characters: letters, numbers, or underscores`} value: new RegExp(
maxLength={VALIDATION.USERNAME_MAX} `^[a-zA-Z0-9_]{${VALIDATION.USERNAME_MIN},${VALIDATION.USERNAME_MAX}}$`,
disabled={formState.status === "submitting"} ),
autoFocus message:
/> t`${VALIDATION.USERNAME_MIN}${VALIDATION.USERNAME_MAX} characters: letters, numbers, or underscores`,
<input },
name="email" }}
type="email" />
placeholder={t`Email address`} <TextField<Values>
required name="email"
disabled={formState.status === "submitting"} type="email"
/> placeholder={t`Email address`}
<input rules={{ required: true }}
name="password" />
type="password" <TextField<Values>
placeholder={t`Password (min. ${VALIDATION.PASSWORD_MIN} characters)`} name="password"
required type="password"
minLength={VALIDATION.PASSWORD_MIN} placeholder={t`Password (min. ${VALIDATION.PASSWORD_MIN} characters)`}
maxLength={VALIDATION.PASSWORD_MAX} maxLength={VALIDATION.PASSWORD_MAX}
disabled={formState.status === "submitting"} rules={{
/> required: true,
<button minLength: {
type="submit" value: VALIDATION.PASSWORD_MIN,
className="btn-primary" message: t`At least ${VALIDATION.PASSWORD_MIN} characters`,
disabled={formState.status === "submitting"} },
> }}
{formState.status === "submitting" />
? <Trans>Registering</Trans> <SubmitButton pendingLabel={<Trans>Registering</Trans>}>
: <Trans>Register</Trans>} <Trans>Register</Trans>
</button> </SubmitButton>
</form> </form>
</FormProvider>
<p className="auth-card-footer"> <p className="auth-card-footer">
<Trans> <Trans>

View File

@@ -176,7 +176,8 @@
[data-style="brutalist"] .rich-content-thumbnail-btn, [data-style="brutalist"] .rich-content-thumbnail-btn,
[data-style="brutalist"] .user-menu-trigger, [data-style="brutalist"] .user-menu-trigger,
[data-style="brutalist"] .nav-search-btn, [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; border: none;
box-shadow: none; box-shadow: none;
} }
@@ -186,7 +187,9 @@
[data-style="brutalist"] .nav-search-btn:hover:not(:disabled), [data-style="brutalist"] .nav-search-btn:hover:not(:disabled),
[data-style="brutalist"] .nav-search-btn:active, [data-style="brutalist"] .nav-search-btn:active,
[data-style="brutalist"] .auth-link-btn:hover:not(:disabled), [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; transform: none;
box-shadow: none; box-shadow: none;
} }
@@ -287,10 +290,8 @@
border-color: var(--color-border) !important; border-color: var(--color-border) !important;
} }
[data-style="brutalist"] .dump-form input:focus, [data-style="brutalist"] .form input:focus,
[data-style="brutalist"] .dump-form textarea:focus, [data-style="brutalist"] .form textarea:focus,
[data-style="brutalist"] .auth-form input:focus,
[data-style="brutalist"] .auth-form textarea:focus,
[data-style="brutalist"] .mention-textarea-wrap textarea:focus { [data-style="brutalist"] .mention-textarea-wrap textarea:focus {
box-shadow: 2px 2px 0 var(--color-accent); box-shadow: 2px 2px 0 var(--color-accent);
} }