feat(web): профиль с аватаром, панель в углу, настройки сервера, скрытые ID
Правки пользователя: - в «Профиль → Основной» сверху квадратный аватар слева, справа от него настройки имени, «О себе», состояния и эмодзи во всю высоту; смена аватара — клик по нему, форматы (JPEG, PNG, WebP, GIF до 8 МБ) — в подсказке; - убраны дубли: часовой пояс только в «Язык и регион», статус — только в мини-профиле панели; - панель пользователя переехала в нижний левый угол под сайдбар, подсказка о выходе из сервера — на её прежнее место; панель ужата под 240 px; - шестерёнка сервера открывает новый пункт настроек «Сервер» (список серверов, общее, участники, роли, опасная зона), а «Профиль → Сервер» оставлен только для ника и «Применить ко всем серверам»; - окно настроек получило статический размер 70% рабочей области со скроллом; - ID пользователей скрыты везде, кроме раздела «Инстанс» у администратора; - список друзей свёрнут под кнопкой «Друзья (N)» и раскрывается снизу вверх; - 249 тестов Vitest (было 239), check/lint/test/build зелёные.
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { GuildSummary } from '@/api/types';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
import { Menu, MenuGroupLabel, MenuItem } from '@/components/ui/Menu';
|
||||
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
interface GuildPickerProps {
|
||||
/** Серверы, доступные пользователю (снапшот шлюза или REST-ответ). */
|
||||
guilds: GuildSummary[];
|
||||
/** Список ещё едет: показываем скелетон вместо «не найдено». */
|
||||
isPending?: boolean;
|
||||
onSelect: (guildId: string) => void;
|
||||
/** Подпись выпадающего списка (у админского пункта она своя). */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Выпадающий список серверов с иконкой и именем: общий элемент пункта
|
||||
* «Сервер» и вкладки «Профиль → Сервер». Выбранный сервер живёт в сторе
|
||||
* сессии, поэтому компонент только сообщает о смене через `onSelect`.
|
||||
*/
|
||||
export function GuildPicker({ guilds, isPending = false, onSelect, label }: GuildPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const selectedGuildId = useSessionStore((state) => state.settingsGuildId);
|
||||
const [open, setOpen] = useState(false);
|
||||
const anchorRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
const pickerLabel = label ?? t('settings.servers.pickerLabel');
|
||||
const active = guilds.find((guild) => guild.id === selectedGuildId) ?? null;
|
||||
|
||||
if (isPending) {
|
||||
return <SkeletonLines rows={2} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={anchorRef}
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-label={t('settings.servers.selectLabel')}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className="flex w-full items-center gap-2 rounded-[var(--radius-md)] border border-border/60 bg-surface-1 px-3 py-2 text-left text-sm transition-colors duration-[var(--duration-fast)] hover:bg-surface-2"
|
||||
>
|
||||
{active === null ? null : (
|
||||
<Avatar
|
||||
name={active.name}
|
||||
seed={active.id}
|
||||
fileId={active.icon_file_id}
|
||||
size="sm"
|
||||
shape="square"
|
||||
/>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate font-medium">
|
||||
{active?.name ?? t('settings.servers.selectLabel')}
|
||||
</span>
|
||||
<span aria-hidden="true" className="text-fg-muted">
|
||||
▾
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Menu
|
||||
open={open}
|
||||
label={pickerLabel}
|
||||
anchorRef={anchorRef}
|
||||
placement="bottom"
|
||||
onClose={() => setOpen(false)}
|
||||
>
|
||||
<MenuGroupLabel>{pickerLabel}</MenuGroupLabel>
|
||||
{guilds.map((guild) => (
|
||||
<MenuItem
|
||||
key={guild.id}
|
||||
role="menuitemradio"
|
||||
selected={guild.id === active?.id}
|
||||
alwaysShowIcon
|
||||
icon={
|
||||
<Avatar
|
||||
name={guild.name}
|
||||
seed={guild.id}
|
||||
fileId={guild.icon_file_id}
|
||||
size="sm"
|
||||
shape="square"
|
||||
/>
|
||||
}
|
||||
onClick={() => {
|
||||
onSelect(guild.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{guild.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { fetchGuild, fetchMembers, guildMembersQueryKey, guildQueryKey } from '@/api/guilds';
|
||||
import { fetchMyGuilds, myGuildsQueryKey } from '@/api/users';
|
||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||
import { Card } from '@/components/ui/primitives';
|
||||
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||
import { canAdministerGuild } from '@/lib/identity';
|
||||
import { useCurrentUser } from '@/lib/hooks';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { GuildDangerSection } from '@/pages/settings/GuildDangerSection';
|
||||
import { GuildGeneralSection } from '@/pages/settings/GuildGeneralSection';
|
||||
import { GuildMembersSection } from '@/pages/settings/GuildMembersSection';
|
||||
import { GuildPicker } from '@/pages/settings/GuildPicker';
|
||||
import { GuildRolesSection } from '@/pages/settings/GuildRolesSection';
|
||||
|
||||
/**
|
||||
* Пункт «Сервер» окна настроек: администрирование одного сервера —
|
||||
* общее (имя и описание), участники, роли и «Опасная зона».
|
||||
*
|
||||
* В отличие от вкладки «Профиль → Сервер» (там только свой никнейм), сюда
|
||||
* попадают через шестерёнку в шапке сервера или через пункт меню настроек.
|
||||
* Сервер можно переключить тем же выпадающим списком.
|
||||
*/
|
||||
export function GuildSettingsPage() {
|
||||
const sessionGuilds = useSessionStore((state) => state.guilds);
|
||||
const upsertGuild = useSessionStore((state) => state.upsertGuild);
|
||||
const selectSettingsGuild = useSessionStore((state) => state.selectSettingsGuild);
|
||||
|
||||
// Подстраховка снапшота: без неё выбранный сервер не найти в сторе.
|
||||
const myGuilds = useQuery({
|
||||
queryKey: myGuildsQueryKey,
|
||||
queryFn: ({ signal }) => fetchMyGuilds(signal),
|
||||
retry: 0,
|
||||
enabled: sessionGuilds.length === 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionGuilds.length > 0 || myGuilds.data === undefined) {
|
||||
return;
|
||||
}
|
||||
for (const guild of myGuilds.data) {
|
||||
upsertGuild(guild);
|
||||
}
|
||||
}, [sessionGuilds.length, myGuilds.data, upsertGuild]);
|
||||
|
||||
const guilds = useMemo(
|
||||
() => (sessionGuilds.length > 0 ? sessionGuilds : (myGuilds.data ?? [])),
|
||||
[sessionGuilds, myGuilds.data],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<GuildPicker
|
||||
guilds={guilds}
|
||||
isPending={myGuilds.isPending && guilds.length === 0}
|
||||
onSelect={selectSettingsGuild}
|
||||
/>
|
||||
|
||||
<GuildSettingsBody guilds={guilds} />
|
||||
{myGuilds.isError ? (
|
||||
<Card>
|
||||
<ErrorNotice error={myGuilds.error} onRetry={() => void myGuilds.refetch()} />
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Секции выбранного сервера: данные тянутся по REST только для него. */
|
||||
function GuildSettingsBody({ guilds }: { guilds: { id: string }[] }) {
|
||||
const { t } = useTranslation();
|
||||
const currentUser = useCurrentUser();
|
||||
const selectedGuildId = useSessionStore((state) => state.settingsGuildId);
|
||||
const selectSettingsGuild = useSessionStore((state) => state.selectSettingsGuild);
|
||||
|
||||
const active = useMemo(
|
||||
() => guilds.find((guild) => guild.id === selectedGuildId) ?? null,
|
||||
[guilds, selectedGuildId],
|
||||
);
|
||||
|
||||
// Первый доступный сервер выбираем сами: список только что загрузился.
|
||||
useEffect(() => {
|
||||
if (active !== null || guilds.length === 0) {
|
||||
return;
|
||||
}
|
||||
const first = guilds[0];
|
||||
if (first !== undefined) {
|
||||
selectSettingsGuild(first.id);
|
||||
}
|
||||
}, [active, guilds, selectSettingsGuild]);
|
||||
|
||||
const activeId = active?.id ?? null;
|
||||
|
||||
const detail = useQuery({
|
||||
queryKey: guildQueryKey(activeId ?? ''),
|
||||
queryFn: ({ signal }) => fetchGuild(activeId ?? '', signal),
|
||||
enabled: activeId !== null,
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
const members = useQuery({
|
||||
queryKey: guildMembersQueryKey(activeId ?? ''),
|
||||
queryFn: ({ signal }) => fetchMembers(activeId ?? '', signal),
|
||||
enabled: activeId !== null,
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
if (activeId === null) {
|
||||
return (
|
||||
<Card>
|
||||
<p className="text-sm text-fg-muted">{t('settings.servers.notFound')}</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (detail.isPending) {
|
||||
return (
|
||||
<Card>
|
||||
<SkeletonLines rows={5} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const guild = detail.data;
|
||||
if (guild === undefined) {
|
||||
return (
|
||||
<Card>
|
||||
<ErrorNotice error={detail.error} onRetry={() => void detail.refetch()} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const canManage = canAdministerGuild(
|
||||
guild.my_permissions,
|
||||
guild.owner_id,
|
||||
currentUser.data?.id,
|
||||
currentUser.data?.is_instance_admin === true,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<GuildGeneralSection key={guild.id} guild={guild} canEdit={canManage} />
|
||||
<GuildMembersSection
|
||||
members={members.data}
|
||||
isPending={members.isPending}
|
||||
error={members.error}
|
||||
onRetry={() => void members.refetch()}
|
||||
/>
|
||||
<GuildRolesSection key={`${guild.id}-roles`} guildId={guild.id} />
|
||||
<GuildDangerSection
|
||||
key={`${guild.id}-danger`}
|
||||
guildId={guild.id}
|
||||
guildName={guild.name}
|
||||
isOwner={currentUser.data?.id === guild.owner_id}
|
||||
isInstanceAdmin={currentUser.data?.is_instance_admin === true}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default GuildSettingsPage;
|
||||
@@ -136,6 +136,9 @@ export default function InstanceSettingsPage() {
|
||||
<h3 className="text-base font-semibold">
|
||||
{t('settings.instance.users', { count: users.data?.length ?? 0 })}
|
||||
</h3>
|
||||
{/* ID пользователей — единственное место, где он виден: раздел открыт
|
||||
только администратору инстанса. */}
|
||||
<p className="mt-1 text-xs text-fg-muted">{t('settings.instance.usersIdHint')}</p>
|
||||
{users.isPending ? <LoadingNotice /> : null}
|
||||
{users.isError ? (
|
||||
<ErrorNotice className="mt-3" error={users.error} onRetry={() => void users.refetch()} />
|
||||
@@ -150,9 +153,16 @@ export default function InstanceSettingsPage() {
|
||||
key={user.id}
|
||||
className="flex items-center justify-between gap-3 border-b border-border/40 py-2 last:border-b-0"
|
||||
>
|
||||
<span className="truncate">
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{user.display_name} <span className="text-fg-muted">@{user.username}</span>
|
||||
</span>
|
||||
<span
|
||||
className="shrink-0 font-mono text-xs text-fg-muted"
|
||||
data-testid={`instance-user-id-${user.id}`}
|
||||
title={t('settings.instance.userId')}
|
||||
>
|
||||
{user.id}
|
||||
</span>
|
||||
{user.is_instance_admin ? (
|
||||
<span className="shrink-0 rounded-full border border-accent/50 px-2 text-xs text-accent">
|
||||
{t('settings.instance.adminBadge')}
|
||||
|
||||
+22
-131
@@ -1,39 +1,28 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
fetchGuild,
|
||||
fetchMembers,
|
||||
guildMembersQueryKey,
|
||||
guildQueryKey,
|
||||
updateGuildMember,
|
||||
} from '@/api/guilds';
|
||||
import { fetchMembers, guildMembersQueryKey, updateGuildMember } from '@/api/guilds';
|
||||
import { fetchMyGuilds, myGuildsQueryKey } from '@/api/users';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||
import { Menu, MenuGroupLabel, MenuItem } from '@/components/ui/Menu';
|
||||
import { Card } from '@/components/ui/primitives';
|
||||
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||
import { canManageGuildSettings } from '@/lib/identity';
|
||||
import { useCurrentUser } from '@/lib/hooks';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { GuildDangerSection } from '@/pages/settings/GuildDangerSection';
|
||||
import { GuildGeneralSection } from '@/pages/settings/GuildGeneralSection';
|
||||
import { GuildMembersSection } from '@/pages/settings/GuildMembersSection';
|
||||
import { GuildNicknameSection } from '@/pages/settings/GuildNicknameSection';
|
||||
import { GuildRolesSection } from '@/pages/settings/GuildRolesSection';
|
||||
import { GuildPicker } from '@/pages/settings/GuildPicker';
|
||||
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
||||
|
||||
/**
|
||||
* Вкладка «Профиль → Сервер»: мой профиль на конкретном сервере и (для
|
||||
* владельца и прав управления) администрирование сервера.
|
||||
* Вкладка «Профиль → Сервер»: только личный ник на конкретном сервере и
|
||||
* кнопка «Применить ко всем серверам». Администрирование сервера живёт в
|
||||
* отдельном пункте «Сервер» (см. `GuildSettingsPage`) — так эта вкладка
|
||||
* доступна и обычному участнику.
|
||||
*
|
||||
* Список серверов берём из READY-снапшота, а если шлюз ещё не подключился —
|
||||
* из `GET /users/@me/guilds`. Выбранный сервер лежит в сторе сессии: его же
|
||||
* сбрасывает «Опасная зона» после выхода с сервера.
|
||||
*/
|
||||
export function ServersSettingsPage() {
|
||||
export function ProfileServerTab() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const currentUser = useCurrentUser();
|
||||
@@ -83,16 +72,6 @@ export function ServersSettingsPage() {
|
||||
|
||||
const activeId = active?.id ?? null;
|
||||
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const pickerRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
const detail = useQuery({
|
||||
queryKey: guildQueryKey(activeId ?? ''),
|
||||
queryFn: ({ signal }) => fetchGuild(activeId ?? '', signal),
|
||||
enabled: activeId !== null,
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
const members = useQuery({
|
||||
queryKey: guildMembersQueryKey(activeId ?? ''),
|
||||
queryFn: ({ signal }) => fetchMembers(activeId ?? '', signal),
|
||||
@@ -129,11 +108,7 @@ export function ServersSettingsPage() {
|
||||
onSettled: () => setProgress(null),
|
||||
});
|
||||
|
||||
const guild = detail.data;
|
||||
const sessionGuild = sessionGuilds.find((item) => item.id === activeId) ?? null;
|
||||
const myPermissions = guild?.my_permissions ?? active?.my_permissions ?? [];
|
||||
const ownerId = guild?.owner_id ?? active?.owner_id ?? '';
|
||||
const canManage = canManageGuildSettings(myPermissions, ownerId, currentUser.data?.id);
|
||||
const currentNickname = useMemo(() => {
|
||||
const fromSnapshot = sessionGuild?.my_nickname;
|
||||
if (fromSnapshot !== undefined && fromSnapshot !== '') {
|
||||
@@ -168,112 +143,28 @@ export function ServersSettingsPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={pickerRef}
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={pickerOpen}
|
||||
aria-label={t('settings.servers.selectLabel')}
|
||||
onClick={() => setPickerOpen((value) => !value)}
|
||||
className="flex w-full items-center gap-2 rounded-[var(--radius-md)] border border-border/60 bg-surface-1 px-3 py-2 text-left text-sm transition-colors duration-[var(--duration-fast)] hover:bg-surface-2"
|
||||
>
|
||||
{active === null ? null : (
|
||||
<Avatar
|
||||
name={active.name}
|
||||
seed={active.id}
|
||||
fileId={active.icon_file_id}
|
||||
size="sm"
|
||||
shape="square"
|
||||
/>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate font-medium">
|
||||
{active?.name ?? t('settings.servers.selectLabel')}
|
||||
</span>
|
||||
<span aria-hidden="true" className="text-fg-muted">
|
||||
▾
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Menu
|
||||
open={pickerOpen}
|
||||
label={t('settings.servers.pickerLabel')}
|
||||
anchorRef={pickerRef}
|
||||
placement="bottom"
|
||||
onClose={() => setPickerOpen(false)}
|
||||
>
|
||||
<MenuGroupLabel>{t('settings.servers.pickerLabel')}</MenuGroupLabel>
|
||||
{guilds.map((item) => (
|
||||
<MenuItem
|
||||
key={item.id}
|
||||
role="menuitemradio"
|
||||
selected={item.id === activeId}
|
||||
alwaysShowIcon
|
||||
icon={
|
||||
<Avatar
|
||||
name={item.name}
|
||||
seed={item.id}
|
||||
fileId={item.icon_file_id}
|
||||
size="sm"
|
||||
shape="square"
|
||||
/>
|
||||
}
|
||||
onClick={() => {
|
||||
selectSettingsGuild(item.id);
|
||||
setPickerOpen(false);
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</div>
|
||||
<GuildPicker guilds={guilds} onSelect={selectSettingsGuild} />
|
||||
|
||||
{activeId === null ? (
|
||||
<Card>
|
||||
<p className="text-sm text-fg-muted">{t('settings.servers.notFound')}</p>
|
||||
</Card>
|
||||
) : detail.isPending ? (
|
||||
<Card>
|
||||
<SkeletonLines rows={5} />
|
||||
</Card>
|
||||
) : guild === undefined ? (
|
||||
<Card>
|
||||
<ErrorNotice error={detail.error} onRetry={() => void detail.refetch()} />
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<GuildNicknameSection
|
||||
guildId={guild.id}
|
||||
userId={userId}
|
||||
currentNickname={currentNickname}
|
||||
members={members.data}
|
||||
applyAll={{
|
||||
pending: applyAll.isPending,
|
||||
progress,
|
||||
result: applyResult,
|
||||
run: onApplyAll,
|
||||
}}
|
||||
/>
|
||||
<GuildGeneralSection key={guild.id} guild={guild} canEdit={canManage} />
|
||||
<GuildMembersSection
|
||||
members={members.data}
|
||||
isPending={members.isPending}
|
||||
error={members.error}
|
||||
onRetry={() => void members.refetch()}
|
||||
/>
|
||||
<GuildRolesSection key={`${guild.id}-roles`} guildId={guild.id} />
|
||||
<GuildDangerSection
|
||||
key={`${guild.id}-danger`}
|
||||
guildId={guild.id}
|
||||
guildName={guild.name}
|
||||
isOwner={userId !== undefined && userId === guild.owner_id}
|
||||
isInstanceAdmin={currentUser.data?.is_instance_admin === true}
|
||||
/>
|
||||
</>
|
||||
<GuildNicknameSection
|
||||
guildId={activeId}
|
||||
userId={userId}
|
||||
currentNickname={currentNickname}
|
||||
members={members.data}
|
||||
applyAll={{
|
||||
pending: applyAll.isPending,
|
||||
progress,
|
||||
result: applyResult,
|
||||
run: onApplyAll,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ServersSettingsPage;
|
||||
export default ProfileServerTab;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState, type ChangeEvent, type FormEvent } from 'react';
|
||||
import { useCallback, useRef, useState, type ChangeEvent, type FormEvent } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -10,16 +10,14 @@ import {
|
||||
currentUserQueryKey,
|
||||
type UpdateProfileInput,
|
||||
} from '@/api/users';
|
||||
import { userStatuses, type User } from '@/api/types';
|
||||
import type { User } from '@/api/types';
|
||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||
import { Field, SelectField, TextAreaField } from '@/components/ui/Field';
|
||||
import { Field, TextAreaField } from '@/components/ui/Field';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
import { Badge, Button, Card } from '@/components/ui/primitives';
|
||||
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||
import { supportedLanguages, type SupportedLanguage } from '@/i18n';
|
||||
import { formatBytes } from '@/lib/format';
|
||||
import { useCurrentUser } from '@/lib/hooks';
|
||||
import { DEFAULT_TIMEZONE, timezoneChoices, timezoneLabel } from '@/lib/timezones';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
||||
|
||||
@@ -73,6 +71,11 @@ function useAvatarPreview() {
|
||||
* Форма профиля. Состояние инициализируется из `user` при монтировании,
|
||||
* а после сохранения кэш React Query обновляется ответом сервера — поэтому
|
||||
* синхронизация через эффект не нужна.
|
||||
*
|
||||
* Сверху формы — крупный аватар слева и поля справа на его высоту: имя,
|
||||
* «О себе» и своё состояние с эмодзи. Статус присутствия и часовой пояс здесь
|
||||
* намеренно отсутствуют (статус меняется в мини-профиле панели, часовой пояс —
|
||||
* в разделе «Язык и регион»), чтобы настройки не дублировались.
|
||||
*/
|
||||
export function ProfileForm({ user }: { user: User }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
@@ -83,22 +86,15 @@ export function ProfileForm({ user }: { user: User }) {
|
||||
const [bio, setBio] = useState(user.bio);
|
||||
const [customStatus, setCustomStatus] = useState(user.custom_status);
|
||||
const [customEmoji, setCustomEmoji] = useState(user.custom_status_emoji);
|
||||
const [status, setStatus] = useState<string>(user.status);
|
||||
const [tz, setTz] = useState(user.timezone ?? DEFAULT_TIMEZONE);
|
||||
const [language, setLanguage] = useState<SupportedLanguage>(user.locale === 'en' ? 'en' : 'ru');
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [avatarError, setAvatarError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const avatar = useAvatarPreview();
|
||||
|
||||
/** Профиль из ответа сервера: кэш, снапшот сессии и язык интерфейса. */
|
||||
/** Профиль из ответа сервера: кэш и снапшот сессии. */
|
||||
const applyUser = (updated: User): void => {
|
||||
queryClient.setQueryData(currentUserQueryKey, updated);
|
||||
useSessionStore.getState().patchUser(updated);
|
||||
if (updated.locale === 'en' || updated.locale === 'ru') {
|
||||
window.localStorage.setItem('glchat.language', updated.locale);
|
||||
void i18n.changeLanguage(updated.locale);
|
||||
}
|
||||
};
|
||||
|
||||
const save = useMutation({
|
||||
@@ -154,21 +150,29 @@ export function ProfileForm({ user }: { user: User }) {
|
||||
upload.mutate(file);
|
||||
};
|
||||
|
||||
const openFilePicker = useCallback(() => {
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const onSubmit = (event: FormEvent<HTMLFormElement>): void => {
|
||||
event.preventDefault();
|
||||
setSaved(false);
|
||||
// Часовой пояс и статус присутствия форма не трогает: у них свои места.
|
||||
save.mutate({
|
||||
display_name: displayName.trim(),
|
||||
bio: bio.trim(),
|
||||
custom_status: customStatus.trim(),
|
||||
custom_status_emoji: customEmoji.trim(),
|
||||
status,
|
||||
locale: language,
|
||||
timezone: tz,
|
||||
});
|
||||
};
|
||||
|
||||
const avatarName = displayName === '' ? user.username : displayName;
|
||||
const avatarBusy = upload.isPending || removeAvatar.isPending;
|
||||
// Форматы и лимит живут в подсказке к аватару, а не в тексте формы.
|
||||
const avatarHint = t('settings.profile.avatar.limit', {
|
||||
formats: t('settings.profile.avatar.formats'),
|
||||
limit: formatBytes(AVATAR_MAX_BYTES, locale),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
@@ -177,81 +181,105 @@ export function ProfileForm({ user }: { user: User }) {
|
||||
<p className="mt-1 text-sm text-fg-muted">{t('settings.profile.description')}</p>
|
||||
|
||||
<form className="mt-4 flex flex-col gap-4" onSubmit={onSubmit} noValidate>
|
||||
<Field
|
||||
label={t('settings.profile.displayName')}
|
||||
name="display_name"
|
||||
value={displayName}
|
||||
maxLength={64}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
/>
|
||||
<TextAreaField
|
||||
label={t('settings.profile.bio')}
|
||||
placeholder={t('settings.profile.bioPlaceholder')}
|
||||
name="bio"
|
||||
rows={4}
|
||||
maxLength={512}
|
||||
value={bio}
|
||||
onChange={(event) => setBio(event.target.value)}
|
||||
/>
|
||||
<div className="grid gap-4 sm:grid-cols-[1fr_6rem]">
|
||||
<Field
|
||||
label={t('settings.profile.customStatus')}
|
||||
placeholder={t('settings.profile.customStatusPlaceholder')}
|
||||
name="custom_status"
|
||||
maxLength={128}
|
||||
value={customStatus}
|
||||
onChange={(event) => setCustomStatus(event.target.value)}
|
||||
/>
|
||||
<Field
|
||||
label={t('settings.profile.customStatusEmoji')}
|
||||
placeholder={t('settings.profile.customStatusEmojiPlaceholder')}
|
||||
name="custom_status_emoji"
|
||||
maxLength={8}
|
||||
value={customEmoji}
|
||||
onChange={(event) => setCustomEmoji(event.target.value)}
|
||||
/>
|
||||
{/* Аватар слева, поля справа — на его высоту. */}
|
||||
<div className="grid gap-4 sm:grid-cols-[auto_minmax(0,1fr)]">
|
||||
<div className="min-h-36 sm:min-h-40">
|
||||
<div className="group relative h-full w-36 sm:w-40" data-testid="profile-avatar">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
aria-label={t('settings.profile.avatar.choose')}
|
||||
className="sr-only"
|
||||
onChange={onPickFile}
|
||||
/>
|
||||
<Avatar
|
||||
name={avatarName}
|
||||
seed={user.id}
|
||||
fileId={user.avatar_file_id}
|
||||
preview={avatar.preview ?? undefined}
|
||||
size="xl"
|
||||
shape="square"
|
||||
/>
|
||||
{/*
|
||||
Клик по аватару открывает выбор файла, поэтому кнопка лежит
|
||||
поверх картинки; «Удалить» — отдельная кнопка рядом.
|
||||
*/}
|
||||
<button
|
||||
type="button"
|
||||
disabled={avatarBusy}
|
||||
onClick={openFilePicker}
|
||||
title={avatarHint}
|
||||
aria-label={`${t('settings.profile.avatar.change')}. ${avatarHint}`}
|
||||
className="absolute inset-0 z-10 flex items-center justify-center rounded-[var(--radius-lg)] bg-black/60 px-2 text-center text-xs font-medium text-white opacity-0 transition-opacity duration-[var(--duration-fast)] focus-visible:opacity-100 group-hover:opacity-100 disabled:cursor-not-allowed"
|
||||
>
|
||||
{t(
|
||||
upload.isPending
|
||||
? 'settings.profile.avatar.uploading'
|
||||
: 'settings.profile.avatar.change',
|
||||
)}
|
||||
</button>
|
||||
{user.avatar_file_id === undefined ? null : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute bottom-1 left-1 z-20 px-2 py-0.5 text-xs"
|
||||
disabled={avatarBusy}
|
||||
aria-label={t('settings.profile.avatar.remove')}
|
||||
onClick={() => removeAvatar.mutate()}
|
||||
>
|
||||
{t('settings.profile.avatar.remove')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-4">
|
||||
<Field
|
||||
label={t('settings.profile.displayName')}
|
||||
name="display_name"
|
||||
value={displayName}
|
||||
maxLength={64}
|
||||
disabled={save.isPending}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
/>
|
||||
<TextAreaField
|
||||
label={t('settings.profile.bio')}
|
||||
placeholder={t('settings.profile.bioPlaceholder')}
|
||||
name="bio"
|
||||
rows={3}
|
||||
maxLength={512}
|
||||
value={bio}
|
||||
disabled={save.isPending}
|
||||
onChange={(event) => setBio(event.target.value)}
|
||||
/>
|
||||
<div className="grid gap-4 sm:grid-cols-[minmax(0,1fr)_6rem]">
|
||||
<Field
|
||||
label={t('settings.profile.customStatus')}
|
||||
placeholder={t('settings.profile.customStatusPlaceholder')}
|
||||
name="custom_status"
|
||||
maxLength={128}
|
||||
value={customStatus}
|
||||
disabled={save.isPending}
|
||||
onChange={(event) => setCustomStatus(event.target.value)}
|
||||
/>
|
||||
<Field
|
||||
label={t('settings.profile.customStatusEmoji')}
|
||||
placeholder={t('settings.profile.customStatusEmojiPlaceholder')}
|
||||
name="custom_status_emoji"
|
||||
maxLength={8}
|
||||
value={customEmoji}
|
||||
disabled={save.isPending}
|
||||
onChange={(event) => setCustomEmoji(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SelectField
|
||||
label={t('settings.profile.status')}
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
options={userStatuses.map((value) => ({
|
||||
value,
|
||||
label: t(`user.status.${value}`),
|
||||
}))}
|
||||
/>
|
||||
<SelectField
|
||||
label={t('settings.profile.timezone')}
|
||||
hint={t('settings.profile.timezoneHint')}
|
||||
value={tz}
|
||||
onChange={setTz}
|
||||
options={timezoneChoices(tz).map((zone) => ({
|
||||
value: zone,
|
||||
label:
|
||||
zone === DEFAULT_TIMEZONE
|
||||
? `${timezoneLabel(zone, locale)} · ${t('settings.profile.timezoneDefault')}`
|
||||
: timezoneLabel(zone, locale),
|
||||
}))}
|
||||
/>
|
||||
<SelectField
|
||||
label={t('settings.profile.locale')}
|
||||
value={language}
|
||||
onChange={(value) => setLanguage(value === 'en' ? 'en' : 'ru')}
|
||||
options={supportedLanguages.map((value) => ({
|
||||
value,
|
||||
label: t(`language.${value}`),
|
||||
}))}
|
||||
/>
|
||||
|
||||
<dl className="text-sm text-fg-muted">
|
||||
<div className="flex justify-between gap-4 border-b border-border/40 py-2">
|
||||
<dt>{t('settings.profile.username')}</dt>
|
||||
<dd className="font-medium text-fg">{user.username}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4 border-b border-border/40 py-2">
|
||||
<dt>{t('settings.profile.userId')}</dt>
|
||||
<dd className="font-mono text-xs text-fg">{user.id}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4 py-2">
|
||||
<dt>{t('settings.profile.badges')}</dt>
|
||||
<dd className="text-fg">
|
||||
@@ -260,6 +288,13 @@ export function ProfileForm({ user }: { user: User }) {
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{avatarError === null ? null : (
|
||||
<p role="alert" className="text-sm text-danger">
|
||||
{avatarError}
|
||||
</p>
|
||||
)}
|
||||
{upload.isError ? <ErrorNotice error={upload.error} /> : null}
|
||||
{removeAvatar.isError ? <ErrorNotice error={removeAvatar.error} /> : null}
|
||||
{save.isError ? <ErrorNotice error={save.error} /> : null}
|
||||
{saved && !save.isError ? (
|
||||
<p className="text-sm text-success" role="status">
|
||||
@@ -275,62 +310,6 @@ export function ProfileForm({ user }: { user: User }) {
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.profile.avatar.title')}
|
||||
description={t('settings.profile.avatar.hint')}
|
||||
>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-4">
|
||||
{avatar.preview === null ? (
|
||||
<Avatar name={avatarName} seed={user.id} fileId={user.avatar_file_id} size="lg" />
|
||||
) : (
|
||||
<img
|
||||
src={avatar.preview}
|
||||
alt={t('settings.profile.avatar.preview')}
|
||||
className="h-14 w-14 rounded-full object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
aria-label={t('settings.profile.avatar.choose')}
|
||||
className="sr-only"
|
||||
onChange={onPickFile}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={upload.isPending}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{t(upload.isPending ? 'common.saving' : 'settings.profile.avatar.upload')}
|
||||
</Button>
|
||||
{user.avatar_file_id === undefined ? null : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={removeAvatar.isPending}
|
||||
onClick={() => removeAvatar.mutate()}
|
||||
>
|
||||
{t('settings.profile.avatar.remove')}
|
||||
</Button>
|
||||
)}
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t('settings.profile.avatar.limit', {
|
||||
limit: formatBytes(AVATAR_MAX_BYTES, locale),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{avatarError === null ? null : (
|
||||
<p role="alert" className="mt-3 text-sm text-danger">
|
||||
{avatarError}
|
||||
</p>
|
||||
)}
|
||||
{upload.isError ? <ErrorNotice className="mt-3" error={upload.error} /> : null}
|
||||
{removeAvatar.isError ? <ErrorNotice className="mt-3" error={removeAvatar.error} /> : null}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.profile.decorations.title')}
|
||||
description={t('settings.profile.decorations.hint')}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { canAdministerGuild } from '@/lib/identity';
|
||||
import { useCurrentUser } from '@/lib/hooks';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { useUiStore, type SettingsSection } from '@/stores/ui';
|
||||
import AppearanceSettingsPage from '@/pages/settings/AppearanceSettingsPage';
|
||||
import GuildSettingsPage from '@/pages/settings/GuildSettingsPage';
|
||||
import InstanceSettingsPage from '@/pages/settings/InstanceSettingsPage';
|
||||
import { LanguageRegionSection } from '@/pages/settings/LanguageRegionSection';
|
||||
import { NotificationsSettingsSection } from '@/pages/settings/NotificationsSettingsSection';
|
||||
import ProfileServerTab from '@/pages/settings/ProfileServerTab';
|
||||
import ProfileSettingsPage from '@/pages/settings/ProfileSettingsPage';
|
||||
import SecuritySettingsPage from '@/pages/settings/SecuritySettingsPage';
|
||||
import { ServersSettingsPage } from '@/pages/settings/ServersSettingsPage';
|
||||
import { VoiceSettingsSection } from '@/pages/settings/VoiceSettingsSection';
|
||||
|
||||
/** Вкладки пункта «Профиль»: глобальный профиль и профиль на сервере. */
|
||||
@@ -48,16 +51,32 @@ function ProfileTabs() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'main' ? <ProfileSettingsPage /> : <ServersSettingsPage />}
|
||||
{tab === 'main' ? <ProfileSettingsPage /> : <ProfileServerTab />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Есть ли у пользователя хотя бы один сервер, которым он управляет. */
|
||||
function useCanManageGuilds(): boolean {
|
||||
const currentUser = useCurrentUser();
|
||||
const guilds = useSessionStore((state) => state.guilds);
|
||||
const isInstanceAdmin = currentUser.data?.is_instance_admin === true;
|
||||
|
||||
if (isInstanceAdmin) {
|
||||
return true;
|
||||
}
|
||||
return guilds.some((guild) =>
|
||||
canAdministerGuild(guild.my_permissions, guild.owner_id, currentUser.data?.id),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Модальное окно настроек: слева вертикальный список пунктов, справа
|
||||
* содержимое выбранного. Открывается из панели пользователя (шестерёнка) и из
|
||||
* меню пользователя, а также при переходе на старые адреса `/settings/*`.
|
||||
*
|
||||
* Размер окна фиксирован (70% ширины и высоты окна браузера): при переключении
|
||||
* пунктов оно не подстраивается под содержимое, а прокручивается изнутри.
|
||||
* Окно живёт в портале: панель пользователя — узкая колонка, и портал снимает
|
||||
* вопросы к её `overflow` и анимационным трансформациям.
|
||||
*/
|
||||
@@ -69,6 +88,9 @@ export function SettingsModal() {
|
||||
const closeSettings = useUiStore((state) => state.closeSettings);
|
||||
const currentUser = useCurrentUser();
|
||||
const isAdmin = currentUser.data?.is_instance_admin === true;
|
||||
const canManageGuilds = useCanManageGuilds();
|
||||
|
||||
const selectedGuildId = useSessionStore((state) => state.settingsGuildId);
|
||||
|
||||
const items: { id: SettingsSection; label: string }[] = [
|
||||
{ id: 'profile', label: t('settings.tabs.profile') },
|
||||
@@ -77,6 +99,7 @@ export function SettingsModal() {
|
||||
{ id: 'notifications', label: t('settings.tabs.notifications') },
|
||||
{ id: 'voice', label: t('settings.tabs.voice') },
|
||||
{ id: 'language', label: t('settings.tabs.language') },
|
||||
...(canManageGuilds ? [{ id: 'server' as const, label: t('settings.tabs.server') }] : []),
|
||||
...(isAdmin ? [{ id: 'instance' as const, label: t('settings.tabs.instance') }] : []),
|
||||
];
|
||||
|
||||
@@ -84,15 +107,23 @@ export function SettingsModal() {
|
||||
<Modal
|
||||
open={open}
|
||||
title={t('settings.title')}
|
||||
size="xl"
|
||||
portal
|
||||
panelClassName="flex max-h-[85vh] flex-col"
|
||||
panelClassName="flex flex-col overflow-hidden"
|
||||
bodyClassName="flex min-h-0 flex-1 gap-4"
|
||||
panelStyle={{
|
||||
// 70% доступной области с границами: на маленьких экранах окно
|
||||
// остаётся читаемым, на больших — не растягивается во весь экран.
|
||||
width: 'clamp(20rem, 70vw, 72rem)',
|
||||
height: 'clamp(22rem, 70vh, 52rem)',
|
||||
maxWidth: 'calc(100vw - 2rem)',
|
||||
maxHeight: 'calc(100vh - 2rem)',
|
||||
}}
|
||||
onClose={closeSettings}
|
||||
>
|
||||
<nav
|
||||
aria-label={t('settings.navLabel')}
|
||||
className="flex w-40 shrink-0 flex-col gap-0.5 overflow-y-auto border-r border-border/40 pr-2"
|
||||
data-testid="settings-nav"
|
||||
>
|
||||
{items.map((item) => (
|
||||
<button
|
||||
@@ -123,6 +154,7 @@ export function SettingsModal() {
|
||||
{section === 'notifications' ? <NotificationsSettingsSection /> : null}
|
||||
{section === 'voice' ? <VoiceSettingsSection /> : null}
|
||||
{section === 'language' ? <LanguageRegionSection /> : null}
|
||||
{section === 'server' ? <GuildSettingsPage key={selectedGuildId ?? 'none'} /> : null}
|
||||
{section === 'instance' ? <InstanceSettingsPage /> : null}
|
||||
{section === 'profile' ? <ProfileTabs /> : null}
|
||||
</div>
|
||||
|
||||
@@ -13,9 +13,9 @@ export function parseSettingsPath(pathname: string): SettingsTarget {
|
||||
const rest = segments.slice(1);
|
||||
|
||||
if (rest[0] === 'servers') {
|
||||
// Старые адреса серверов теперь ведут в администрирование сервера.
|
||||
return {
|
||||
section: 'profile',
|
||||
tab: 'server',
|
||||
section: 'server',
|
||||
guildId: rest[1] === undefined ? null : decodeURIComponent(rest[1]),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user