feat(guilds): встроенные шаблоны серверов

- каталог шаблонов «Пустой», «Сообщество», «Игровой» (AGENT.md 7.3): роли,
  категории, текстовые и голосовые комнаты без сообщений
- POST /guilds принимает template_id, неизвестный шаблон отклоняется; аудит
  фиксирует выбранный шаблон
- GET /guild-templates отдаёт каталог с предпросмотром структуры
- клиент: выбор шаблона в окне создания сервера
- тесты: Go (структура каждого шаблона) и Vitest (выбор шаблона)
This commit is contained in:
2026-09-21 18:04:52 +03:00
parent a36ee9527b
commit 91f415e36d
9 changed files with 572 additions and 15 deletions
+25 -2
View File
@@ -17,10 +17,33 @@ export const guildChannelsQueryKey = (guildId: string) => ['guilds', guildId, 'c
export const guildMembersQueryKey = (guildId: string) => ['guilds', guildId, 'members'] as const;
export const guildRolesQueryKey = (guildId: string) => ['guilds', guildId, 'roles'] as const;
export async function createGuild(name: string): Promise<GuildSummary> {
/** Встроенный шаблон структуры сервера (AGENT.md 7.3). */
export interface GuildTemplate {
id: string;
name: string;
description: string;
roles: string[];
text_channels: number;
voice_channels: number;
categories: string[];
}
export const guildTemplatesQueryKey = ['guild-templates'] as const;
/** `GET /guild-templates` — встроенные шаблоны серверов. */
export async function fetchGuildTemplates(signal?: AbortSignal): Promise<GuildTemplate[]> {
const payload = await request<{ templates: GuildTemplate[] }>(
'/guild-templates',
signal === undefined ? {} : { signal },
);
return payload.templates;
}
/** `POST /guilds` — создать сервер по шаблону (пусто — «Пустой»). */
export async function createGuild(name: string, templateId = ''): Promise<GuildSummary> {
const payload = await request<{ guild: GuildSummary }>('/guilds', {
method: 'POST',
body: { name },
body: templateId === '' ? { name } : { name, template_id: templateId },
});
return payload.guild;
}
+3 -1
View File
@@ -194,7 +194,9 @@
"hint": "You can rename it later in the server settings.",
"submit": "Create",
"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",
"templateCounts": "{{text}} text · {{voice}} voice · {{roles}} roles"
},
"empty": {
"title": "You have no servers yet",
+3 -1
View File
@@ -194,7 +194,9 @@
"hint": "Название можно изменить позже в настройках сервера.",
"submit": "Создать",
"limit": "На инстансе можно создать не более {{count}} серверов.",
"limitReached": "Достигнут лимит серверов — создайте новый после удаления одного из текущих."
"limitReached": "Достигнут лимит серверов — создайте новый после удаления одного из текущих.",
"template": "Шаблон сервера",
"templateCounts": "{{text}} текст. · {{voice}} голос. · {{roles}} роли"
},
"empty": {
"title": "У вас пока нет серверов",
+50 -3
View File
@@ -1,9 +1,9 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
import { createGuild } from '@/api/guilds';
import { createGuild, fetchGuildTemplates, guildTemplatesQueryKey } from '@/api/guilds';
import { instanceQueryKey } from '@/api/instance';
import { myGuildsQueryKey } from '@/api/users';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
@@ -31,9 +31,19 @@ export function CreateGuildModal() {
const upsertGuild = useSessionStore((state) => state.upsertGuild);
const [name, setName] = useState('');
const [templateId, setTemplateId] = useState('empty');
// Шаблоны структуры: список небольшой и статический, кэшируется на сессию.
const templates = useQuery({
queryKey: guildTemplatesQueryKey,
queryFn: ({ signal }) => fetchGuildTemplates(signal),
enabled: open,
staleTime: 300_000,
retry: 0,
});
const create = useMutation({
mutationFn: (guildName: string) => createGuild(guildName),
mutationFn: (guildName: string) => createGuild(guildName, templateId),
onSuccess: (guild) => {
close();
setName('');
@@ -83,6 +93,43 @@ export function CreateGuildModal() {
maxLength={64}
hint={t('guilds.create.hint')}
/>
{/* Шаблон задаёт структуру комнат и ролей (AGENT.md 7.3). */}
<fieldset className="flex flex-col gap-2" data-testid="guild-templates">
<legend className="text-sm font-medium">{t('guilds.create.template')}</legend>
{(templates.data ?? []).map((template) => (
<label
key={template.id}
data-testid={`guild-template-${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)] ${
templateId === template.id
? '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={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>
))}
{templates.isError ? (
<ErrorNotice error={templates.error} onRetry={() => void templates.refetch()} />
) : null}
</fieldset>
{instance.data === undefined ? null : (
<p className="text-xs text-fg-muted">
{t('guilds.create.limit', { count: instance.data.max_guilds_per_user })}