feat(guilds): встроенные шаблоны серверов
- каталог шаблонов «Пустой», «Сообщество», «Игровой» (AGENT.md 7.3): роли, категории, текстовые и голосовые комнаты без сообщений - POST /guilds принимает template_id, неизвестный шаблон отклоняется; аудит фиксирует выбранный шаблон - GET /guild-templates отдаёт каталог с предпросмотром структуры - клиент: выбор шаблона в окне создания сервера - тесты: Go (структура каждого шаблона) и Vitest (выбор шаблона)
This commit is contained in:
+25
-2
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -194,7 +194,9 @@
|
||||
"hint": "Название можно изменить позже в настройках сервера.",
|
||||
"submit": "Создать",
|
||||
"limit": "На инстансе можно создать не более {{count}} серверов.",
|
||||
"limitReached": "Достигнут лимит серверов — создайте новый после удаления одного из текущих."
|
||||
"limitReached": "Достигнут лимит серверов — создайте новый после удаления одного из текущих.",
|
||||
"template": "Шаблон сервера",
|
||||
"templateCounts": "{{text}} текст. · {{voice}} голос. · {{roles}} роли"
|
||||
},
|
||||
"empty": {
|
||||
"title": "У вас пока нет серверов",
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
Reference in New Issue
Block a user