mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-28 05:46:58 +02:00
Replace per-row controlClassName widths with a size enum (text / select-wide / select / code / none) on SettingsRow. Text-entry fields (name, slug, description, context, bio) now share the text tier so their control edges align within a card; deliberately-short fields (issue prefix) keep a clearly smaller tier. MUL-4428 Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
196 lines
6.6 KiB
TypeScript
196 lines
6.6 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { Input } from "@multica/ui/components/ui/input";
|
|
import { Textarea } from "@multica/ui/components/ui/textarea";
|
|
import { toast } from "sonner";
|
|
import { useAuthStore } from "@multica/core/auth";
|
|
import { api } from "@multica/core/api";
|
|
import { AvatarUploadControl } from "../../common/avatar-upload-control";
|
|
import { useT } from "../../i18n";
|
|
import {
|
|
SettingsCard,
|
|
SettingsRow,
|
|
SettingsSaveState,
|
|
SettingsSection,
|
|
SettingsTab,
|
|
} from "./settings-layout";
|
|
import { useAutoSave } from "./use-auto-save";
|
|
|
|
// Mirror server/internal/handler/auth.go:MaxProfileDescriptionLen. Counted in
|
|
// JS String.length (UTF-16 code units) here while the server counts runes,
|
|
// so a profile full of supplementary-plane emoji will trip the client cap
|
|
// before the server's — which is the safer direction of drift.
|
|
const MAX_PROFILE_DESCRIPTION_LEN = 2000;
|
|
|
|
interface ProfileDraft {
|
|
name: string;
|
|
profileDescription: string;
|
|
}
|
|
|
|
function profilesEqual(left: ProfileDraft, right: ProfileDraft) {
|
|
return left.name === right.name && left.profileDescription === right.profileDescription;
|
|
}
|
|
|
|
export function AccountTab() {
|
|
const { t } = useT("settings");
|
|
const user = useAuthStore((s) => s.user);
|
|
const setUser = useAuthStore((s) => s.setUser);
|
|
|
|
const [profileName, setProfileName] = useState(user?.name ?? "");
|
|
const [profileDescription, setProfileDescription] = useState(
|
|
user?.profile_description ?? "",
|
|
);
|
|
|
|
useEffect(() => {
|
|
setProfileName(user?.name ?? "");
|
|
setProfileDescription(user?.profile_description ?? "");
|
|
// Preserve in-progress edits when an avatar upload or auto-save replaces
|
|
// the current user object in the auth store.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally keyed on user identity
|
|
}, [user?.id]);
|
|
|
|
const descriptionTooLong = profileDescription.length > MAX_PROFILE_DESCRIPTION_LEN;
|
|
|
|
const draft = useMemo(
|
|
() => ({ name: profileName, profileDescription }),
|
|
[profileDescription, profileName],
|
|
);
|
|
const savedDraft = useMemo(
|
|
() => ({
|
|
name: user?.name ?? "",
|
|
profileDescription: user?.profile_description ?? "",
|
|
}),
|
|
[user?.name, user?.profile_description],
|
|
);
|
|
const saveProfile = useCallback(
|
|
async (next: ProfileDraft) => {
|
|
const updated = await api.updateMe({
|
|
name: next.name,
|
|
profile_description: next.profileDescription,
|
|
});
|
|
setUser(updated);
|
|
},
|
|
[setUser],
|
|
);
|
|
const autoSave = useAutoSave({
|
|
value: draft,
|
|
savedValue: savedDraft,
|
|
onSave: saveProfile,
|
|
onSuccess: () =>
|
|
toast.success(t(($) => $.account.toast_profile_updated), {
|
|
id: "settings-auto-save",
|
|
}),
|
|
onError: (error) =>
|
|
toast.error(
|
|
error instanceof Error
|
|
? error.message
|
|
: t(($) => $.account.toast_profile_failed),
|
|
),
|
|
enabled: !!user && !!profileName.trim() && !descriptionTooLong,
|
|
isEqual: profilesEqual,
|
|
});
|
|
|
|
return (
|
|
<SettingsTab title={t(($) => $.page.tabs.profile)}>
|
|
<SettingsSection
|
|
title={t(($) => $.account.section_profile)}
|
|
action={
|
|
<SettingsSaveState
|
|
status={autoSave.status}
|
|
savingLabel={t(($) => $.auto_save.saving)}
|
|
savedLabel={t(($) => $.auto_save.saved)}
|
|
errorLabel={t(($) => $.auto_save.failed)}
|
|
/>
|
|
}
|
|
>
|
|
<SettingsCard>
|
|
<SettingsRow
|
|
label={t(($) => $.account.avatar_label)}
|
|
description={t(($) => $.account.click_avatar_hint)}
|
|
size="none"
|
|
>
|
|
<div className="flex justify-start sm:justify-end">
|
|
<AvatarUploadControl
|
|
variant="user"
|
|
value={user?.avatar_url ?? null}
|
|
name={user?.name ?? ""}
|
|
size={64}
|
|
onUploaded={async (url) => {
|
|
try {
|
|
const updated = await api.updateMe({ avatar_url: url });
|
|
setUser(updated);
|
|
toast.success(t(($) => $.account.toast_avatar_updated), {
|
|
id: "settings-auto-save",
|
|
});
|
|
} catch (error) {
|
|
toast.error(
|
|
error instanceof Error
|
|
? error.message
|
|
: t(($) => $.account.toast_avatar_failed),
|
|
);
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
</SettingsRow>
|
|
|
|
<SettingsRow
|
|
label={t(($) => $.account.name_label)}
|
|
size="text"
|
|
>
|
|
<Input
|
|
type="text"
|
|
name="profile-name"
|
|
autoComplete="name"
|
|
aria-label={t(($) => $.account.name_label)}
|
|
value={profileName}
|
|
onChange={(event) => setProfileName(event.target.value)}
|
|
onBlur={autoSave.flush}
|
|
/>
|
|
</SettingsRow>
|
|
|
|
<SettingsRow
|
|
label={t(($) => $.account.profile_description_label)}
|
|
description={t(($) => $.account.profile_description_hint)}
|
|
size="text"
|
|
align="start"
|
|
>
|
|
<div>
|
|
<Textarea
|
|
name="profile-description"
|
|
autoComplete="off"
|
|
aria-label={t(($) => $.account.profile_description_label)}
|
|
value={profileDescription}
|
|
onChange={(event) => setProfileDescription(event.target.value)}
|
|
onBlur={autoSave.flush}
|
|
placeholder={t(($) => $.account.profile_description_placeholder)}
|
|
rows={5}
|
|
maxLength={MAX_PROFILE_DESCRIPTION_LEN}
|
|
aria-invalid={descriptionTooLong}
|
|
className="resize-y"
|
|
/>
|
|
<div className="mt-1 flex justify-end text-xs text-muted-foreground">
|
|
<span
|
|
className={descriptionTooLong ? "text-destructive shrink-0" : "shrink-0"}
|
|
aria-live="polite"
|
|
>
|
|
{profileDescription.length}/{MAX_PROFILE_DESCRIPTION_LEN}
|
|
</span>
|
|
</div>
|
|
{descriptionTooLong ? (
|
|
<p className="mt-1 text-xs text-destructive">
|
|
{t(($) => $.account.profile_description_too_long, {
|
|
max: MAX_PROFILE_DESCRIPTION_LEN,
|
|
count: profileDescription.length,
|
|
})}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</SettingsRow>
|
|
</SettingsCard>
|
|
</SettingsSection>
|
|
</SettingsTab>
|
|
);
|
|
}
|