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` зелёные.
244 lines
8.8 KiB
TypeScript
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>
|
|
);
|
|
}
|