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

View File

@@ -5,6 +5,23 @@ import { DUMPS_DIR } from "../lib/upload.ts";
const router = new Router({ prefix: "/api/files" });
/**
* Builds an RFC 6266 `Content-Disposition` value that is always a valid
* ByteString. `headers.set` throws a TypeError if the value contains any
* character > U+00FF, so a filename with an emoji / accent / CJK character
* (e.g. an uploaded file) would otherwise crash the response. We provide an
* ASCII-only `filename="…"` fallback plus an RFC 5987 `filename*` with the
* full UTF-8 name for modern browsers.
*/
function contentDisposition(name: string): string {
const asciiFallback = name
.replace(/["\\]/g, "_")
.replace(/[^\x20-\x7e]/g, "_");
return `inline; filename="${asciiFallback}"; filename*=UTF-8''${
encodeURIComponent(name)
}`;
}
router.get("/:dumpId", async (ctx) => {
const { dumpId } = ctx.params;
@@ -36,7 +53,7 @@ router.get("/:dumpId", async (ctx) => {
ctx.response.headers.set("Content-Type", dump.fileMime);
ctx.response.headers.set(
"Content-Disposition",
`inline; filename="${dump.fileName}"`,
contentDisposition(dump.fileName),
);
ctx.response.headers.set("Accept-Ranges", "bytes");

8
deno.lock generated
View File

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

View File

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

View File

@@ -235,30 +235,31 @@
}
/* ── Forms ── */
.auth-form {
/* Unified form system: `.form` container + `.form-field` / `.form-label` /
`.form-hint` building blocks, shared by every form. */
.form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.dump-form .form-group,
.auth-form .form-group {
.form-field {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.dump-form label,
.auth-form label {
.form-label {
font-size: 0.85rem;
font-weight: 600;
opacity: 0.6;
}
.dump-form input,
.dump-form textarea,
.auth-form input,
.auth-form textarea {
.form input,
.form textarea {
width: 100%;
box-sizing: border-box;
padding: 0.65rem 1rem;
border-radius: 8px;
border: 2px solid var(--color-border);
@@ -274,15 +275,21 @@
outline: none;
}
.dump-form textarea,
.auth-form textarea {
/* A submit/secondary button placed as a direct child of `.form` (the auth
forms, which have no action row) spans the full width like the inputs.
Buttons inside `.form-actions` keep their intrinsic width. */
.form > .btn-primary,
.form > .btn-secondary,
.form > .btn-danger {
width: 100%;
}
.form textarea {
resize: vertical;
}
.dump-form input:hover,
.dump-form textarea:hover,
.auth-form input:hover,
.auth-form textarea:hover {
.form input:hover,
.form textarea:hover {
border-color: color-mix(
in srgb,
var(--color-accent) 45%,
@@ -290,10 +297,8 @@
);
}
.dump-form input:focus,
.dump-form textarea:focus,
.auth-form input:focus,
.auth-form textarea:focus {
.form input:focus,
.form textarea:focus {
border-color: var(--color-accent);
background-color: color-mix(
in srgb,
@@ -304,13 +309,27 @@
color-mix(in srgb, var(--color-accent) 18%, transparent);
}
.dump-form input:disabled,
.dump-form textarea:disabled,
.auth-form input:disabled,
.auth-form textarea:disabled {
.form input:disabled,
.form textarea:disabled {
opacity: 0.6;
}
.form-hint {
font-size: 0.8rem;
opacity: 0.7;
margin-top: 0.1rem;
}
.form-hint--error {
color: var(--color-error, #c0392b);
opacity: 1;
}
.form-success {
color: var(--color-success, green);
margin: 0;
}
.dump-create-title-row {
display: flex;
align-items: center;
@@ -2308,16 +2327,6 @@ body.has-player .fab-new {
opacity: 0.85;
}
.auth-field-hint {
display: block;
font-size: 0.8rem;
margin-top: 0.25rem;
}
.auth-field-hint--error {
color: var(--color-error, #e53e3e);
}
/* ── Form pages (DumpCreate / DumpEdit) ── */
@keyframes page-enter {
from {
@@ -2348,7 +2357,9 @@ body.has-player .fab-new {
.form-page--two-col .dump-edit-preview {
border-radius: 0 0 0 12px;
}
.form-page--two-col .dump-form {
/* Higher specificity than the generic `.form-page .form` below so the
form's bottom-left stays square where it meets the preview. */
.form-page.form-page--two-col .form {
border-radius: 0 0 12px 0;
}
}
@@ -2395,10 +2406,7 @@ body.has-player .fab-new {
gap: 0.75rem;
}
.dump-form {
display: flex;
flex-direction: column;
gap: 1rem;
.form-page .form {
background: var(--color-surface);
border-radius: 0 0 12px 12px;
padding: 1.25rem;
@@ -2421,7 +2429,13 @@ body.has-player .fab-new {
margin-left: auto;
}
/* Subtle text action. Resets the global <button> chrome so it looks the same
whether rendered as a <button> (modal/page forms) or a <Link> (DumpEdit). */
.form-cancel {
background: none;
border: none;
padding: 0;
cursor: pointer;
font-size: 0.9rem;
color: var(--color-text);
opacity: 0.6;
@@ -3189,30 +3203,6 @@ body.has-player .fab-new {
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-header {
@@ -3341,6 +3331,12 @@ body.has-player .fab-new {
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-textarea:focus {
border-color: var(--color-accent);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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