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:
2026-09-26 16:45:16 +03:00
parent 605b2038f1
commit 96e0cd5918
7 changed files with 625 additions and 32 deletions
+89 -29
View File
@@ -3,7 +3,14 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
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 { myGuildsQueryKey } from '@/api/users';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
@@ -14,6 +21,51 @@ import { useInstance } from '@/lib/hooks';
import { useSessionStore } from '@/stores/session';
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}`).
* Открывается из рейки и с пустого экрана «У вас пока нет серверов»,
@@ -33,7 +85,7 @@ export function CreateGuildModal() {
const [name, setName] = useState('');
const [templateId, setTemplateId] = useState('empty');
// Шаблоны структуры: список небольшой и статический, кэшируется на сессию.
// Встроенные шаблоны: список небольшой и статический, кэшируется на сессию.
const templates = useQuery({
queryKey: guildTemplatesQueryKey,
queryFn: ({ signal }) => fetchGuildTemplates(signal),
@@ -42,6 +94,15 @@ export function CreateGuildModal() {
retry: 0,
});
// Свои шаблоны (Фаза 7): сервер отдаёт только шаблоны текущего пользователя.
const myTemplates = useQuery({
queryKey: myGuildTemplatesQueryKey,
queryFn: ({ signal }) => fetchMyGuildTemplates(signal),
enabled: open,
staleTime: 60_000,
retry: 0,
});
const create = useMutation({
mutationFn: (guildName: string) => createGuild(guildName, templateId),
onSuccess: (guild) => {
@@ -57,6 +118,8 @@ export function CreateGuildModal() {
const limitReached =
instance.data !== undefined && guilds.length >= instance.data.max_guilds_per_user;
const mine = myTemplates.data ?? [];
return (
<Modal
open={open}
@@ -97,35 +160,32 @@ export function CreateGuildModal() {
<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
<TemplateOption
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>
value={template.id}
testId={`guild-template-${template.id}`}
template={template}
selected={templateId === template.id}
onSelect={setTemplateId}
/>
))}
<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 ? (
<ErrorNotice error={templates.error} onRetry={() => void templates.refetch()} />
) : null}