feat(invites): приглашения, каталог публичных серверов и публичность сервера

- POST /guilds/{id}/invites (CREATE_INVITES): код из 10 символов, лимит
  использований и срок жизни; GET /guilds/{id}/invites (MANAGE_GUILD);
  DELETE /invites/{code} — создатель или MANAGE_GUILD;
- GET /invites/{code} — предпросмотр с карточкой сервера, 410 для истёкших и
  исчерпанных; POST /invites/{code} — принятие с проверкой лимита участников,
  повторное принятие не расходует использование;
- PATCH /guilds/{id} принимает `public`: сервер попадает в каталог и открыт для
  входа без приглашения (главный сервер публиковать нельзя);
- GET /guilds/public — каталог (имя, описание, иконка, число участников);
- аудит invite.create/invite.revoke/invite.accept, события INVITE_CREATE и
  INVITE_DELETE владельцу сессии;
- тесты: полный жизненный цикл приглашения, исчерпание, отзыв, права,
  публичный каталог и вход в публичный сервер.
This commit is contained in:
2026-09-19 23:33:34 +03:00
parent e4ffbb05a5
commit 61a84bfeae
16 changed files with 1233 additions and 32 deletions
+6 -3
View File
@@ -1,15 +1,18 @@
import { Suspense } from 'react';
import { RouterProvider } from 'react-router';
import { LoadingNotice } from '@/components/ui/ErrorNotice';
import { Card } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton';
import { router } from '@/router';
import '@/i18n';
/** Заглушка на время загрузки ленивых страниц. */
/** Заглушка на время загрузки ленивых страниц: скелетон вместо пустоты. */
export function RouteFallback() {
return (
<div className="flex min-h-full items-center justify-center p-6">
<LoadingNotice />
<Card className="w-full max-w-md">
<SkeletonLines rows={4} />
</Card>
</div>
);
}
+1 -1
View File
@@ -16,7 +16,7 @@ export function Button({ variant = 'primary', className = '', ...rest }: ButtonP
return (
<button
type="button"
className={`inline-flex items-center justify-center gap-2 rounded-[var(--radius-md)] px-3 py-1.5 text-sm font-medium transition-colors duration-[var(--duration-fast)] disabled:cursor-not-allowed disabled:opacity-50 ${variants[variant]} ${className}`}
className={`inline-flex items-center justify-center gap-2 rounded-[var(--radius-md)] px-3 py-1.5 text-sm font-medium transition-[background-color,opacity,transform,border-color] duration-[var(--duration-fast)] active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-50 ${variants[variant]} ${className}`}
{...rest}
/>
);
+68
View File
@@ -183,6 +183,7 @@
"empty": "В этом сервере пока нет видимых комнат.",
"select": "Открыть комнату {{name}}",
"add": "Создать комнату",
"settings": "Настройки сервера",
"create": {
"title": "Новая комната",
"name": "Название комнаты",
@@ -214,6 +215,13 @@
"user": {
"panelLabel": "Панель пользователя",
"settings": "Настройки пользователя",
"menu": {
"label": "Меню пользователя",
"open": "Открыть меню пользователя",
"account": "Настройки аккаунта",
"guilds": "Настройки серверов",
"status": "Статус"
},
"logout": "Выйти",
"loggingOut": "Выходим…",
"statusLabel": "Статус: {{status}}",
@@ -240,6 +248,11 @@
"settings": {
"title": "Настройки",
"back": "В приложение",
"switcher": {
"label": "Разделы настроек",
"account": "Настройки сервера",
"servers": "Мои серверы"
},
"tabs": {
"label": "Разделы настроек",
"profile": "Профиль",
@@ -331,6 +344,61 @@
"memberCount": "{{count}} участн.",
"adminBadge": "админ",
"mainBadge": "главный"
},
"servers": {
"pickerLabel": "Мои серверы",
"empty": "У вас пока нет серверов.",
"emptyTitle": "Пока нет ни одного сервера",
"emptyHint": "Присоединитесь к главному серверу инстанса или создайте свой — тогда здесь появятся его настройки.",
"select": "Открыть настройки сервера {{name}}",
"notFound": "Сервер не найден среди доступных вам.",
"readOnlyHint": "Редактирование доступно владельцу сервера и участникам с правами ADMINISTRATOR или MANAGE_GUILD.",
"general": {
"title": "Общее",
"description": "Название и описание сервера видят все участники.",
"name": "Название сервера",
"guildDescription": "Описание",
"descriptionPlaceholder": "О чём этот сервер",
"saved": "Настройки сервера сохранены."
},
"nickname": {
"title": "Мой профиль на сервере",
"description": "Никнейм виден только на этом сервере.",
"label": "Никнейм",
"placeholder": "Оставьте пустым, чтобы сбросить ник",
"current": "Текущий никнейм:",
"none": "не задан",
"saved": "Никнейм сохранён."
},
"members": {
"title": "Участники",
"description": "Первые {{limit}} участников сервера.",
"empty": "Участники не найдены.",
"roles": "Ролей: {{roles}}",
"more": "Полный список участников с ролями появится в следующих фазах."
},
"roles": {
"title": "Роли",
"description": "Цвет и позиция ролей. Управление ролями появится в следующих фазах.",
"empty": "На сервере пока нет ролей.",
"position": "Позиция {{position}}",
"default": "по умолчанию"
},
"danger": {
"title": "Опасная зона",
"description": "Эти действия необратимы.",
"leaveOwnerHint": "Владелец не может покинуть свой сервер: сначала передайте владение или удалите сервер.",
"leaveHint": "Выйти из сервера и потерять доступ к его комнатам.",
"leave": "Покинуть сервер",
"leaveConfirmTitle": "Покинуть сервер?",
"leaveConfirmBody": "Вы выйдете из сервера «{{name}}». Вернуться можно будет по приглашению.",
"leaveConfirm": "Покинуть",
"deleteHint": "Удалить сервер вместе с комнатами, ролями и участниками.",
"delete": "Удалить сервер",
"deleteConfirmTitle": "Удалить сервер?",
"deleteConfirmBody": "Сервер «{{name}}» будет удалён без возможности восстановления.",
"deleteConfirm": "Удалить"
}
}
}
}
+2 -1
View File
@@ -156,7 +156,8 @@ export default function AppLayout() {
/>
<ChannelSidebar guildId={guildId} channelId={channelId} />
<div className="flex min-w-0 flex-1 flex-col">
<div className="min-h-0 flex-1 overflow-auto">
{/* Ключ по пути: смена комнаты/сервера появляется с анимацией. */}
<div key={location.pathname} className="gl-page min-h-0 flex-1 overflow-auto">
<Outlet />
</div>
<UserPanel />
+37 -16
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { NavLink, useNavigate } from 'react-router';
import { Link, NavLink, useNavigate } from 'react-router';
import { createChannel, fetchChannels, guildChannelsQueryKey, leaveGuild } from '@/api/guilds';
import { myGuildsQueryKey } from '@/api/users';
@@ -9,8 +9,9 @@ import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field, SelectField } from '@/components/ui/Field';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton';
import { groupChannels } from '@/lib/channels';
import { canManageGuild } from '@/lib/identity';
import { canManageGuild, canManageGuildSettings } from '@/lib/identity';
import { useCurrentUser } from '@/lib/hooks';
import { useSessionStore } from '@/stores/session';
@@ -91,6 +92,12 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
);
const canManage =
guild !== null && canManageGuild(guild.my_permissions, guild.owner_id, currentUser.data?.id);
// Шестерёнка настроек сервера — только владельцу и правам управления.
const canManageSettings =
guild !== null &&
canManageGuildSettings(guild.my_permissions, guild.owner_id, currentUser.data?.id);
// Каналы ещё едут по REST — показываем скелетон вместо «пусто».
const channelsLoading = needsChannels && remoteChannels.isPending;
if (guildId === null) {
return (
@@ -107,22 +114,36 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
<h1 className="truncate text-sm font-semibold" title={guild?.name}>
{guild?.name ?? t('channels.notInGuild.title')}
</h1>
{canManage ? (
<button
type="button"
aria-label={t('channels.add')}
title={t('channels.add')}
className="rounded-[var(--radius-sm)] px-1 text-lg leading-none text-fg-muted hover:text-fg"
onClick={() => setCreateOpen(true)}
>
+
</button>
) : null}
<div className="flex items-center gap-1">
{canManageSettings ? (
<Link
to={`/settings/servers/${guildId}`}
aria-label={t('channels.settings')}
title={t('channels.settings')}
className="rounded-[var(--radius-sm)] px-1 text-lg leading-none text-fg-muted transition-[color,background-color,transform] duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg active:scale-95"
>
⚙
</Link>
) : null}
{canManage ? (
<button
type="button"
aria-label={t('channels.add')}
title={t('channels.add')}
className="rounded-[var(--radius-sm)] px-1 text-lg leading-none text-fg-muted transition-[color,background-color,transform] duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg active:scale-95"
onClick={() => setCreateOpen(true)}
>
+
</button>
) : null}
</div>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-3">
{guild === null ? (
<p className="px-2 text-sm text-fg-muted">{t('channels.notInGuild.body')}</p>
) : channelsLoading ? (
<SkeletonLines className="px-2" rows={5} />
) : groups.length === 0 ? (
<p className="px-2 text-sm text-fg-muted">{t('channels.empty')}</p>
) : (
@@ -134,7 +155,7 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
{group.category.name}
</h2>
)}
<ul className="flex flex-col gap-0.5">
<ul className="gl-stagger flex flex-col gap-0.5">
{group.channels.map((channel) => (
<li key={channel.id}>
<NavLink
@@ -142,9 +163,9 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
aria-current={channel.id === channelId ? 'page' : undefined}
aria-label={t('channels.select', { name: channel.name })}
title={channel.name}
className={`flex items-center gap-2 truncate rounded-[var(--radius-sm)] px-2 py-1 text-sm ${
className={`relative flex items-center gap-2 truncate rounded-[var(--radius-sm)] px-2 py-1 text-sm transition-colors duration-[var(--duration-fast)] ${
channel.id === channelId
? 'bg-surface-3 text-fg'
? 'gl-indicator bg-surface-3 text-fg'
: 'text-fg-muted hover:bg-surface-2 hover:text-fg'
}`}
>
+5 -5
View File
@@ -19,7 +19,7 @@ export function GuildRail({ guilds, selectedGuildId, canCreate, onCreate }: Guil
aria-label={t('guilds.railLabel')}
className="flex w-[72px] shrink-0 flex-col items-center gap-2 overflow-y-auto border-r border-border/40 bg-surface-2/60 py-3"
>
<ul className="flex flex-col items-center gap-2" data-testid="guild-rail">
<ul className="gl-stagger flex flex-col items-center gap-2" data-testid="guild-rail">
{guilds.map((guild) => {
const active = guild.id === selectedGuildId;
return (
@@ -29,10 +29,10 @@ export function GuildRail({ guilds, selectedGuildId, canCreate, onCreate }: Guil
title={guild.name}
aria-label={t('guilds.switchTo', { name: guild.name })}
aria-current={active ? 'page' : undefined}
className={`block rounded-[var(--radius-md)] transition-[border-radius,background-color] duration-[var(--duration-fast)] ${
className={`relative block rounded-[var(--radius-md)] transition-[border-radius,background-color,transform] duration-[var(--duration-fast)] ${
active
? 'rounded-[var(--radius-lg)] ring-2 ring-accent'
: 'hover:rounded-[var(--radius-lg)]'
? 'gl-ring rounded-[var(--radius-lg)]'
: 'hover:-translate-y-0.5 hover:rounded-[var(--radius-lg)] active:translate-y-0'
}`}
>
<Avatar name={guild.name} seed={guild.id} size="lg" shape="square" />
@@ -48,7 +48,7 @@ export function GuildRail({ guilds, selectedGuildId, canCreate, onCreate }: Guil
onClick={onCreate}
aria-label={t('guilds.add')}
title={t('guilds.add')}
className="flex h-14 w-14 items-center justify-center rounded-[var(--radius-md)] border border-dashed border-border bg-surface-1 text-2xl text-success hover:rounded-[var(--radius-lg)] hover:border-success"
className="flex h-14 w-14 items-center justify-center rounded-[var(--radius-md)] border border-dashed border-border bg-surface-1 text-2xl text-success transition-[border-radius,border-color,transform] duration-[var(--duration-fast)] hover:-translate-y-0.5 hover:rounded-[var(--radius-lg)] hover:border-success active:translate-y-0"
>
+
</button>
@@ -0,0 +1,163 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
import { deleteGuild, guildQueryKey, leaveGuild } from '@/api/guilds';
import { instanceQueryKey } from '@/api/instance';
import { myGuildsQueryKey } from '@/api/users';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/primitives';
import { useSessionStore } from '@/stores/session';
import { SettingsSection } from '@/pages/settings/SettingsSection';
interface GuildDangerSectionProps {
guildId: string;
guildName: string;
/** Владелец не может покинуть сервер — только удалить его. */
isOwner: boolean;
/** Администратор инстанса может удалить любой сервер. */
isInstanceAdmin: boolean;
}
type ConfirmAction = 'leave' | 'delete';
/** «Опасная зона»: выход с сервера и удаление сервера с подтверждением. */
export function GuildDangerSection({
guildId,
guildName,
isOwner,
isInstanceAdmin,
}: GuildDangerSectionProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [confirm, setConfirm] = useState<ConfirmAction | null>(null);
// Последнее действие: пока окно закрывается, тексты не должны «прыгать».
const [lastAction, setLastAction] = useState<ConfirmAction>('leave');
const ask = (action: ConfirmAction): void => {
setLastAction(action);
setConfirm(action);
};
/** После выхода/удаления возвращаемся в приложение и обновляем снапшот. */
const afterExit = (): void => {
setConfirm(null);
useSessionStore.getState().removeGuild(guildId);
useSessionStore.getState().selectSettingsGuild(null);
void queryClient.invalidateQueries({ queryKey: myGuildsQueryKey });
void queryClient.invalidateQueries({ queryKey: instanceQueryKey });
void queryClient.removeQueries({ queryKey: guildQueryKey(guildId) });
void navigate('/app', { replace: true });
};
const leave = useMutation({
mutationFn: () => leaveGuild(guildId),
onSuccess: afterExit,
});
const remove = useMutation({
mutationFn: () => deleteGuild(guildId),
onSuccess: afterExit,
});
const canDelete = isOwner || isInstanceAdmin;
const pending = leave.isPending || remove.isPending;
return (
<SettingsSection
title={t('settings.servers.danger.title')}
description={t('settings.servers.danger.description')}
className="border-danger/40"
>
<div className="mt-4 flex flex-col gap-4">
{isOwner ? (
<p className="text-sm text-fg-muted">{t('settings.servers.danger.leaveOwnerHint')}</p>
) : (
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="max-w-md text-sm text-fg-muted">
{t('settings.servers.danger.leaveHint')}
</p>
<Button
variant="ghost"
className="border-danger/50 text-danger"
disabled={pending}
onClick={() => {
leave.reset();
ask('leave');
}}
>
{t('settings.servers.danger.leave')}
</Button>
</div>
)}
{canDelete ? (
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border/40 pt-4">
<p className="max-w-md text-sm text-fg-muted">
{t('settings.servers.danger.deleteHint')}
</p>
<Button
variant="ghost"
className="border-danger/50 text-danger"
disabled={pending}
onClick={() => {
remove.reset();
ask('delete');
}}
>
{t('settings.servers.danger.delete')}
</Button>
</div>
) : null}
</div>
{leave.isError ? <ErrorNotice className="mt-3" error={leave.error} /> : null}
{remove.isError ? <ErrorNotice className="mt-3" error={remove.error} /> : null}
<Modal
open={confirm !== null}
title={t(
lastAction === 'delete'
? 'settings.servers.danger.deleteConfirmTitle'
: 'settings.servers.danger.leaveConfirmTitle',
)}
onClose={() => setConfirm(null)}
footer={
<>
<Button variant="ghost" onClick={() => setConfirm(null)}>
{t('common.cancel')}
</Button>
<Button
disabled={pending}
onClick={() => {
if (lastAction === 'delete') {
remove.mutate();
return;
}
leave.mutate();
}}
>
{t(
lastAction === 'delete'
? 'settings.servers.danger.deleteConfirm'
: 'settings.servers.danger.leaveConfirm',
)}
</Button>
</>
}
>
<p className="text-sm text-fg-muted">
{t(
lastAction === 'delete'
? 'settings.servers.danger.deleteConfirmBody'
: 'settings.servers.danger.leaveConfirmBody',
{ name: guildName },
)}
</p>
</Modal>
</SettingsSection>
);
}
@@ -1,4 +1,4 @@
import { useState, type FormEvent } from 'react';
import { useEffect, useState, type FormEvent } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -30,8 +30,17 @@ export function GuildNicknameSection({
const { t } = useTranslation();
const queryClient = useQueryClient();
const [nickname, setNickname] = useState(currentNickname);
const [edited, setEdited] = useState(false);
const [saved, setSaved] = useState(false);
// Ник может прийти из списка участников уже после монтирования секции:
// подтягиваем его, пока пользователь не начал править поле сам.
useEffect(() => {
if (!edited) {
setNickname(currentNickname);
}
}, [currentNickname, edited]);
const save = useMutation({
mutationFn: (value: string) => updateGuildMember(guildId, userId ?? '', { nickname: value }),
onSuccess: (_result, value) => {
@@ -72,7 +81,10 @@ export function GuildNicknameSection({
value={nickname}
maxLength={32}
disabled={userId === undefined}
onChange={(event) => setNickname(event.target.value)}
onChange={(event) => {
setEdited(true);
setNickname(event.target.value);
}}
/>
{save.isError ? <ErrorNotice error={save.error} /> : null}
@@ -4,9 +4,10 @@ import { useTranslation } from 'react-i18next';
import { updateProfile, currentUserQueryKey, type UpdateProfileInput } from '@/api/users';
import { userStatuses, type User } from '@/api/types';
import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field, SelectField, TextAreaField } from '@/components/ui/Field';
import { Button, Card } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton';
import { supportedLanguages, type SupportedLanguage } from '@/i18n';
import { useCurrentUser } from '@/lib/hooks';
import { useSessionStore } from '@/stores/session';
@@ -155,7 +156,7 @@ export default function ProfileSettingsPage() {
if (currentUser.isPending) {
return (
<Card>
<LoadingNotice />
<SkeletonLines rows={5} />
</Card>
);
}
@@ -0,0 +1,209 @@
import { useEffect, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { NavLink, useNavigate, useParams } from 'react-router';
import { fetchGuild, fetchMembers, guildMembersQueryKey, guildQueryKey } from '@/api/guilds';
import { fetchMyGuilds, myGuildsQueryKey } from '@/api/users';
import { Avatar } from '@/components/ui/Avatar';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
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 { SettingsSection } from '@/pages/settings/SettingsSection';
/**
* Настройки пользовательских серверов (`/settings/servers/:guildId`).
*
* Список серверов берём из READY-снапшота, а если шлюз ещё не подключился —
* из `GET /users/@me/guilds` (как в `AppLayout`). Выбранный сервер хранится
* в URL и дублируется в сторе, поэтому прямая ссылка на настройки работает.
*/
export default function ServersSettingsPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const params = useParams<{ guildId?: string }>();
const routeGuildId = params.guildId;
const currentUser = useCurrentUser();
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 = sessionGuilds.length > 0 ? sessionGuilds : (myGuilds.data ?? []);
const active = useMemo(
() => (routeGuildId === undefined ? null : (guilds.find((g) => g.id === routeGuildId) ?? null)),
[guilds, routeGuildId],
);
const activeId = active?.id ?? null;
// URL — источник правды; стор запоминает последний открытый сервер.
useEffect(() => {
if (activeId !== null) {
selectSettingsGuild(activeId);
return;
}
if (routeGuildId === undefined && guilds.length > 0) {
const first = guilds[0];
if (first !== undefined) {
void navigate(`/settings/servers/${first.id}`, { replace: true });
}
}
}, [activeId, routeGuildId, guilds, navigate, selectSettingsGuild]);
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,
});
const guild = detail.data;
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 = active?.my_nickname;
if (fromSnapshot !== undefined && fromSnapshot !== '') {
return fromSnapshot;
}
const mine = members.data?.find((member) => member.user_id === currentUser.data?.id);
return mine?.nickname ?? '';
}, [active, members.data, currentUser.data?.id]);
return (
<div className="flex flex-col gap-5">
<Card className="p-3">
<h2 className="px-1 text-xs font-semibold uppercase tracking-wide text-fg-muted">
{t('settings.servers.pickerLabel')}
</h2>
{guilds.length === 0 ? (
myGuilds.isPending ? (
<SkeletonLines className="mt-3" rows={1} />
) : (
<p className="mt-2 px-1 text-sm text-fg-muted">{t('settings.servers.empty')}</p>
)
) : (
<nav
aria-label={t('settings.servers.pickerLabel')}
className="gl-stagger mt-2 flex flex-wrap gap-1.5"
data-testid="guild-picker"
>
{guilds.map((guildItem) => {
const selected = guildItem.id === activeId;
return (
<NavLink
key={guildItem.id}
to={`/settings/servers/${guildItem.id}`}
aria-current={selected ? 'page' : undefined}
aria-label={t('settings.servers.select', { name: guildItem.name })}
className={`flex max-w-56 items-center gap-2 rounded-[var(--radius-md)] border px-2 py-1.5 text-sm transition-colors duration-[var(--duration-fast)] ${
selected
? 'border-accent bg-surface-3 text-fg'
: 'border-border/60 bg-surface-1 text-fg-muted hover:bg-surface-2 hover:text-fg'
}`}
>
<Avatar
name={guildItem.name}
seed={guildItem.id}
fileId={guildItem.icon_file_id}
size="sm"
shape="square"
/>
<span className="truncate">{guildItem.name}</span>
</NavLink>
);
})}
</nav>
)}
</Card>
{guilds.length === 0 ? (
myGuilds.isPending ? (
<Card>
<SkeletonLines rows={4} />
</Card>
) : (
<SettingsSection
title={t('settings.servers.emptyTitle')}
description={t('settings.servers.emptyHint')}
>
<div className="mt-4">
<NavLink className="text-sm text-accent underline" to="/app">
{t('settings.back')}
</NavLink>
</div>
</SettingsSection>
)
) : activeId === null ? (
// Сервер из URL не найден среди доступных пользователю.
<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>
) : (
<>
<GuildGeneralSection key={guild.id} guild={guild} canEdit={canManage} />
<GuildNicknameSection
guildId={guild.id}
userId={currentUser.data?.id}
currentNickname={currentNickname}
members={members.data}
/>
<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 !== undefined && currentUser.data.id === guild.owner_id}
isInstanceAdmin={currentUser.data?.is_instance_admin === true}
/>
</>
)}
</div>
);
}
+3 -2
View File
@@ -3,8 +3,9 @@ import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
import { fetchSessions, logoutAll, sessionsQueryKey } from '@/api/auth';
import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Button, Card } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton';
import { formatDateTime } from '@/lib/identity';
import { useGatewayStore } from '@/stores/gateway';
import { useSessionStore } from '@/stores/session';
@@ -54,7 +55,7 @@ export function SessionsSection() {
<ErrorNotice className="mt-3" error={logoutAllMutation.error} />
) : null}
{sessions.isPending ? <LoadingNotice /> : null}
{sessions.isPending ? <SkeletonLines className="mt-2" rows={2} /> : null}
{sessions.isError ? (
<ErrorNotice
className="mt-3"