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:
@@ -0,0 +1,459 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation, useNavigate } from 'react-router';
|
||||
|
||||
import type { Channel } from '@/api/types';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { groupChannels } from '@/lib/channels';
|
||||
import { useSessionStore, type SessionGuild } from '@/stores/session';
|
||||
import { useUiStore, type SettingsTarget } from '@/stores/ui';
|
||||
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
|
||||
|
||||
/** Сколько пунктов показываем в разделе: палитра — быстрый переход, не список. */
|
||||
const SECTION_LIMIT = 8;
|
||||
|
||||
interface PaletteItem {
|
||||
/** Устойчивый id: по нему строятся DOM-id опций и выбор мышью. */
|
||||
id: string;
|
||||
label: string;
|
||||
/** Серый хвост строки: например, сервер комнаты. */
|
||||
hint?: string;
|
||||
icon: string;
|
||||
/** Текст для поиска помимо подписи (имя сервера, синонимы). */
|
||||
keywords: string;
|
||||
run: () => void;
|
||||
}
|
||||
|
||||
interface PaletteSection {
|
||||
title: string;
|
||||
items: PaletteItem[];
|
||||
}
|
||||
|
||||
/** id опции в разметке: по нему `aria-activedescendant` находит активный пункт. */
|
||||
function optionId(id: string): string {
|
||||
return `palette-option-${id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Совпадение по подстроке без учёта регистра. `toLowerCase` одинаково работает
|
||||
* и с латиницей, и с кириллицей, поэтому раскладка значения не имеет.
|
||||
*/
|
||||
function matches(item: PaletteItem, needle: string): boolean {
|
||||
return `${item.label} ${item.hint ?? ''} ${item.keywords}`.toLowerCase().includes(needle);
|
||||
}
|
||||
|
||||
/** Оставляет только совпадения и обрезает разделы до `SECTION_LIMIT`. */
|
||||
function filterSections(sections: PaletteSection[], query: string): PaletteSection[] {
|
||||
const needle = query.trim().toLowerCase();
|
||||
const result: PaletteSection[] = [];
|
||||
for (const section of sections) {
|
||||
const items =
|
||||
needle === '' ? section.items : section.items.filter((item) => matches(item, needle));
|
||||
if (items.length > 0) {
|
||||
result.push({ title: section.title, items: items.slice(0, SECTION_LIMIT) });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Разделы палитры: серверы, комнаты выбранного сервера, беседы и действия. */
|
||||
function buildSections(input: {
|
||||
t: (key: string) => string;
|
||||
guilds: SessionGuild[];
|
||||
selectedGuildId: string | null;
|
||||
firstChannelId: (guildId: string) => string | null;
|
||||
dmChannels: Channel[];
|
||||
voiceActive: boolean;
|
||||
micEnabled: boolean;
|
||||
deafened: boolean;
|
||||
navigate: (path: string) => void;
|
||||
openSettings: (target?: SettingsTarget) => void;
|
||||
openGuildCreate: () => void;
|
||||
setMicEnabled: (enabled: boolean) => Promise<void>;
|
||||
setDeafened: (enabled: boolean) => Promise<void>;
|
||||
}): PaletteSection[] {
|
||||
const { t, guilds, selectedGuildId, firstChannelId, dmChannels, navigate } = input;
|
||||
const sections: PaletteSection[] = [];
|
||||
|
||||
// Серверы: ведём в первую доступную комнату — она же открывается при выборе
|
||||
// сервера в рейке.
|
||||
if (guilds.length > 0) {
|
||||
sections.push({
|
||||
title: t('palette.sections.guilds'),
|
||||
items: guilds.map((guild) => {
|
||||
const channelId = firstChannelId(guild.id);
|
||||
return {
|
||||
id: `guild-${guild.id}`,
|
||||
label: guild.name,
|
||||
icon: '🏠',
|
||||
keywords: t('palette.keywords.guild'),
|
||||
run: () => {
|
||||
navigate(channelId === null ? `/app/${guild.id}` : `/app/${guild.id}/${channelId}`);
|
||||
},
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// Комнаты выбранного сервера: та же группировка по категориям, что в сайдбаре.
|
||||
const selected = guilds.find((guild) => guild.id === selectedGuildId) ?? null;
|
||||
if (selected !== null) {
|
||||
for (const group of groupChannels(selected.channels)) {
|
||||
sections.push({
|
||||
title: group.category?.name ?? t('palette.sections.channels'),
|
||||
items: group.channels.map((channel) => ({
|
||||
id: `channel-${channel.id}`,
|
||||
label: channel.name,
|
||||
hint: selected.name,
|
||||
icon: channel.type === 'voice' ? '🔊' : '#',
|
||||
keywords: t('palette.keywords.channel'),
|
||||
run: () => {
|
||||
navigate(`/app/${selected.id}/${channel.id}`);
|
||||
},
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Личные беседы: берём уже загруженный снапшот, новых запросов не делаем.
|
||||
if (dmChannels.length > 0) {
|
||||
sections.push({
|
||||
title: t('palette.sections.dms'),
|
||||
items: dmChannels.map((channel) => ({
|
||||
id: `dm-${channel.id}`,
|
||||
label: channel.name === '' ? t('friends.dm.unknown') : channel.name,
|
||||
icon: '💬',
|
||||
keywords: t('palette.keywords.dm'),
|
||||
run: () => {
|
||||
navigate(`/app/friends/${channel.id}`);
|
||||
},
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const actions: PaletteItem[] = [
|
||||
{
|
||||
id: 'action-profile',
|
||||
label: t('palette.actions.profile'),
|
||||
icon: '👤',
|
||||
keywords: 'settings profile account',
|
||||
run: () => {
|
||||
navigate('/settings/account/profile');
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'action-security',
|
||||
label: t('palette.actions.security'),
|
||||
icon: '🔒',
|
||||
keywords: 'settings security password 2fa',
|
||||
run: () => {
|
||||
navigate('/settings/account/security');
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'action-appearance',
|
||||
label: t('palette.actions.appearance'),
|
||||
icon: '🎨',
|
||||
keywords: 'settings appearance theme',
|
||||
run: () => {
|
||||
navigate('/settings/account/appearance');
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'action-friends',
|
||||
label: t('palette.actions.friends'),
|
||||
icon: '👥',
|
||||
keywords: t('palette.keywords.dm'),
|
||||
run: () => {
|
||||
navigate('/app/friends');
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'action-create-guild',
|
||||
label: t('palette.actions.createGuild'),
|
||||
icon: '➕',
|
||||
keywords: t('palette.keywords.guild'),
|
||||
run: input.openGuildCreate,
|
||||
},
|
||||
];
|
||||
|
||||
// Настройки сервера — только когда сервер выбран: открываем тот же пункт
|
||||
// окна настроек, что и шестерёнка в шапке сайдбара.
|
||||
if (selected !== null) {
|
||||
actions.push({
|
||||
id: 'action-guild-settings',
|
||||
label: t('palette.actions.server'),
|
||||
hint: selected.name,
|
||||
icon: '⚙',
|
||||
keywords: 'settings guild server',
|
||||
run: () => {
|
||||
input.openSettings({ section: 'server', guildId: selected.id });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Голосовые действия имеют смысл только в активной сессии.
|
||||
if (input.voiceActive) {
|
||||
actions.push(
|
||||
{
|
||||
id: 'action-toggle-mic',
|
||||
label: t('palette.actions.toggleMic'),
|
||||
icon: input.micEnabled ? '🎙' : '🔇',
|
||||
keywords: 'voice microphone mute',
|
||||
run: () => {
|
||||
void input.setMicEnabled(!input.micEnabled);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'action-deafen',
|
||||
label: t('palette.actions.deafen'),
|
||||
icon: '🎧',
|
||||
keywords: 'voice deafen sound',
|
||||
run: () => {
|
||||
void input.setDeafened(!input.deafened);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
sections.push({ title: t('palette.sections.actions'), items: actions });
|
||||
return sections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Палитра команд (docs/client-tauri.md §5): быстрый переход по серверам,
|
||||
* комнатам и действиям.
|
||||
*
|
||||
* Открывается по Ctrl/Cmd+K (в том числе из поля ввода — это ожидаемое
|
||||
* поведение шортката) и по сообщению `openPalette` от desktop-обёртки, когда
|
||||
* окно было неактивно. Данные берутся из уже загруженных сторов: палитра не
|
||||
* делает ни одного запроса и на пустом снапшоте показывает только действия.
|
||||
*/
|
||||
export function CommandPalette() {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const paletteOpen = useUiStore((state) => state.paletteOpen);
|
||||
const openPalette = useUiStore((state) => state.openPalette);
|
||||
const closePalette = useUiStore((state) => state.closePalette);
|
||||
const openSettings = useUiStore((state) => state.openSettings);
|
||||
const openGuildCreate = useUiStore((state) => state.openGuildCreate);
|
||||
const guilds = useSessionStore((state) => state.guilds);
|
||||
const dmChannels = useSessionStore((state) => state.dmChannels);
|
||||
const selectedGuildId = useSessionStore((state) => state.selectedGuildId);
|
||||
const firstChannelId = useSessionStore((state) => state.firstChannelId);
|
||||
const voiceStatus = useVoiceConnectionStore((state) => state.status);
|
||||
const micEnabled = useVoiceConnectionStore((state) => state.micEnabled);
|
||||
const deafened = useVoiceConnectionStore((state) => state.deafened);
|
||||
const setMicEnabled = useVoiceConnectionStore((state) => state.setMicEnabled);
|
||||
const setDeafened = useVoiceConnectionStore((state) => state.setDeafened);
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
// id списка уникален: `aria-controls` не должен указывать на чужой узел.
|
||||
const listId = useId();
|
||||
|
||||
// Палитра живёт в защищённой части клиента: на публичных экранах (вход,
|
||||
// приглашение) перехватывать Ctrl+K нечего.
|
||||
const enabled = location.pathname.startsWith('/app') || location.pathname.startsWith('/settings');
|
||||
|
||||
// Ctrl/Cmd+K на уровне документа: работает и когда фокус в поле ввода.
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
const onKeyDown = (event: KeyboardEvent): void => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
|
||||
event.preventDefault();
|
||||
openPalette();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [enabled, openPalette]);
|
||||
|
||||
// Каждое открытие начинается с чистого поиска (подстройка состояния под
|
||||
// изменившийся проп во время рендера — приём из `ui/Modal.tsx`).
|
||||
const [prevOpen, setPrevOpen] = useState(paletteOpen);
|
||||
if (prevOpen !== paletteOpen) {
|
||||
setPrevOpen(paletteOpen);
|
||||
if (paletteOpen) {
|
||||
setQuery('');
|
||||
setActiveIndex(0);
|
||||
}
|
||||
}
|
||||
|
||||
const sections = useMemo(
|
||||
() =>
|
||||
buildSections({
|
||||
t,
|
||||
guilds,
|
||||
selectedGuildId,
|
||||
firstChannelId,
|
||||
dmChannels,
|
||||
voiceActive: voiceStatus === 'connected' || voiceStatus === 'reconnecting',
|
||||
micEnabled,
|
||||
deafened,
|
||||
navigate: (path) => void navigate(path),
|
||||
openSettings,
|
||||
openGuildCreate,
|
||||
setMicEnabled,
|
||||
setDeafened,
|
||||
}),
|
||||
[
|
||||
t,
|
||||
guilds,
|
||||
selectedGuildId,
|
||||
firstChannelId,
|
||||
dmChannels,
|
||||
voiceStatus,
|
||||
micEnabled,
|
||||
deafened,
|
||||
navigate,
|
||||
openSettings,
|
||||
openGuildCreate,
|
||||
setMicEnabled,
|
||||
setDeafened,
|
||||
],
|
||||
);
|
||||
|
||||
const visible = useMemo(() => filterSections(sections, query), [sections, query]);
|
||||
const flat = useMemo(() => visible.flatMap((section) => section.items), [visible]);
|
||||
const active = flat[activeIndex] ?? null;
|
||||
|
||||
// Активный пункт всегда на виду: список может быть длиннее панели.
|
||||
useEffect(() => {
|
||||
if (!paletteOpen || active === null) {
|
||||
return;
|
||||
}
|
||||
const node = listRef.current?.querySelector<HTMLElement>(
|
||||
`[data-palette-option="${active.id}"]`,
|
||||
);
|
||||
// jsdom не умеет прокрутку — в тестах вызов просто пропускаем.
|
||||
if (node !== null && node !== undefined && typeof node.scrollIntoView === 'function') {
|
||||
node.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}, [paletteOpen, active]);
|
||||
|
||||
const runItem = useCallback(
|
||||
(item: PaletteItem | null): void => {
|
||||
if (item === null) {
|
||||
return;
|
||||
}
|
||||
closePalette();
|
||||
item.run();
|
||||
},
|
||||
[closePalette],
|
||||
);
|
||||
|
||||
const onKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>): void => {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
if (flat.length === 0) {
|
||||
return;
|
||||
}
|
||||
const step = event.key === 'ArrowDown' ? 1 : -1;
|
||||
setActiveIndex((index) => (index + step + flat.length) % flat.length);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
runItem(active);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={paletteOpen && enabled}
|
||||
title={t('palette.title')}
|
||||
onClose={closePalette}
|
||||
size="lg"
|
||||
panelClassName="p-0 overflow-hidden"
|
||||
bodyClassName="mt-4"
|
||||
initialFocusRef={inputRef}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
role="combobox"
|
||||
aria-expanded="true"
|
||||
aria-controls={listId}
|
||||
aria-activedescendant={active === null ? undefined : optionId(active.id)}
|
||||
aria-label={t('palette.placeholder')}
|
||||
placeholder={t('palette.placeholder')}
|
||||
value={query}
|
||||
data-testid="palette-search"
|
||||
className="w-full border-b border-border/60 bg-transparent px-4 py-2 text-sm text-fg outline-none placeholder:text-fg-muted"
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
setActiveIndex(0);
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={listRef}
|
||||
id={listId}
|
||||
role="listbox"
|
||||
aria-label={t('palette.title')}
|
||||
data-testid="palette-list"
|
||||
className="max-h-80 overflow-y-auto p-2"
|
||||
>
|
||||
{flat.length === 0 ? (
|
||||
<p className="px-2 py-3 text-sm text-fg-muted" data-testid="palette-empty">
|
||||
{t('palette.empty')}
|
||||
</p>
|
||||
) : (
|
||||
visible.map((section) => (
|
||||
<div key={section.title} role="group" aria-label={section.title}>
|
||||
<p className="px-2 py-1 text-xs font-semibold uppercase tracking-wide text-fg-muted">
|
||||
{section.title}
|
||||
</p>
|
||||
<ul role="presentation">
|
||||
{section.items.map((item) => {
|
||||
const selected = item.id === active?.id;
|
||||
return (
|
||||
<li key={item.id} role="presentation">
|
||||
<div
|
||||
id={optionId(item.id)}
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
data-palette-option={item.id}
|
||||
data-testid={`palette-option-${item.id}`}
|
||||
className={`flex cursor-pointer items-center gap-2 rounded-[var(--radius-sm)] px-2 py-1.5 text-sm ${
|
||||
selected ? 'bg-surface-3 text-fg' : 'text-fg-muted hover:bg-surface-2'
|
||||
}`}
|
||||
onMouseEnter={() => setActiveIndex(flat.indexOf(item))}
|
||||
onClick={() => runItem(item)}
|
||||
>
|
||||
<span aria-hidden="true">{item.icon}</span>
|
||||
<span className="truncate">{item.label}</span>
|
||||
{item.hint === undefined ? null : (
|
||||
<span className="ml-auto truncate text-xs text-fg-muted">
|
||||
{item.hint}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { unreadCount, useMessagesStore } from '@/stores/messages';
|
||||
import { useUiStore } from '@/stores/ui';
|
||||
import { useUnsavedStore } from '@/stores/unsaved';
|
||||
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
|
||||
import { platform, routeFromDeepLink } from '@/lib/platform';
|
||||
import { router } from '@/router';
|
||||
@@ -47,6 +49,22 @@ export function DesktopBridge() {
|
||||
case 'settings':
|
||||
// Настройки обёртки клиенту не нужны: он читает только свои.
|
||||
break;
|
||||
case 'closeRequested': {
|
||||
// Обёртка перехватила закрытие окна, потому что клиент сообщил о
|
||||
// несохранённых правках. Если реестр уже пуст (успели сохранить),
|
||||
// отпускаем окно — иначе пользователь не закроет приложение.
|
||||
const unsaved = useUnsavedStore.getState();
|
||||
if (unsaved.entries.length === 0) {
|
||||
platform.closeWindow();
|
||||
} else {
|
||||
unsaved.requestClose();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'openPalette':
|
||||
// Глобальный Ctrl/Cmd+K сработал при неактивном окне.
|
||||
useUiStore.getState().openPalette();
|
||||
break;
|
||||
}
|
||||
}),
|
||||
[],
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { ApiError } from '@/api/client';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/primitives';
|
||||
import { platform } from '@/lib/platform';
|
||||
import { useReducedMotion } from '@/lib/reducedMotion';
|
||||
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
|
||||
import { useUnsavedStore } from '@/stores/unsaved';
|
||||
|
||||
/**
|
||||
* Текст ошибки сохранения: серверную ошибку переводим по коду, локальную
|
||||
* проверку формы (`Error`) показываем её собственным сообщением.
|
||||
*/
|
||||
function saveErrorText(error: unknown, describe: (error: unknown) => string): string {
|
||||
if (error instanceof ApiError) {
|
||||
return describe(error);
|
||||
}
|
||||
return error instanceof Error ? error.message : describe(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Перехват закрытия окна при несохранённых изменениях (docs/client-tauri.md §5).
|
||||
*
|
||||
* Обёртка перехватывает `onCloseRequested` и присылает `closeRequested` только
|
||||
* тогда, когда клиент сообщил ей о правках. Здесь на это сообщение (флаг
|
||||
* `closePrompt` в реестре) отвечают тряской контента и диалогом
|
||||
* «Сохранить / Выйти без сохранения». Тряска — движение, поэтому при
|
||||
* `prefers-reduced-motion: reduce` её заменяет подсветка рамки.
|
||||
*
|
||||
* Диалог рендерится соседом трясущегося контейнера: `transform` анимации
|
||||
* превратил бы его в содержащий блок для `position: fixed` подложки, и окно
|
||||
* уезжало бы вместе с контентом.
|
||||
*/
|
||||
export function DirtyCloseGuard({ children }: { children: ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
const describeError = useApiErrorMessage();
|
||||
const reduced = useReducedMotion();
|
||||
const promptOpen = useUnsavedStore((state) => state.closePrompt);
|
||||
const dismissClose = useUnsavedStore((state) => state.dismissClose);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
|
||||
const attention = promptOpen ? (reduced ? 'gl-attention-flash' : 'gl-attention-shake') : '';
|
||||
|
||||
const onCancel = (): void => {
|
||||
// Отмена: окно остаётся открытым, правки — на месте.
|
||||
dismissClose();
|
||||
};
|
||||
|
||||
const onDiscard = (): void => {
|
||||
dismissClose();
|
||||
platform.closeWindow();
|
||||
};
|
||||
|
||||
const onSave = async (): Promise<void> => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await useUnsavedStore.getState().saveAll();
|
||||
} catch (cause) {
|
||||
// Диалог остаётся открытым: пользователь видит, что именно не сохранилось,
|
||||
// и может повторить или выйти без сохранения.
|
||||
setError(cause);
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
setSaving(false);
|
||||
// Реестр опустел: закрываем диалог. В браузере `window.close()` может не
|
||||
// сработать, и окно должно вернуться в обычное состояние, а не остаться
|
||||
// с вопросом «сохранить изменения?».
|
||||
dismissClose();
|
||||
platform.closeWindow();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div data-testid="app-content" className={`h-full ${attention}`}>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={promptOpen}
|
||||
title={t('unsaved.title')}
|
||||
onClose={() => {
|
||||
if (!saving) {
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" disabled={saving} onClick={onCancel}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="border-danger/50 text-danger"
|
||||
disabled={saving}
|
||||
onClick={onDiscard}
|
||||
>
|
||||
{t('unsaved.discard')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
void onSave();
|
||||
}}
|
||||
>
|
||||
{t(saving ? 'common.saving' : 'common.save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-fg-muted">{t('unsaved.body')}</p>
|
||||
{error === null ? null : (
|
||||
<p role="alert" data-testid="unsaved-error" className="mt-3 text-sm text-danger">
|
||||
{saveErrorText(error, describeError)}
|
||||
</p>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Outlet } from 'react-router';
|
||||
|
||||
import { CommandPalette } from '@/components/CommandPalette';
|
||||
import { DirtyCloseGuard } from '@/components/DirtyCloseGuard';
|
||||
|
||||
/**
|
||||
* Оболочка корня клиента (docs/client-tauri.md §5).
|
||||
*
|
||||
* Тряска контента при закрытии окна с несохранёнными правками и палитра команд
|
||||
* нужны и в приложении, и в окне настроек, и на экране входа-в-настройки,
|
||||
* поэтому живут на уровне корневого маршрута, а не внутри `AppLayout`.
|
||||
*/
|
||||
export function RootShell() {
|
||||
return (
|
||||
<>
|
||||
<DirtyCloseGuard>
|
||||
<Outlet />
|
||||
</DirtyCloseGuard>
|
||||
{/* Палитра — сосед трясущегося контейнера: её подложка `fixed`. */}
|
||||
<CommandPalette />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
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 {
|
||||
@@ -65,6 +66,17 @@ export function InstanceGuildsPanel({
|
||||
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) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { updateInstanceSettings, type InstanceSettings } 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';
|
||||
|
||||
interface InstanceLimitsPanelProps {
|
||||
settings: InstanceSettings;
|
||||
@@ -67,7 +68,11 @@ export function InstanceLimitsPanel({ settings, onSaved }: InstanceLimitsPanelPr
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (): void => {
|
||||
/**
|
||||
* Разбор формы: либо патч для сервера, либо текст локальной проверки.
|
||||
* Общий и для кнопки «Сохранить», и для диалога закрытия окна.
|
||||
*/
|
||||
const buildPatch = (): { patch: Partial<InstanceSettings> | null; error: string | null } => {
|
||||
const sizes = {
|
||||
max_avatar_size: mbToBytes(form.avatar),
|
||||
max_guild_image_size: mbToBytes(form.guildImage),
|
||||
@@ -76,32 +81,63 @@ export function InstanceLimitsPanel({ settings, onSaved }: InstanceLimitsPanelPr
|
||||
max_cosmetic_size: mbToBytes(form.cosmetic),
|
||||
};
|
||||
if (Object.values(sizes).some((value) => value === null)) {
|
||||
setError(t('settings.instance.limits.invalidSize'));
|
||||
return;
|
||||
return { patch: null, error: t('settings.instance.limits.invalidSize') };
|
||||
}
|
||||
const guilds = Number(form.guilds);
|
||||
const members = Number(form.members);
|
||||
const message = Number(form.message);
|
||||
if (!Number.isFinite(guilds) || !Number.isFinite(members) || !Number.isFinite(message)) {
|
||||
setError(t('settings.instance.limits.invalidNumber'));
|
||||
return;
|
||||
return { patch: null, error: t('settings.instance.limits.invalidNumber') };
|
||||
}
|
||||
setError(null);
|
||||
// Размеры проверены выше: null уже отсеян, здесь только числа.
|
||||
save.mutate({
|
||||
registration_enabled: form.registration,
|
||||
allow_guild_creation: form.creation,
|
||||
max_guilds_per_user: guilds,
|
||||
max_members_per_guild: members,
|
||||
max_message_length: message,
|
||||
max_avatar_size: sizes.max_avatar_size ?? settings.max_avatar_size,
|
||||
max_guild_image_size: sizes.max_guild_image_size ?? settings.max_guild_image_size,
|
||||
max_emoji_size: sizes.max_emoji_size ?? settings.max_emoji_size,
|
||||
max_sound_size: sizes.max_sound_size ?? settings.max_sound_size,
|
||||
max_cosmetic_size: sizes.max_cosmetic_size ?? settings.max_cosmetic_size,
|
||||
});
|
||||
return {
|
||||
patch: {
|
||||
registration_enabled: form.registration,
|
||||
allow_guild_creation: form.creation,
|
||||
max_guilds_per_user: guilds,
|
||||
max_members_per_guild: members,
|
||||
max_message_length: message,
|
||||
// Размеры проверены выше: null уже отсеян, здесь только числа.
|
||||
max_avatar_size: sizes.max_avatar_size ?? settings.max_avatar_size,
|
||||
max_guild_image_size: sizes.max_guild_image_size ?? settings.max_guild_image_size,
|
||||
max_emoji_size: sizes.max_emoji_size ?? settings.max_emoji_size,
|
||||
max_sound_size: sizes.max_sound_size ?? settings.max_sound_size,
|
||||
max_cosmetic_size: sizes.max_cosmetic_size ?? settings.max_cosmetic_size,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
};
|
||||
|
||||
const onSubmit = (): void => {
|
||||
const { patch, error: message } = buildPatch();
|
||||
setError(message);
|
||||
if (patch !== null) {
|
||||
save.mutate(patch);
|
||||
}
|
||||
};
|
||||
|
||||
// Черновик лимитов: сравниваем введённое с тем, что уже сохранено на сервере.
|
||||
useUnsavedChanges({
|
||||
dirty:
|
||||
form.registration !== settings.registration_enabled ||
|
||||
form.creation !== settings.allow_guild_creation ||
|
||||
form.guilds !== String(settings.max_guilds_per_user) ||
|
||||
form.members !== String(settings.max_members_per_guild) ||
|
||||
form.message !== String(settings.max_message_length) ||
|
||||
form.avatar !== bytesToMb(settings.max_avatar_size) ||
|
||||
form.guildImage !== bytesToMb(settings.max_guild_image_size) ||
|
||||
form.emoji !== bytesToMb(settings.max_emoji_size) ||
|
||||
form.sound !== bytesToMb(settings.max_sound_size) ||
|
||||
form.cosmetic !== bytesToMb(settings.max_cosmetic_size),
|
||||
save: async () => {
|
||||
const { patch, error: message } = buildPatch();
|
||||
if (patch === null) {
|
||||
// Локальная проверка не прошла: диалог покажет текст, окно останется.
|
||||
throw new Error(message ?? t('settings.instance.limits.invalidNumber'));
|
||||
}
|
||||
await save.mutateAsync(patch);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
className="mt-3 flex flex-col gap-4"
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -28,6 +35,11 @@ interface ModalProps {
|
||||
* `overflow` и трансформациям родителей.
|
||||
*/
|
||||
portal?: boolean;
|
||||
/**
|
||||
* Куда поставить фокус при открытии. По умолчанию — первое поле панели;
|
||||
* палитре команд нужен именно поиск, а не крестик в шапке (он тоже кнопка).
|
||||
*/
|
||||
initialFocusRef?: RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
/** Длительность анимации закрытия: столько панель живёт в DOM после `open=false`. */
|
||||
@@ -63,6 +75,7 @@ export function Modal({
|
||||
panelStyle,
|
||||
bodyClassName = '',
|
||||
portal = false,
|
||||
initialFocusRef,
|
||||
}: ModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -115,10 +128,10 @@ export function Modal({
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
const firstField = panelRef.current?.querySelector<HTMLElement>(
|
||||
'input, textarea, select, button',
|
||||
);
|
||||
firstField?.focus();
|
||||
const target =
|
||||
initialFocusRef?.current ??
|
||||
panelRef.current?.querySelector<HTMLElement>('input, textarea, select, button');
|
||||
target?.focus();
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
const index = openModals.indexOf(token);
|
||||
@@ -128,7 +141,7 @@ export function Modal({
|
||||
// Возвращаем фокус туда, откуда окно открыли.
|
||||
previous?.focus();
|
||||
};
|
||||
}, [open]);
|
||||
}, [open, initialFocusRef]);
|
||||
|
||||
if (!mounted) {
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user