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
+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 })}