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,105 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { platform } from '@/lib/platform';
|
||||
|
||||
/**
|
||||
* Единый реестр несохранённых изменений (docs/client-tauri.md §5).
|
||||
*
|
||||
* Формы с локальным черновиком регистрируют здесь запись, пока в них есть
|
||||
* правки. Реестр нужен по двум причинам: платформа должна знать, перехватывать
|
||||
* ли закрытие окна (один флаг на всё приложение), а диалог закрытия — уметь
|
||||
* сохранить все формы по очереди, не зная о них ничего.
|
||||
*/
|
||||
|
||||
/** Сохранение формы: та же операция, что и кнопка «Сохранить». */
|
||||
export type SaveHandler = () => Promise<void>;
|
||||
|
||||
export interface UnsavedEntry {
|
||||
id: string;
|
||||
save: SaveHandler;
|
||||
}
|
||||
|
||||
interface UnsavedState {
|
||||
entries: UnsavedEntry[];
|
||||
/** Обёртка попросила закрыть окно, а в реестре есть правки. */
|
||||
closePrompt: boolean;
|
||||
/**
|
||||
* Регистрирует запись и возвращает функцию снятия. Повторная регистрация с
|
||||
* тем же id заменяет обработчик: форма может пересобрать `save`.
|
||||
*/
|
||||
register: (id: string, save: SaveHandler) => () => void;
|
||||
unregister: (id: string) => void;
|
||||
/** Показать диалог закрытия (сообщение `closeRequested` от обёртки). */
|
||||
requestClose: () => void;
|
||||
/** Закрыть диалог без выхода: пользователь выбрал «Отмена». */
|
||||
dismissClose: () => void;
|
||||
/**
|
||||
* Сохранить все формы по очереди. Первая же ошибка останавливает процесс и
|
||||
* уходит наверх: диалог покажет её текст, а окно останется открытым.
|
||||
*/
|
||||
saveAll: () => Promise<void>;
|
||||
/** Сброс для тестов и выхода из аккаунта. */
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const defaults = {
|
||||
entries: [] as UnsavedEntry[],
|
||||
closePrompt: false,
|
||||
};
|
||||
|
||||
export const useUnsavedStore = create<UnsavedState>((set, get) => ({
|
||||
...defaults,
|
||||
|
||||
register: (id, save) => {
|
||||
const entries = get().entries;
|
||||
const index = entries.findIndex((entry) => entry.id === id);
|
||||
if (index === -1) {
|
||||
set({ entries: [...entries, { id, save }] });
|
||||
} else {
|
||||
const next = [...entries];
|
||||
next[index] = { id, save };
|
||||
set({ entries: next });
|
||||
}
|
||||
return () => get().unregister(id);
|
||||
},
|
||||
|
||||
unregister: (id) => {
|
||||
const entries = get().entries;
|
||||
if (!entries.some((entry) => entry.id === id)) {
|
||||
return;
|
||||
}
|
||||
set({ entries: entries.filter((entry) => entry.id !== id) });
|
||||
},
|
||||
|
||||
requestClose: () => set({ closePrompt: true }),
|
||||
|
||||
dismissClose: () => set({ closePrompt: false }),
|
||||
|
||||
saveAll: async () => {
|
||||
// Копия списка: `save` может изменить реестр (например, форма сбросит
|
||||
// черновик и снимет регистрацию), а обход должен остаться устойчивым.
|
||||
for (const entry of [...get().entries]) {
|
||||
await entry.save();
|
||||
}
|
||||
},
|
||||
|
||||
reset: () => set({ ...defaults }),
|
||||
}));
|
||||
|
||||
/** Есть ли в реестре хотя бы одна несохранённая форма. */
|
||||
export function hasUnsavedChanges(state: UnsavedState): boolean {
|
||||
return state.entries.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Платформа узнаёт о переходе «есть правки / нет правок» ровно один раз:
|
||||
* сравниваем агрегат, а не длину списка. Иначе сохранение одной из двух форм
|
||||
* мигало бы `setDirty(false)` → `setDirty(true)`, и обёртка успела бы решить,
|
||||
* что закрывать окно можно.
|
||||
*/
|
||||
useUnsavedStore.subscribe((state, previous) => {
|
||||
const dirty = hasUnsavedChanges(state);
|
||||
if (dirty !== hasUnsavedChanges(previous)) {
|
||||
platform.setDirty(dirty);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user