v3: code quality and visual consistency pass (refactored all forms across the app), fixed filename-related upload bug
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 47s

This commit is contained in:
khannurien
2026-06-26 22:20:07 +00:00
parent 26f5abfa4e
commit 73e0114bf7
30 changed files with 2369 additions and 2120 deletions

View File

@@ -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>
);
}