feat(templates): свои шаблоны в клиенте — создание сервера и настройки (Фаза 7)
Модалка создания сервера показывает блок «Мои шаблоны» рядом со встроенными и отправляет template_id=personal:<id>; секция «Шаблоны сервера» в настройках (видна с MANAGE_GUILD) сохраняет структуру текущего сервера и удаляет ненужные шаблоны. - api: fetchMyGuildTemplates, createGuildTemplate, deleteGuildTemplate, ключ myGuildTemplatesQueryKey и лимит MY_GUILD_TEMPLATE_LIMIT; - i18n: ключи в ru.json и en.json (паритет); - тесты: web/tests/guildTemplates.test.tsx.
This commit is contained in:
+43
-1
@@ -17,7 +17,7 @@ export const guildChannelsQueryKey = (guildId: string) => ['guilds', guildId, 'c
|
|||||||
export const guildMembersQueryKey = (guildId: string) => ['guilds', guildId, 'members'] as const;
|
export const guildMembersQueryKey = (guildId: string) => ['guilds', guildId, 'members'] as const;
|
||||||
export const guildRolesQueryKey = (guildId: string) => ['guilds', guildId, 'roles'] as const;
|
export const guildRolesQueryKey = (guildId: string) => ['guilds', guildId, 'roles'] as const;
|
||||||
|
|
||||||
/** Встроенный шаблон структуры сервера (AGENT.md 7.3). */
|
/** Шаблон структуры сервера: встроенный или свой (AGENT.md 7.3). */
|
||||||
export interface GuildTemplate {
|
export interface GuildTemplate {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -26,9 +26,16 @@ export interface GuildTemplate {
|
|||||||
text_channels: number;
|
text_channels: number;
|
||||||
voice_channels: number;
|
voice_channels: number;
|
||||||
categories: string[];
|
categories: string[];
|
||||||
|
/** Личный шаблон: дата создания и сервер-источник (Фаза 7). */
|
||||||
|
created_at?: string;
|
||||||
|
source_guild_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Предел личных шаблонов на пользователя — как на сервере. */
|
||||||
|
export const MY_GUILD_TEMPLATE_LIMIT = 20;
|
||||||
|
|
||||||
export const guildTemplatesQueryKey = ['guild-templates'] as const;
|
export const guildTemplatesQueryKey = ['guild-templates'] as const;
|
||||||
|
export const myGuildTemplatesQueryKey = ['guild-templates', 'mine'] as const;
|
||||||
|
|
||||||
/** `GET /guild-templates` — встроенные шаблоны серверов. */
|
/** `GET /guild-templates` — встроенные шаблоны серверов. */
|
||||||
export async function fetchGuildTemplates(signal?: AbortSignal): Promise<GuildTemplate[]> {
|
export async function fetchGuildTemplates(signal?: AbortSignal): Promise<GuildTemplate[]> {
|
||||||
@@ -39,6 +46,41 @@ export async function fetchGuildTemplates(signal?: AbortSignal): Promise<GuildTe
|
|||||||
return payload.templates;
|
return payload.templates;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `GET /guild-templates/mine` — свои шаблоны: видны только владельцу. */
|
||||||
|
export async function fetchMyGuildTemplates(signal?: AbortSignal): Promise<GuildTemplate[]> {
|
||||||
|
const payload = await request<{ templates: GuildTemplate[] }>(
|
||||||
|
'/guild-templates/mine',
|
||||||
|
signal === undefined ? {} : { signal },
|
||||||
|
);
|
||||||
|
return payload.templates;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateGuildTemplateInput {
|
||||||
|
guildId: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `POST /guild-templates` — сохранить структуру сервера личным шаблоном. */
|
||||||
|
export async function createGuildTemplate(input: CreateGuildTemplateInput): Promise<GuildTemplate> {
|
||||||
|
const payload = await request<{ template: GuildTemplate }>('/guild-templates', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
guild_id: input.guildId,
|
||||||
|
name: input.name,
|
||||||
|
description: input.description ?? '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return payload.template;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `DELETE /guild-templates/{id}` — удалить свой шаблон. */
|
||||||
|
export async function deleteGuildTemplate(templateId: string): Promise<void> {
|
||||||
|
await request<{ ok: boolean }>(`/guild-templates/${encodeURIComponent(templateId)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** `POST /guilds` — создать сервер по шаблону (пусто — «Пустой»). */
|
/** `POST /guilds` — создать сервер по шаблону (пусто — «Пустой»). */
|
||||||
export async function createGuild(name: string, templateId = ''): Promise<GuildSummary> {
|
export async function createGuild(name: string, templateId = ''): Promise<GuildSummary> {
|
||||||
const payload = await request<{ guild: GuildSummary }>('/guilds', {
|
const payload = await request<{ guild: GuildSummary }>('/guilds', {
|
||||||
|
|||||||
@@ -111,6 +111,9 @@
|
|||||||
"emojis": {
|
"emojis": {
|
||||||
"limit_reached": "The server has reached its emoji limit."
|
"limit_reached": "The server has reached its emoji limit."
|
||||||
},
|
},
|
||||||
|
"template": {
|
||||||
|
"limit_reached": "You have reached the personal template limit — delete one of them."
|
||||||
|
},
|
||||||
"voice.disabled": "Voice is not configured on this instance.",
|
"voice.disabled": "Voice is not configured on this instance.",
|
||||||
"voice.channel_full": "No free slots in this room.",
|
"voice.channel_full": "No free slots in this room.",
|
||||||
"voice.token_refresh_failed": "Could not renew the voice session. Please join again.",
|
"voice.token_refresh_failed": "Could not renew the voice session. Please join again.",
|
||||||
@@ -256,7 +259,9 @@
|
|||||||
"limit": "You can create at most {{count}} servers on this instance.",
|
"limit": "You can create at most {{count}} servers on this instance.",
|
||||||
"limitReached": "You have reached the server limit — remove one of your servers first.",
|
"limitReached": "You have reached the server limit — remove one of your servers first.",
|
||||||
"template": "Server template",
|
"template": "Server template",
|
||||||
"templateCounts": "{{text}} text · {{voice}} voice · {{roles}} roles"
|
"templateCounts": "{{text}} text · {{voice}} voice · {{roles}} roles",
|
||||||
|
"myTemplates": "My templates",
|
||||||
|
"myTemplatesEmpty": "No templates yet — save a server structure in its settings."
|
||||||
},
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "You have no servers yet",
|
"title": "You have no servers yet",
|
||||||
@@ -1222,6 +1227,26 @@
|
|||||||
"role_revoke": "Role revoked"
|
"role_revoke": "Role revoked"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"templates": {
|
||||||
|
"title": "Server templates",
|
||||||
|
"description": "Save the server structure — roles with permissions and colours, categories and rooms — and create new servers from it. Up to {{limit}} templates, visible only to you.",
|
||||||
|
"counter": "{{count}} of {{limit}}",
|
||||||
|
"empty": "You have no templates yet.",
|
||||||
|
"counts": "{{text}} text · {{voice}} voice · {{roles}} roles",
|
||||||
|
"saveTitle": "Save structure as a template",
|
||||||
|
"saveHint": "The template includes roles (except the default one), categories and rooms with their settings. Messages are not saved.",
|
||||||
|
"name": "Template name",
|
||||||
|
"nameHint": "Up to 64 characters; empty — the server name “{{name}}”.",
|
||||||
|
"templateDescription": "Template description",
|
||||||
|
"descriptionPlaceholder": "What is inside the template",
|
||||||
|
"save": "Save template",
|
||||||
|
"saved": "Template saved.",
|
||||||
|
"delete": "Delete",
|
||||||
|
"deleteLabel": "Delete template {{name}}",
|
||||||
|
"deleteConfirmTitle": "Delete template?",
|
||||||
|
"deleteConfirmBody": "Template “{{name}}” will be deleted. Servers created from it stay.",
|
||||||
|
"deleteConfirm": "Delete"
|
||||||
|
},
|
||||||
"bans": {
|
"bans": {
|
||||||
"title": "Bans",
|
"title": "Bans",
|
||||||
"description": "Banned users cannot return by invite until the ban is lifted.",
|
"description": "Banned users cannot return by invite until the ban is lifted.",
|
||||||
|
|||||||
@@ -111,6 +111,9 @@
|
|||||||
"emojis": {
|
"emojis": {
|
||||||
"limit_reached": "На сервере достигнут лимит эмодзи."
|
"limit_reached": "На сервере достигнут лимит эмодзи."
|
||||||
},
|
},
|
||||||
|
"template": {
|
||||||
|
"limit_reached": "Достигнут лимит своих шаблонов — удалите один из них."
|
||||||
|
},
|
||||||
"voice.disabled": "Голос не настроен на инстансе.",
|
"voice.disabled": "Голос не настроен на инстансе.",
|
||||||
"voice.channel_full": "В комнате нет свободных мест.",
|
"voice.channel_full": "В комнате нет свободных мест.",
|
||||||
"voice.token_refresh_failed": "Не удалось продлить сессию голоса. Подключитесь заново.",
|
"voice.token_refresh_failed": "Не удалось продлить сессию голоса. Подключитесь заново.",
|
||||||
@@ -256,7 +259,9 @@
|
|||||||
"limit": "На инстансе можно создать не более {{count}} серверов.",
|
"limit": "На инстансе можно создать не более {{count}} серверов.",
|
||||||
"limitReached": "Достигнут лимит серверов — создайте новый после удаления одного из текущих.",
|
"limitReached": "Достигнут лимит серверов — создайте новый после удаления одного из текущих.",
|
||||||
"template": "Шаблон сервера",
|
"template": "Шаблон сервера",
|
||||||
"templateCounts": "{{text}} текст. · {{voice}} голос. · {{roles}} роли"
|
"templateCounts": "{{text}} текст. · {{voice}} голос. · {{roles}} роли",
|
||||||
|
"myTemplates": "Мои шаблоны",
|
||||||
|
"myTemplatesEmpty": "Своих шаблонов пока нет — сохраните структуру сервера в его настройках."
|
||||||
},
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "У вас пока нет серверов",
|
"title": "У вас пока нет серверов",
|
||||||
@@ -1222,6 +1227,26 @@
|
|||||||
"role_revoke": "Снятие роли"
|
"role_revoke": "Снятие роли"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"templates": {
|
||||||
|
"title": "Шаблоны сервера",
|
||||||
|
"description": "Сохраните структуру сервера — роли с правами и цветом, категории и комнаты — и создавайте по ней новые серверы. Своих шаблонов: до {{limit}}, они видны только вам.",
|
||||||
|
"counter": "{{count}} из {{limit}}",
|
||||||
|
"empty": "Своих шаблонов пока нет.",
|
||||||
|
"counts": "{{text}} текст. · {{voice}} голос. · {{roles}} роли",
|
||||||
|
"saveTitle": "Сохранить структуру как шаблон",
|
||||||
|
"saveHint": "В шаблон попадут роли (кроме роли по умолчанию), категории и комнаты с их настройками. Сообщения не сохраняются.",
|
||||||
|
"name": "Название шаблона",
|
||||||
|
"nameHint": "До 64 символов; пусто — имя сервера «{{name}}».",
|
||||||
|
"templateDescription": "Описание шаблона",
|
||||||
|
"descriptionPlaceholder": "Что внутри шаблона",
|
||||||
|
"save": "Сохранить шаблон",
|
||||||
|
"saved": "Шаблон сохранён.",
|
||||||
|
"delete": "Удалить",
|
||||||
|
"deleteLabel": "Удалить шаблон {{name}}",
|
||||||
|
"deleteConfirmTitle": "Удалить шаблон?",
|
||||||
|
"deleteConfirmBody": "Шаблон «{{name}}» будет удалён. Серверы, созданные по нему, останутся.",
|
||||||
|
"deleteConfirm": "Удалить"
|
||||||
|
},
|
||||||
"bans": {
|
"bans": {
|
||||||
"title": "Баны",
|
"title": "Баны",
|
||||||
"description": "Забаненные не могут вернуться по приглашению, пока бан не снят.",
|
"description": "Забаненные не могут вернуться по приглашению, пока бан не снят.",
|
||||||
|
|||||||
@@ -3,7 +3,14 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
import { createGuild, fetchGuildTemplates, guildTemplatesQueryKey } from '@/api/guilds';
|
import {
|
||||||
|
createGuild,
|
||||||
|
fetchGuildTemplates,
|
||||||
|
fetchMyGuildTemplates,
|
||||||
|
guildTemplatesQueryKey,
|
||||||
|
myGuildTemplatesQueryKey,
|
||||||
|
type GuildTemplate,
|
||||||
|
} from '@/api/guilds';
|
||||||
import { instanceQueryKey } from '@/api/instance';
|
import { instanceQueryKey } from '@/api/instance';
|
||||||
import { myGuildsQueryKey } from '@/api/users';
|
import { myGuildsQueryKey } from '@/api/users';
|
||||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||||
@@ -14,6 +21,51 @@ import { useInstance } from '@/lib/hooks';
|
|||||||
import { useSessionStore } from '@/stores/session';
|
import { useSessionStore } from '@/stores/session';
|
||||||
import { useUiStore } from '@/stores/ui';
|
import { useUiStore } from '@/stores/ui';
|
||||||
|
|
||||||
|
/** Значение радио личного шаблона: так его понимает `POST /guilds` (7.3). */
|
||||||
|
function personalTemplateValue(templateId: string): string {
|
||||||
|
return `personal:${templateId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TemplateOptionProps {
|
||||||
|
value: string;
|
||||||
|
testId: string;
|
||||||
|
template: GuildTemplate;
|
||||||
|
selected: boolean;
|
||||||
|
onSelect: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Карточка шаблона в списке выбора: имя, счётчики и описание. */
|
||||||
|
function TemplateOption({ value, testId, template, selected, onSelect }: TemplateOptionProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
data-testid={testId}
|
||||||
|
className={`flex cursor-pointer flex-col gap-0.5 rounded-[var(--radius-md)] border px-3 py-2 transition-colors duration-[var(--duration-fast)] ${
|
||||||
|
selected ? 'border-accent bg-accent/10' : 'border-border/60 hover:bg-surface-2'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="guild_template"
|
||||||
|
value={value}
|
||||||
|
checked={selected}
|
||||||
|
onChange={() => onSelect(value)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">{template.name}</span>
|
||||||
|
<span className="ml-auto text-xs text-fg-muted">
|
||||||
|
{t('guilds.create.templateCounts', {
|
||||||
|
text: template.text_channels,
|
||||||
|
voice: template.voice_channels,
|
||||||
|
roles: template.roles.length,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-fg-muted">{template.description}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Единственное модальное окно создания сервера (`POST /guilds {name}`).
|
* Единственное модальное окно создания сервера (`POST /guilds {name}`).
|
||||||
* Открывается из рейки и с пустого экрана «У вас пока нет серверов»,
|
* Открывается из рейки и с пустого экрана «У вас пока нет серверов»,
|
||||||
@@ -33,7 +85,7 @@ export function CreateGuildModal() {
|
|||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [templateId, setTemplateId] = useState('empty');
|
const [templateId, setTemplateId] = useState('empty');
|
||||||
|
|
||||||
// Шаблоны структуры: список небольшой и статический, кэшируется на сессию.
|
// Встроенные шаблоны: список небольшой и статический, кэшируется на сессию.
|
||||||
const templates = useQuery({
|
const templates = useQuery({
|
||||||
queryKey: guildTemplatesQueryKey,
|
queryKey: guildTemplatesQueryKey,
|
||||||
queryFn: ({ signal }) => fetchGuildTemplates(signal),
|
queryFn: ({ signal }) => fetchGuildTemplates(signal),
|
||||||
@@ -42,6 +94,15 @@ export function CreateGuildModal() {
|
|||||||
retry: 0,
|
retry: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Свои шаблоны (Фаза 7): сервер отдаёт только шаблоны текущего пользователя.
|
||||||
|
const myTemplates = useQuery({
|
||||||
|
queryKey: myGuildTemplatesQueryKey,
|
||||||
|
queryFn: ({ signal }) => fetchMyGuildTemplates(signal),
|
||||||
|
enabled: open,
|
||||||
|
staleTime: 60_000,
|
||||||
|
retry: 0,
|
||||||
|
});
|
||||||
|
|
||||||
const create = useMutation({
|
const create = useMutation({
|
||||||
mutationFn: (guildName: string) => createGuild(guildName, templateId),
|
mutationFn: (guildName: string) => createGuild(guildName, templateId),
|
||||||
onSuccess: (guild) => {
|
onSuccess: (guild) => {
|
||||||
@@ -57,6 +118,8 @@ export function CreateGuildModal() {
|
|||||||
const limitReached =
|
const limitReached =
|
||||||
instance.data !== undefined && guilds.length >= instance.data.max_guilds_per_user;
|
instance.data !== undefined && guilds.length >= instance.data.max_guilds_per_user;
|
||||||
|
|
||||||
|
const mine = myTemplates.data ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
open={open}
|
open={open}
|
||||||
@@ -97,35 +160,32 @@ export function CreateGuildModal() {
|
|||||||
<fieldset className="flex flex-col gap-2" data-testid="guild-templates">
|
<fieldset className="flex flex-col gap-2" data-testid="guild-templates">
|
||||||
<legend className="text-sm font-medium">{t('guilds.create.template')}</legend>
|
<legend className="text-sm font-medium">{t('guilds.create.template')}</legend>
|
||||||
{(templates.data ?? []).map((template) => (
|
{(templates.data ?? []).map((template) => (
|
||||||
<label
|
<TemplateOption
|
||||||
key={template.id}
|
key={template.id}
|
||||||
data-testid={`guild-template-${template.id}`}
|
value={template.id}
|
||||||
className={`flex cursor-pointer flex-col gap-0.5 rounded-[var(--radius-md)] border px-3 py-2 transition-colors duration-[var(--duration-fast)] ${
|
testId={`guild-template-${template.id}`}
|
||||||
templateId === template.id
|
template={template}
|
||||||
? 'border-accent bg-accent/10'
|
selected={templateId === template.id}
|
||||||
: 'border-border/60 hover:bg-surface-2'
|
onSelect={setTemplateId}
|
||||||
}`}
|
/>
|
||||||
>
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="guild_template"
|
|
||||||
value={template.id}
|
|
||||||
checked={templateId === template.id}
|
|
||||||
onChange={() => setTemplateId(template.id)}
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-medium">{template.name}</span>
|
|
||||||
<span className="ml-auto text-xs text-fg-muted">
|
|
||||||
{t('guilds.create.templateCounts', {
|
|
||||||
text: template.text_channels,
|
|
||||||
voice: template.voice_channels,
|
|
||||||
roles: template.roles.length,
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-fg-muted">{template.description}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
))}
|
||||||
|
<p className="mt-1 text-xs font-medium text-fg-muted" data-testid="my-guild-templates">
|
||||||
|
{t('guilds.create.myTemplates')}
|
||||||
|
</p>
|
||||||
|
{mine.length === 0 ? (
|
||||||
|
<p className="text-xs text-fg-muted">{t('guilds.create.myTemplatesEmpty')}</p>
|
||||||
|
) : (
|
||||||
|
mine.map((template) => (
|
||||||
|
<TemplateOption
|
||||||
|
key={template.id}
|
||||||
|
value={personalTemplateValue(template.id)}
|
||||||
|
testId={`guild-template-personal-${template.id}`}
|
||||||
|
template={template}
|
||||||
|
selected={templateId === personalTemplateValue(template.id)}
|
||||||
|
onSelect={setTemplateId}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
{templates.isError ? (
|
{templates.isError ? (
|
||||||
<ErrorNotice error={templates.error} onRetry={() => void templates.refetch()} />
|
<ErrorNotice error={templates.error} onRetry={() => void templates.refetch()} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { GuildMembersSection } from '@/pages/settings/GuildMembersSection';
|
|||||||
import { GuildPicker } from '@/pages/settings/GuildPicker';
|
import { GuildPicker } from '@/pages/settings/GuildPicker';
|
||||||
import { GuildRolesSection } from '@/pages/settings/GuildRolesSection';
|
import { GuildRolesSection } from '@/pages/settings/GuildRolesSection';
|
||||||
import { GuildSoundsSection } from '@/pages/settings/GuildSoundsSection';
|
import { GuildSoundsSection } from '@/pages/settings/GuildSoundsSection';
|
||||||
|
import { GuildTemplatesSection } from '@/pages/settings/GuildTemplatesSection';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Пункт «Сервер» окна настроек: администрирование одного сервера —
|
* Пункт «Сервер» окна настроек: администрирование одного сервера —
|
||||||
@@ -230,6 +231,14 @@ function GuildSettingsBody({ guilds }: { guilds: { id: string }[] }) {
|
|||||||
{canManageSoundSet ? (
|
{canManageSoundSet ? (
|
||||||
<GuildSoundsSection key={`${guild.id}-sounds`} guildId={guild.id} />
|
<GuildSoundsSection key={`${guild.id}-sounds`} guildId={guild.id} />
|
||||||
) : null}
|
) : null}
|
||||||
|
{/* Личные шаблоны (Фаза 7): структуру сохраняет тот, у кого MANAGE_GUILD. */}
|
||||||
|
{canManage ? (
|
||||||
|
<GuildTemplatesSection
|
||||||
|
key={`${guild.id}-templates`}
|
||||||
|
guildId={guild.id}
|
||||||
|
guildName={guild.name}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<GuildBansSection key={`${guild.id}-bans`} guildId={guild.id} canBan={canBan} />
|
<GuildBansSection key={`${guild.id}-bans`} guildId={guild.id} canBan={canBan} />
|
||||||
<GuildAuditSection key={`${guild.id}-audit`} guildId={guild.id} canView={canViewAudit} />
|
<GuildAuditSection key={`${guild.id}-audit`} guildId={guild.id} canView={canViewAudit} />
|
||||||
<GuildDangerSection
|
<GuildDangerSection
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import {
|
||||||
|
MY_GUILD_TEMPLATE_LIMIT,
|
||||||
|
createGuildTemplate,
|
||||||
|
deleteGuildTemplate,
|
||||||
|
fetchMyGuildTemplates,
|
||||||
|
myGuildTemplatesQueryKey,
|
||||||
|
type GuildTemplate,
|
||||||
|
} from '@/api/guilds';
|
||||||
|
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||||
|
import { Field, TextAreaField } from '@/components/ui/Field';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { Button } from '@/components/ui/primitives';
|
||||||
|
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||||
|
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
||||||
|
|
||||||
|
interface GuildTemplatesSectionProps {
|
||||||
|
guildId: string;
|
||||||
|
/** Имя сервера — подсказка для имени шаблона (сервер подставит его сам). */
|
||||||
|
guildName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «Шаблоны сервера»: сохранить структуру текущего сервера (роли с правами и
|
||||||
|
* цветом, категории и комнаты) личным шаблоном и удалить ненужные (AGENT.md
|
||||||
|
* 7.3, Фаза 7). Шаблоны приватны: сервер отдаёт только шаблоны владельца.
|
||||||
|
*/
|
||||||
|
export function GuildTemplatesSection({ guildId, guildName }: GuildTemplatesSectionProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [removing, setRemoving] = useState<GuildTemplate | null>(null);
|
||||||
|
|
||||||
|
const list = useQuery({
|
||||||
|
queryKey: myGuildTemplatesQueryKey,
|
||||||
|
queryFn: ({ signal }) => fetchMyGuildTemplates(signal),
|
||||||
|
retry: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const templates = list.data ?? [];
|
||||||
|
const failed = list.isError && list.data === undefined;
|
||||||
|
const full = templates.length >= MY_GUILD_TEMPLATE_LIMIT;
|
||||||
|
|
||||||
|
const create = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
createGuildTemplate({
|
||||||
|
guildId,
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
setName('');
|
||||||
|
setDescription('');
|
||||||
|
setSaved(true);
|
||||||
|
void queryClient.invalidateQueries({ queryKey: myGuildTemplatesQueryKey });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: (templateId: string) => deleteGuildTemplate(templateId),
|
||||||
|
onSuccess: () => {
|
||||||
|
setRemoving(null);
|
||||||
|
void queryClient.invalidateQueries({ queryKey: myGuildTemplatesQueryKey });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = (event: FormEvent<HTMLFormElement>): void => {
|
||||||
|
event.preventDefault();
|
||||||
|
setSaved(false);
|
||||||
|
create.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsSection
|
||||||
|
title={t('settings.servers.templates.title')}
|
||||||
|
description={t('settings.servers.templates.description', { limit: MY_GUILD_TEMPLATE_LIMIT })}
|
||||||
|
>
|
||||||
|
<p className="mt-3 text-sm text-fg-muted" data-testid="guild-templates-counter">
|
||||||
|
{t('settings.servers.templates.counter', {
|
||||||
|
count: templates.length,
|
||||||
|
limit: MY_GUILD_TEMPLATE_LIMIT,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{list.isPending ? (
|
||||||
|
<SkeletonLines className="mt-4" rows={2} />
|
||||||
|
) : failed ? (
|
||||||
|
<ErrorNotice className="mt-4" error={list.error} onRetry={() => void list.refetch()} />
|
||||||
|
) : templates.length === 0 ? (
|
||||||
|
<p className="mt-4 text-sm text-fg-muted">{t('settings.servers.templates.empty')}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="gl-stagger mt-4 flex flex-col gap-1" data-testid="my-guild-templates">
|
||||||
|
{templates.map((template) => (
|
||||||
|
<li
|
||||||
|
key={template.id}
|
||||||
|
data-testid={`my-guild-template-${template.id}`}
|
||||||
|
className="flex items-center gap-3 rounded-[var(--radius-md)] px-2 py-1.5 transition-colors duration-[var(--duration-fast)] hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<span className="truncate text-sm font-medium">{template.name}</span>
|
||||||
|
<span className="truncate text-xs text-fg-muted">
|
||||||
|
{t('settings.servers.templates.counts', {
|
||||||
|
text: template.text_channels,
|
||||||
|
voice: template.voice_channels,
|
||||||
|
roles: template.roles.length,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="border-danger/50 text-danger"
|
||||||
|
aria-label={t('settings.servers.templates.deleteLabel', { name: template.name })}
|
||||||
|
onClick={() => {
|
||||||
|
remove.reset();
|
||||||
|
setRemoving(template);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('settings.servers.templates.delete')}
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{remove.isError ? <ErrorNotice className="mt-3" error={remove.error} /> : null}
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="mt-5 flex flex-col gap-3 border-t border-border/40 pt-4"
|
||||||
|
data-testid="guild-templates-form"
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
>
|
||||||
|
<h3 className="text-sm font-semibold">{t('settings.servers.templates.saveTitle')}</h3>
|
||||||
|
<p className="text-xs text-fg-muted">{t('settings.servers.templates.saveHint')}</p>
|
||||||
|
{full ? (
|
||||||
|
<p className="text-xs text-warning" role="status">
|
||||||
|
{t('errors.template.limit_reached')}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label={t('settings.servers.templates.name')}
|
||||||
|
name="guild_template_name"
|
||||||
|
value={name}
|
||||||
|
maxLength={64}
|
||||||
|
placeholder={guildName}
|
||||||
|
hint={t('settings.servers.templates.nameHint', { name: guildName })}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
/>
|
||||||
|
<TextAreaField
|
||||||
|
label={t('settings.servers.templates.templateDescription')}
|
||||||
|
name="guild_template_description"
|
||||||
|
rows={2}
|
||||||
|
maxLength={200}
|
||||||
|
placeholder={t('settings.servers.templates.descriptionPlaceholder')}
|
||||||
|
value={description}
|
||||||
|
onChange={(event) => setDescription(event.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{create.isError ? <ErrorNotice error={create.error} /> : null}
|
||||||
|
{saved && !create.isError ? (
|
||||||
|
<p role="status" className="text-sm text-success">
|
||||||
|
{t('settings.servers.templates.saved')}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="submit" disabled={create.isPending || full}>
|
||||||
|
{t(create.isPending ? 'common.saving' : 'settings.servers.templates.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={removing !== null}
|
||||||
|
title={t('settings.servers.templates.deleteConfirmTitle')}
|
||||||
|
onClose={() => setRemoving(null)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={() => setRemoving(null)}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="border-danger/50 text-danger"
|
||||||
|
disabled={remove.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (removing !== null) {
|
||||||
|
remove.mutate(removing.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('settings.servers.templates.deleteConfirm')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-fg-muted">
|
||||||
|
{t('settings.servers.templates.deleteConfirmBody', {
|
||||||
|
name: removing === null ? '' : removing.name,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</Modal>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { screen, waitFor, within } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
|
import {
|
||||||
|
bodyOf,
|
||||||
|
findRequest,
|
||||||
|
installFetch,
|
||||||
|
json,
|
||||||
|
makeUser,
|
||||||
|
recordedRequests,
|
||||||
|
renderApp,
|
||||||
|
resetStores,
|
||||||
|
type FetchRoute,
|
||||||
|
} from './helpers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Личные шаблоны серверов (AGENT.md 7.3, Фаза 7): модалка создания сервера
|
||||||
|
* показывает свои шаблоны рядом со встроенными и отправляет `personal:<id>`,
|
||||||
|
* а секция «Шаблоны сервера» сохраняет структуру и удаляет шаблоны.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const user = makeUser();
|
||||||
|
|
||||||
|
const builtinTemplate = {
|
||||||
|
id: 'empty',
|
||||||
|
name: 'Пустой',
|
||||||
|
description: 'Одна общая текстовая комната.',
|
||||||
|
roles: ['Администратор', 'Пользователь'],
|
||||||
|
text_channels: 1,
|
||||||
|
voice_channels: 0,
|
||||||
|
categories: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const myTemplate = {
|
||||||
|
id: 't-1',
|
||||||
|
name: 'Моя структура',
|
||||||
|
description: 'Снято с сервера',
|
||||||
|
roles: ['Администратор', 'Пользователь', 'Модератор'],
|
||||||
|
text_channels: 2,
|
||||||
|
voice_channels: 1,
|
||||||
|
categories: ['Информация'],
|
||||||
|
created_at: '2026-09-26T10:00:00.000Z',
|
||||||
|
source_guild_id: 'g-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
const guildSummary = {
|
||||||
|
id: 'g-1',
|
||||||
|
name: 'Сервер',
|
||||||
|
description: '',
|
||||||
|
owner_id: 'user-9',
|
||||||
|
is_main: true,
|
||||||
|
member_count: 1,
|
||||||
|
roles: [],
|
||||||
|
my_role_ids: [],
|
||||||
|
my_permissions: ['MANAGE_GUILD'],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Маршруты оболочки приложения, каталога шаблонов и своих шаблонов. */
|
||||||
|
function appRoutes(extra: FetchRoute[] = []): FetchRoute[] {
|
||||||
|
return [
|
||||||
|
{ match: '/api/v1/guild-templates/mine', response: () => json({ templates: [myTemplate] }) },
|
||||||
|
{ match: '/api/v1/guild-templates', response: () => json({ templates: [builtinTemplate] }) },
|
||||||
|
...extra,
|
||||||
|
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [guildSummary] }) },
|
||||||
|
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
||||||
|
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
|
||||||
|
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members: [] }) },
|
||||||
|
{ match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) },
|
||||||
|
{ match: '/api/v1/guilds/g-1/channels', response: () => json({ channels: [] }) },
|
||||||
|
{ match: '/api/v1/guilds/g-1', response: () => json({ guild: guildSummary }) },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Открывает модалку создания сервера и дожидается списка шаблонов. */
|
||||||
|
async function openCreateGuildModal() {
|
||||||
|
renderApp('/app');
|
||||||
|
await userEvent.click(await screen.findByLabelText('Создать сервер'));
|
||||||
|
return screen.findByRole('dialog', { name: 'Создать сервер' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Открывает настройки сервера и дожидается секции «Шаблоны сервера». */
|
||||||
|
async function openTemplatesSection() {
|
||||||
|
renderApp('/settings/servers/g-1');
|
||||||
|
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
|
||||||
|
const counter = await within(dialog).findByTestId('guild-templates-counter');
|
||||||
|
return { dialog, counter };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetStores();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('создание сервера по своему шаблону', () => {
|
||||||
|
it('показывает свои шаблоны и отправляет template_id personal:<id>', async () => {
|
||||||
|
const fetchMock = installFetch(
|
||||||
|
appRoutes([
|
||||||
|
{
|
||||||
|
match: '/api/v1/guilds',
|
||||||
|
method: 'POST',
|
||||||
|
response: () =>
|
||||||
|
json({
|
||||||
|
guild: {
|
||||||
|
id: 'g-new',
|
||||||
|
name: 'Новый',
|
||||||
|
owner_id: 'user-1',
|
||||||
|
is_main: false,
|
||||||
|
member_count: 1,
|
||||||
|
my_role_ids: [],
|
||||||
|
my_permissions: ['ADMINISTRATOR'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const dialog = await openCreateGuildModal();
|
||||||
|
const option = await within(dialog).findByTestId('guild-template-personal-t-1');
|
||||||
|
expect(within(option).getByText('Моя структура')).toBeVisible();
|
||||||
|
// Счётчики как у встроенных шаблонов: 2 текстовые, 1 голосовая, 3 роли.
|
||||||
|
expect(within(option).getByText(/2 текст/)).toBeVisible();
|
||||||
|
|
||||||
|
await userEvent.click(within(option).getByRole('radio'));
|
||||||
|
await userEvent.type(within(dialog).getByLabelText('Название сервера'), 'Новый');
|
||||||
|
await userEvent.click(within(dialog).getByRole('button', { name: 'Создать' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(bodyOf(findRequest(fetchMock, { url: '/guilds', method: 'POST' }))).toEqual({
|
||||||
|
name: 'Новый',
|
||||||
|
template_id: 'personal:t-1',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('без своих шаблонов показывает подсказку и встроенные шаблоны', async () => {
|
||||||
|
installFetch([
|
||||||
|
{ match: '/api/v1/guild-templates/mine', response: () => json({ templates: [] }) },
|
||||||
|
{ match: '/api/v1/guild-templates', response: () => json({ templates: [builtinTemplate] }) },
|
||||||
|
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [guildSummary] }) },
|
||||||
|
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
||||||
|
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const dialog = await openCreateGuildModal();
|
||||||
|
|
||||||
|
expect(await within(dialog).findByTestId('guild-template-empty')).toBeVisible();
|
||||||
|
expect(within(dialog).getByTestId('my-guild-templates')).toBeVisible();
|
||||||
|
expect(within(dialog).getByText(/Своих шаблонов пока нет/)).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('настройки сервера: секция «Шаблоны сервера»', () => {
|
||||||
|
it('сохраняет структуру сервера и удаляет шаблон', async () => {
|
||||||
|
const fetchMock = installFetch(
|
||||||
|
appRoutes([
|
||||||
|
{
|
||||||
|
match: '/api/v1/guild-templates',
|
||||||
|
method: 'POST',
|
||||||
|
response: () => json({ template: { ...myTemplate, id: 't-2', name: 'Копия' } }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: '/api/v1/guild-templates/t-1',
|
||||||
|
method: 'DELETE',
|
||||||
|
response: () => json({ ok: true }),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { dialog } = await openTemplatesSection();
|
||||||
|
expect(await within(dialog).findByTestId('my-guild-template-t-1')).toBeVisible();
|
||||||
|
|
||||||
|
// Поля формы «Сохранить структуру как шаблон»: «Описание» есть и у секции
|
||||||
|
// «Общее», поэтому подпись шаблона отличается, а поиск идёт внутри формы.
|
||||||
|
const form = within(dialog).getByTestId('guild-templates-form');
|
||||||
|
await userEvent.type(within(form).getByLabelText('Название шаблона'), 'Копия');
|
||||||
|
await userEvent.type(within(form).getByLabelText('Описание шаблона'), 'Снимок структуры');
|
||||||
|
await userEvent.click(within(form).getByRole('button', { name: 'Сохранить шаблон' }));
|
||||||
|
|
||||||
|
expect(await within(dialog).findByText('Шаблон сохранён.')).toBeVisible();
|
||||||
|
const post = recordedRequests(fetchMock).find(
|
||||||
|
(request) => request.url.includes('/guild-templates') && request.method === 'POST',
|
||||||
|
);
|
||||||
|
expect(post?.url).toContain('/api/v1/guild-templates');
|
||||||
|
expect(post?.body).toEqual({
|
||||||
|
guild_id: 'g-1',
|
||||||
|
name: 'Копия',
|
||||||
|
description: 'Снимок структуры',
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(
|
||||||
|
within(dialog).getByRole('button', { name: 'Удалить шаблон Моя структура' }),
|
||||||
|
);
|
||||||
|
const confirm = await screen.findByRole('dialog', { name: 'Удалить шаблон?' });
|
||||||
|
await userEvent.click(within(confirm).getByRole('button', { name: 'Удалить' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
findRequest(fetchMock, { url: '/guild-templates/t-1', method: 'DELETE' }),
|
||||||
|
).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('не показывает секцию участнику без права MANAGE_GUILD', async () => {
|
||||||
|
const member = { ...guildSummary, owner_id: 'user-9', my_permissions: ['VIEW_GUILD'] };
|
||||||
|
installFetch([
|
||||||
|
{ match: '/api/v1/guild-templates/mine', response: () => json({ templates: [myTemplate] }) },
|
||||||
|
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [member] }) },
|
||||||
|
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
||||||
|
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
|
||||||
|
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members: [] }) },
|
||||||
|
{ match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) },
|
||||||
|
{ match: '/api/v1/guilds/g-1/channels', response: () => json({ channels: [] }) },
|
||||||
|
{ match: '/api/v1/guilds/g-1', response: () => json({ guild: member }) },
|
||||||
|
]);
|
||||||
|
|
||||||
|
renderApp('/settings/servers/g-1');
|
||||||
|
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
|
||||||
|
// Секция «Общее» отрисовалась — значит страница настроек загрузилась.
|
||||||
|
expect(await within(dialog).findByLabelText('Название сервера')).toBeVisible();
|
||||||
|
expect(within(dialog).queryByTestId('guild-templates-counter')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user