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:
@@ -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 />
|
||||
|
||||
@@ -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'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user