96e0cd5918
Модалка создания сервера показывает блок «Мои шаблоны» рядом со встроенными и отправляет 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.
208 lines
7.4 KiB
TypeScript
208 lines
7.4 KiB
TypeScript
import { useState } from 'react';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useNavigate } from 'react-router';
|
|
|
|
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';
|
|
import { Field } from '@/components/ui/Field';
|
|
import { Modal } from '@/components/ui/Modal';
|
|
import { Button } from '@/components/ui/primitives';
|
|
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}`).
|
|
* Открывается из рейки и с пустого экрана «У вас пока нет серверов»,
|
|
* поэтому смонтировано в `AppLayout`, а состояние — в `ui`-сторе.
|
|
*/
|
|
export function CreateGuildModal() {
|
|
const { t } = useTranslation();
|
|
const navigate = useNavigate();
|
|
const queryClient = useQueryClient();
|
|
const instance = useInstance();
|
|
|
|
const open = useUiStore((state) => state.guildCreateOpen);
|
|
const close = useUiStore((state) => state.closeGuildCreate);
|
|
const guilds = useSessionStore((state) => state.guilds);
|
|
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,
|
|
});
|
|
|
|
// Свои шаблоны (Фаза 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) => {
|
|
close();
|
|
setName('');
|
|
upsertGuild(guild);
|
|
void queryClient.invalidateQueries({ queryKey: myGuildsQueryKey });
|
|
void queryClient.invalidateQueries({ queryKey: instanceQueryKey });
|
|
void navigate(`/app/${guild.id}`);
|
|
},
|
|
});
|
|
|
|
const limitReached =
|
|
instance.data !== undefined && guilds.length >= instance.data.max_guilds_per_user;
|
|
|
|
const mine = myTemplates.data ?? [];
|
|
|
|
return (
|
|
<Modal
|
|
open={open}
|
|
title={t('guilds.create.title')}
|
|
onClose={close}
|
|
footer={
|
|
<>
|
|
<Button variant="ghost" onClick={close}>
|
|
{t('common.cancel')}
|
|
</Button>
|
|
<Button
|
|
onClick={() => create.mutate(name.trim())}
|
|
disabled={create.isPending || name.trim().length === 0}
|
|
>
|
|
{t(create.isPending ? 'common.creating' : 'guilds.create.submit')}
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<form
|
|
className="flex flex-col gap-3"
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
if (name.trim() !== '') {
|
|
create.mutate(name.trim());
|
|
}
|
|
}}
|
|
>
|
|
<Field
|
|
label={t('guilds.create.name')}
|
|
placeholder={t('guilds.create.namePlaceholder')}
|
|
value={name}
|
|
onChange={(event) => setName(event.target.value)}
|
|
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) => (
|
|
<TemplateOption
|
|
key={template.id}
|
|
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}
|
|
</fieldset>
|
|
{instance.data === undefined ? null : (
|
|
<p className="text-xs text-fg-muted">
|
|
{t('guilds.create.limit', { count: instance.data.max_guilds_per_user })}
|
|
</p>
|
|
)}
|
|
{limitReached && !create.isError ? (
|
|
<p className="text-xs text-warning" role="status">
|
|
{t('guilds.create.limitReached')}
|
|
</p>
|
|
) : null}
|
|
{create.isError ? <ErrorNotice error={create.error} /> : null}
|
|
</form>
|
|
</Modal>
|
|
);
|
|
}
|