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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
// Форматы и лимит живут в подсказке к аватару, а не в тексте формы.
|
||||
|
||||
Reference in New Issue
Block a user