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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user