Files
glchat/web/src/pages/settings/GuildCosmeticsSection.tsx
T
grendervill 085454d555 feat(web): палитра команд Ctrl/Cmd+K и подтверждение выхода с несохранёнными правками
Два требования 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` зелёные.
2026-09-22 23:17:32 +03:00

244 lines
8.8 KiB
TypeScript

import { useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { fileContentUrl } from '@/api/files';
import {
COSMETICS_MAX_PER_GUILD,
COSMETIC_MAX_BYTES,
COSMETIC_NAME_MAX,
deleteGuildCosmetic,
fetchGuildCosmetics,
guildCosmeticsQueryKey,
renameGuildCosmetic,
uploadGuildCosmetic,
type CosmeticKind,
type GuildCosmetic,
} from '@/api/cosmetics';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field } from '@/components/ui/Field';
import { Button } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton';
import { useUnsavedChanges } from '@/lib/unsavedChanges';
import { SettingsSection } from '@/pages/settings/SettingsSection';
interface GuildCosmeticsSectionProps {
guildId: string;
canManage: boolean;
}
/**
* «Оформление» сервера (AGENT.md 7.4): галерея рамок и иконок. Файлы хранятся
* обычными загрузками, поэтому анимированные GIF/WebP сохраняют анимацию;
* элементы выдаются ролям в разделе «Роли».
*/
export function GuildCosmeticsSection({ guildId, canManage }: GuildCosmeticsSectionProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const frameInput = useRef<HTMLInputElement | null>(null);
const badgeInput = useRef<HTMLInputElement | null>(null);
const [renaming, setRenaming] = useState<GuildCosmetic | null>(null);
const [name, setName] = useState('');
const [error, setError] = useState<unknown>(null);
const cosmetics = useQuery({
queryKey: guildCosmeticsQueryKey(guildId),
queryFn: ({ signal }) => fetchGuildCosmetics(guildId, signal),
enabled: canManage,
retry: 0,
});
const invalidate = (): void => {
void queryClient.invalidateQueries({ queryKey: guildCosmeticsQueryKey(guildId) });
};
const upload = useMutation({
mutationFn: (input: { kind: CosmeticKind; name: string; file: File }) =>
uploadGuildCosmetic(guildId, input),
onSuccess: () => {
setError(null);
invalidate();
},
onError: (cause: unknown) => setError(cause),
});
const rename = useMutation({
mutationFn: (input: { id: string; name: string }) =>
renameGuildCosmetic(guildId, input.id, input.name),
onSuccess: () => {
setRenaming(null);
invalidate();
},
onError: (cause: unknown) => setError(cause),
});
const remove = useMutation({
mutationFn: (id: string) => deleteGuildCosmetic(guildId, id),
onSuccess: invalidate,
onError: (cause: unknown) => setError(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() });
},
});
if (!canManage) {
return null;
}
/** Проверяем файл на клиенте: сервер всё равно проверит, но так быстрее. */
const pick = (kind: CosmeticKind, file: File | undefined): void => {
if (file === undefined) {
return;
}
if (!file.type.startsWith('image/')) {
setError(new Error(t('settings.servers.cosmetics.notImage')));
return;
}
if (file.size > COSMETIC_MAX_BYTES) {
setError(new Error(t('settings.servers.cosmetics.tooLarge')));
return;
}
upload.mutate({ kind, name: file.name.replace(/\.[^.]+$/u, ''), file });
};
const list = cosmetics.data ?? [];
const frames = list.filter((item) => item.kind === 'frame');
const badges = list.filter((item) => item.kind === 'badge');
return (
<SettingsSection
title={t('settings.servers.cosmetics.title')}
description={t('settings.servers.cosmetics.description')}
>
<div className="mt-4 flex flex-col gap-3" data-testid="guild-cosmetics">
{cosmetics.isPending ? <SkeletonLines rows={2} /> : null}
{cosmetics.isError ? (
<ErrorNotice error={cosmetics.error} onRetry={() => void cosmetics.refetch()} />
) : null}
{error === null ? null : <ErrorNotice error={error} />}
<p className="text-xs text-fg-muted">
{t('settings.servers.cosmetics.limit', {
used: list.length,
total: COSMETICS_MAX_PER_GUILD,
})}
</p>
{[
{ kind: 'frame' as const, items: frames, input: frameInput },
{ kind: 'badge' as const, items: badges, input: badgeInput },
].map((group) => (
<div key={group.kind} className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{t(`settings.servers.cosmetics.kind.${group.kind}`)}
</span>
<Button
variant="ghost"
data-testid={`cosmetic-upload-${group.kind}`}
disabled={upload.isPending || list.length >= COSMETICS_MAX_PER_GUILD}
onClick={() => group.input.current?.click()}
>
{t('settings.servers.cosmetics.upload')}
</Button>
<input
ref={group.input}
type="file"
accept="image/*"
aria-label={t(`settings.servers.cosmetics.kind.${group.kind}`)}
data-testid={`cosmetic-input-${group.kind}`}
className="sr-only"
onChange={(event) => {
pick(group.kind, event.target.files?.[0]);
event.target.value = '';
}}
/>
</div>
{group.items.length === 0 ? (
<p className="text-xs text-fg-muted">{t('settings.servers.cosmetics.empty')}</p>
) : (
<ul className="flex flex-wrap gap-2" data-testid={`cosmetic-list-${group.kind}`}>
{group.items.map((item) => (
<li
key={item.id}
data-testid={`cosmetic-${item.id}`}
className="flex w-40 flex-col items-center gap-1 rounded-[var(--radius-md)] border border-border/50 bg-surface-2 p-2"
>
<img
src={fileContentUrl(item.file_id)}
alt=""
loading="lazy"
className="h-14 w-14 object-contain"
/>
<span className="w-full truncate text-center text-xs" title={item.name}>
{item.name}
</span>
<div className="flex gap-1">
<Button
variant="ghost"
data-testid={`cosmetic-rename-${item.id}`}
onClick={() => {
setRenaming(item);
setName(item.name);
}}
>
{t('settings.channel.webhooks.rename')}
</Button>
<Button
variant="ghost"
className="border border-danger/50 text-danger"
data-testid={`cosmetic-delete-${item.id}`}
disabled={remove.isPending}
onClick={() => {
if (window.confirm(t('settings.servers.cosmetics.deleteConfirm'))) {
remove.mutate(item.id);
}
}}
>
{t('common.delete')}
</Button>
</div>
</li>
))}
</ul>
)}
</div>
))}
{renaming === null ? null : (
<form
className="flex items-end gap-2"
onSubmit={(event) => {
event.preventDefault();
if (name.trim() !== '') {
rename.mutate({ id: renaming.id, name: name.trim() });
}
}}
>
<Field
label={t('settings.servers.cosmetics.name')}
value={name}
maxLength={COSMETIC_NAME_MAX}
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>
</SettingsSection>
);
}