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
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 47s
This commit is contained in:
120
src/App.css
120
src/App.css
@@ -235,30 +235,31 @@
|
||||
}
|
||||
|
||||
/* ── Forms ── */
|
||||
.auth-form {
|
||||
|
||||
/* Unified form system: `.form` container + `.form-field` / `.form-label` /
|
||||
`.form-hint` building blocks, shared by every form. */
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.dump-form .form-group,
|
||||
.auth-form .form-group {
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.dump-form label,
|
||||
.auth-form label {
|
||||
.form-label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.dump-form input,
|
||||
.dump-form textarea,
|
||||
.auth-form input,
|
||||
.auth-form textarea {
|
||||
.form input,
|
||||
.form textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.65rem 1rem;
|
||||
border-radius: 8px;
|
||||
border: 2px solid var(--color-border);
|
||||
@@ -274,15 +275,21 @@
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dump-form textarea,
|
||||
.auth-form textarea {
|
||||
/* A submit/secondary button placed as a direct child of `.form` (the auth
|
||||
forms, which have no action row) spans the full width like the inputs.
|
||||
Buttons inside `.form-actions` keep their intrinsic width. */
|
||||
.form > .btn-primary,
|
||||
.form > .btn-secondary,
|
||||
.form > .btn-danger {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.dump-form input:hover,
|
||||
.dump-form textarea:hover,
|
||||
.auth-form input:hover,
|
||||
.auth-form textarea:hover {
|
||||
.form input:hover,
|
||||
.form textarea:hover {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--color-accent) 45%,
|
||||
@@ -290,10 +297,8 @@
|
||||
);
|
||||
}
|
||||
|
||||
.dump-form input:focus,
|
||||
.dump-form textarea:focus,
|
||||
.auth-form input:focus,
|
||||
.auth-form textarea:focus {
|
||||
.form input:focus,
|
||||
.form textarea:focus {
|
||||
border-color: var(--color-accent);
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
@@ -304,13 +309,27 @@
|
||||
color-mix(in srgb, var(--color-accent) 18%, transparent);
|
||||
}
|
||||
|
||||
.dump-form input:disabled,
|
||||
.dump-form textarea:disabled,
|
||||
.auth-form input:disabled,
|
||||
.auth-form textarea:disabled {
|
||||
.form input:disabled,
|
||||
.form textarea:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.form-hint--error {
|
||||
color: var(--color-error, #c0392b);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.form-success {
|
||||
color: var(--color-success, green);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dump-create-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2308,16 +2327,6 @@ body.has-player .fab-new {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.auth-field-hint {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.auth-field-hint--error {
|
||||
color: var(--color-error, #e53e3e);
|
||||
}
|
||||
|
||||
/* ── Form pages (DumpCreate / DumpEdit) ── */
|
||||
@keyframes page-enter {
|
||||
from {
|
||||
@@ -2348,7 +2357,9 @@ body.has-player .fab-new {
|
||||
.form-page--two-col .dump-edit-preview {
|
||||
border-radius: 0 0 0 12px;
|
||||
}
|
||||
.form-page--two-col .dump-form {
|
||||
/* Higher specificity than the generic `.form-page .form` below so the
|
||||
form's bottom-left stays square where it meets the preview. */
|
||||
.form-page.form-page--two-col .form {
|
||||
border-radius: 0 0 12px 0;
|
||||
}
|
||||
}
|
||||
@@ -2395,10 +2406,7 @@ body.has-player .fab-new {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.dump-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
.form-page .form {
|
||||
background: var(--color-surface);
|
||||
border-radius: 0 0 12px 12px;
|
||||
padding: 1.25rem;
|
||||
@@ -2421,7 +2429,13 @@ body.has-player .fab-new {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Subtle text action. Resets the global <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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
56
src/components/form/FileField.tsx
Normal file
56
src/components/form/FileField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
src/components/form/FormActions.tsx
Normal file
39
src/components/form/FormActions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
src/components/form/FormError.tsx
Normal file
16
src/components/form/FormError.tsx
Normal 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} />;
|
||||
}
|
||||
81
src/components/form/RichTextField.tsx
Normal file
81
src/components/form/RichTextField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
src/components/form/SegmentedField.tsx
Normal file
82
src/components/form/SegmentedField.tsx
Normal 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> },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
30
src/components/form/SubmitButton.tsx
Normal file
30
src/components/form/SubmitButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
90
src/components/form/TextField.tsx
Normal file
90
src/components/form/TextField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
src/components/form/index.ts
Normal file
9
src/components/form/index.ts
Normal 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";
|
||||
59
src/components/form/useApiForm.ts
Normal file
59
src/components/form/useApiForm.ts
Normal 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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user