085454d555
Два требования desktop-клиента (docs/client-tauri.md §5), которые живут в веб-слое — том же, что и в браузере: - **Палитра команд.** Ctrl/Cmd+K (на документе или сообщением `openPalette` от обёртки) открывает быстрый переход: серверы (в первую доступную комнату), комнаты выбранного сервера с группировкой по категориям, личные беседы из уже загруженного снапшота, действия (настройки профиля/безопасности/внешнего вида, настройки сервера, создание сервера, друзья, микрофон и «не слышу» при активной голосовой сессии). Новых запросов палитра не делает; поиск по подстроке без учёта регистра, 8 пунктов на раздел, стрелки со `scrollIntoView`, `role=listbox/option` и `aria-activedescendant`; - **Подтверждение выхода.** Формы с локальным черновиком регистрируются в общем реестре (`stores/unsaved.ts` + хук `useUnsavedChanges`): платформа получает один `setDirty` на переход «есть правки / нет правок», а на сообщение обёртки `closeRequested` клиент отвечает тряской контента и диалогом «Сохранить / Выйти без сохранения». При `prefers-reduced-motion: reduce` тряску заменяет подсветка рамки; в браузере вместо сообщения обёртки работает штатный `beforeunload`. Хук добавлен в формы с явной кнопкой «Сохранить»: профиль, общие настройки сервера, ник, эмодзи, звуки, оформление, вебхуки, лимиты инстанса, переименование сервера (автосохраняемые переключатели намеренно не помечаются); - Deep link `glchat://dm/<channelId>` (личная беседа без сервера) — его присылает обёртка при клике по уведомлению о личном сообщении. Тесты: 525 passed (501 база + 24 новых) — палитра, реестр правок, диалог закрытия, `beforeunload`, разбор ссылок; `npm run check` и `npm run lint` зелёные.
173 lines
6.0 KiB
TypeScript
173 lines
6.0 KiB
TypeScript
import { useState } from 'react';
|
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import {
|
|
deleteInstanceGuild,
|
|
instanceGuildsQueryKey,
|
|
instanceUsersQueryKey,
|
|
updateInstanceGuild,
|
|
} from '@/api/instance';
|
|
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
|
import { Field } from '@/components/ui/Field';
|
|
import { Button } from '@/components/ui/primitives';
|
|
import { useUnsavedChanges } from '@/lib/unsavedChanges';
|
|
import type { InstanceGuild } from '@/api/types';
|
|
|
|
interface InstanceGuildsPanelProps {
|
|
guilds: InstanceGuild[];
|
|
isPending: boolean;
|
|
error: unknown;
|
|
onRetry: () => void;
|
|
/** Открыть настройки сервера: админ правит комнаты, роли и ники там. */
|
|
onOpenSettings: (guildId: string) => void;
|
|
}
|
|
|
|
/**
|
|
* Серверы инстанса (AGENT.md 7.19): администратор переименовывает и удаляет
|
|
* любой сервер и открывает его настройки — комнаты, роли и ники правятся
|
|
* обычными разделами, права администратора инстанса действуют везде.
|
|
*/
|
|
export function InstanceGuildsPanel({
|
|
guilds,
|
|
isPending,
|
|
error,
|
|
onRetry,
|
|
onOpenSettings,
|
|
}: InstanceGuildsPanelProps) {
|
|
const { t } = useTranslation();
|
|
const queryClient = useQueryClient();
|
|
const [renaming, setRenaming] = useState<InstanceGuild | null>(null);
|
|
const [name, setName] = useState('');
|
|
const [actionError, setActionError] = useState<unknown>(null);
|
|
|
|
const invalidate = (): void => {
|
|
void queryClient.invalidateQueries({ queryKey: instanceGuildsQueryKey });
|
|
void queryClient.invalidateQueries({ queryKey: instanceUsersQueryKey });
|
|
};
|
|
|
|
const rename = useMutation({
|
|
mutationFn: (input: { id: string; name: string }) =>
|
|
updateInstanceGuild(input.id, { name: input.name }),
|
|
onSuccess: () => {
|
|
setActionError(null);
|
|
setRenaming(null);
|
|
invalidate();
|
|
},
|
|
onError: (cause: unknown) => setActionError(cause),
|
|
});
|
|
|
|
const remove = useMutation({
|
|
mutationFn: (guildId: string) => deleteInstanceGuild(guildId),
|
|
onSuccess: () => {
|
|
setActionError(null);
|
|
invalidate();
|
|
},
|
|
onError: (cause: unknown) => setActionError(cause),
|
|
});
|
|
|
|
// Незаконченное переименование сервера: черновик живёт в поле, пока форма открыта.
|
|
useUnsavedChanges({
|
|
dirty: renaming !== null && name.trim() !== '' && name.trim() !== renaming.name,
|
|
save: async () => {
|
|
if (renaming === null || name.trim() === '') {
|
|
return;
|
|
}
|
|
await rename.mutateAsync({ id: renaming.id, name: name.trim() });
|
|
},
|
|
});
|
|
|
|
const pending = rename.isPending || remove.isPending;
|
|
|
|
if (isPending) {
|
|
return <p className="mt-2 text-sm text-fg-muted">{t('common.loading')}</p>;
|
|
}
|
|
if (error !== null && error !== undefined) {
|
|
return <ErrorNotice className="mt-3" error={error} onRetry={onRetry} />;
|
|
}
|
|
if (guilds.length === 0) {
|
|
return <p className="mt-2 text-sm text-fg-muted">{t('settings.instance.guildsEmpty')}</p>;
|
|
}
|
|
|
|
return (
|
|
<div className="mt-3 flex flex-col gap-2" data-testid="instance-guilds-actions">
|
|
{actionError === null ? null : <ErrorNotice error={actionError} />}
|
|
<ul className="flex flex-col gap-1 text-sm" data-testid="instance-guilds">
|
|
{guilds.map((guild) => (
|
|
<li
|
|
key={guild.id}
|
|
data-testid={`instance-guild-${guild.id}`}
|
|
className="flex flex-wrap items-center gap-2 border-b border-border/40 py-2 last:border-b-0"
|
|
>
|
|
<span className="min-w-0 flex-1 truncate">{guild.name}</span>
|
|
<span className="shrink-0 text-xs text-fg-muted">
|
|
{guild.is_main ? `${t('settings.instance.mainBadge')} · ` : ''}
|
|
{t('settings.instance.memberCount', { count: guild.member_count })}
|
|
</span>
|
|
<Button
|
|
variant="ghost"
|
|
data-testid={`instance-guild-open-${guild.id}`}
|
|
onClick={() => onOpenSettings(guild.id)}
|
|
>
|
|
{t('settings.instance.actions.openSettings')}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
data-testid={`instance-guild-rename-${guild.id}`}
|
|
disabled={pending}
|
|
onClick={() => {
|
|
setRenaming(guild);
|
|
setName(guild.name);
|
|
}}
|
|
>
|
|
{t('settings.instance.actions.rename')}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
className="border border-danger/50 text-danger"
|
|
data-testid={`instance-guild-delete-${guild.id}`}
|
|
disabled={pending || guild.is_main}
|
|
onClick={() => {
|
|
if (
|
|
window.confirm(
|
|
t('settings.instance.actions.deleteGuildConfirm', { name: guild.name }),
|
|
)
|
|
) {
|
|
remove.mutate(guild.id);
|
|
}
|
|
}}
|
|
>
|
|
{t('common.delete')}
|
|
</Button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
|
|
{renaming === null ? null : (
|
|
<form
|
|
className="flex items-end gap-2"
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
if (renaming !== null && name.trim() !== '') {
|
|
rename.mutate({ id: renaming.id, name: name.trim() });
|
|
}
|
|
}}
|
|
>
|
|
<Field
|
|
label={t('settings.instance.actions.newName')}
|
|
value={name}
|
|
maxLength={64}
|
|
onChange={(event) => setName(event.target.value)}
|
|
/>
|
|
<Button type="submit" disabled={rename.isPending}>
|
|
{t('common.save')}
|
|
</Button>
|
|
<Button variant="ghost" onClick={() => setRenaming(null)}>
|
|
{t('common.cancel')}
|
|
</Button>
|
|
</form>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|