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` зелёные.
This commit is contained in:
2026-09-22 23:17:32 +03:00
parent 72c5189e8f
commit 085454d555
26 changed files with 1818 additions and 34 deletions
@@ -20,6 +20,7 @@ import { SkeletonLines } from '@/components/ui/Skeleton';
import { stepUp } from '@/api/auth';
import { errorCode } from '@/lib/format';
import { useCurrentUser } from '@/lib/hooks';
import { useUnsavedChanges } from '@/lib/unsavedChanges';
import { SettingsSection } from '@/pages/settings/SettingsSection';
/** Сколько держится подсказка «ссылка скопирована», мс. */
@@ -131,6 +132,17 @@ export function ChannelWebhooksSection({ channelId, canManage }: ChannelWebhooks
onSuccess: invalidate,
});
// Незаконченное переименование вебхука (форма открыта в отдельном окне).
useUnsavedChanges({
dirty: renaming !== null && renameValue.trim() !== '' && renameValue.trim() !== renaming.name,
save: async () => {
if (renaming === null || renameValue.trim() === '') {
return;
}
await rename.mutateAsync(renaming);
},
});
if (!canManage) {
return null;
}
@@ -19,6 +19,7 @@ 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 {
@@ -77,6 +78,17 @@ export function GuildCosmeticsSection({ guildId, canManage }: GuildCosmeticsSect
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;
}
@@ -22,6 +22,7 @@ import { Badge, Button } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton';
import { customEmojiShortcode } from '@/lib/customEmoji';
import { formatBytes } from '@/lib/format';
import { useUnsavedChanges } from '@/lib/unsavedChanges';
import { useSessionStore } from '@/stores/session';
import { SettingsSection } from '@/pages/settings/SettingsSection';
@@ -143,6 +144,21 @@ export function GuildEmojisSection({ guildId }: GuildEmojisSectionProps) {
pickFile(event.dataTransfer.files[0]);
};
/*
* Незаконченная загрузка (имя и выбранный файл) — единственный черновик
* раздела: переименование и удаление уходят на сервер сразу. В реестр
* попадает только заполненная форма — невалидную всё равно не сохранить.
*/
useUnsavedChanges({
dirty: file !== null && isValidEmojiName(name),
save: async () => {
if (file === null) {
return;
}
await upload.mutateAsync({ name, file });
},
});
return (
<SettingsSection
title={t('settings.servers.emojis.title')}
@@ -8,6 +8,7 @@ import type { Guild } from '@/api/types';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field, TextAreaField } from '@/components/ui/Field';
import { Button } from '@/components/ui/primitives';
import { useUnsavedChanges } from '@/lib/unsavedChanges';
import { useSessionStore } from '@/stores/session';
import { SettingsSection } from '@/pages/settings/SettingsSection';
@@ -46,6 +47,14 @@ export function GuildGeneralSection({ guild, canEdit }: GuildGeneralSectionProps
save.mutate();
};
// Незавершённая правка имени или описания: закрытие окна не должно её терять.
useUnsavedChanges({
dirty: name !== guild.name || description !== guild.description,
save: async () => {
await save.mutateAsync();
},
});
return (
<SettingsSection
title={t('settings.servers.general.title')}
@@ -7,6 +7,7 @@ import type { GuildMember } from '@/api/types';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field } from '@/components/ui/Field';
import { Button } from '@/components/ui/primitives';
import { useUnsavedChanges } from '@/lib/unsavedChanges';
import { useSessionStore } from '@/stores/session';
import { SettingsSection } from '@/pages/settings/SettingsSection';
@@ -70,6 +71,14 @@ export function GuildNicknameSection({
save.mutate(nickname.trim());
};
// Поле тронули, но не сохранили (`typed === null` означает «не трогали»).
useUnsavedChanges({
dirty: typed !== null && typed.trim() !== currentNickname,
save: async () => {
await save.mutateAsync(nickname.trim());
},
});
return (
<SettingsSection
title={t('settings.servers.nickname.title')}
@@ -23,6 +23,7 @@ import { Modal } from '@/components/ui/Modal';
import { Badge, Button } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton';
import { formatBytes } from '@/lib/format';
import { useUnsavedChanges } from '@/lib/unsavedChanges';
import { useSessionStore } from '@/stores/session';
import { SettingsSection } from '@/pages/settings/SettingsSection';
@@ -171,6 +172,27 @@ export function GuildSoundsSection({ guildId }: GuildSoundsSectionProps) {
pickFile(event_.dataTransfer.files[0]);
};
/*
* Незаконченная загрузка звука (имя и выбранный файл) — единственный черновик
* раздела: переименование и удаление уходят на сервер сразу. В реестр попадает
* только заполненная форма — невалидную всё равно не сохранить.
*/
useUnsavedChanges({
dirty: file !== null && isValidSoundName(name),
save: async () => {
if (file === null) {
return;
}
await upload.mutateAsync({
name,
file,
kind,
...(kind === 'ui' ? { event } : {}),
...(emoji.trim() === '' ? {} : { emoji: emoji.trim() }),
});
},
});
/** Строка списка: имя, событие, эмодзи, переименование и удаление. */
const renderRow = (sound: GuildSound) => (
<li
+24 -6
View File
@@ -19,6 +19,7 @@ import { SkeletonLines } from '@/components/ui/Skeleton';
import { formatBytes } from '@/lib/format';
import { useCurrentUser } from '@/lib/hooks';
import { revokeObjectUrl, safeObjectUrl } from '@/lib/objectUrl';
import { useUnsavedChanges } from '@/lib/unsavedChanges';
import { useSessionStore } from '@/stores/session';
import { ProfileStyleSection } from '@/pages/settings/ProfileStyleSection';
import { SettingsSection } from '@/pages/settings/SettingsSection';
@@ -136,18 +137,35 @@ export function ProfileForm({ user }: { user: User }) {
fileInputRef.current?.click();
}, []);
/**
* Черновик полей — то, что потерялось бы при закрытии окна. Аватар сюда не
* входит: он уходит на сервер сразу после выбора файла.
*/
const draft: UpdateProfileInput = {
display_name: displayName.trim(),
bio: bio.trim(),
custom_status: customStatus.trim(),
custom_status_emoji: customEmoji.trim(),
};
const onSubmit = (event: FormEvent<HTMLFormElement>): void => {
event.preventDefault();
setSaved(false);
// Часовой пояс и статус присутствия форма не трогает: у них свои места.
save.mutate({
display_name: displayName.trim(),
bio: bio.trim(),
custom_status: customStatus.trim(),
custom_status_emoji: customEmoji.trim(),
});
save.mutate(draft);
};
useUnsavedChanges({
dirty:
displayName !== user.display_name ||
bio !== user.bio ||
customStatus !== user.custom_status ||
customEmoji !== user.custom_status_emoji,
save: async () => {
await save.mutateAsync(draft);
},
});
const avatarName = displayName === '' ? user.username : displayName;
const avatarBusy = upload.isPending || removeAvatar.isPending;
// Форматы и лимит живут в подсказке к аватару, а не в тексте формы.