feat(instance): сервер установщика больше не «главный»

Понятие главного сервера убрано из поведения: сервер, создаваемый установщиком,
обычный (is_main = false), автовступления в него нет, а в настройках инстанса
он остаётся лишь справочно (main_guild_id → AdminGuildID в ответе API).
Клиент от него больше не зависит: новичок начинает с пустым списком серверов.
This commit is contained in:
2026-09-20 01:12:01 +03:00
parent 33dc1fec96
commit f26728113d
9 changed files with 458 additions and 23 deletions
+57
View File
@@ -1,11 +1,45 @@
import { create } from 'zustand';
import { useSessionStore } from '@/stores/session';
export type Theme = 'dark' | 'light';
/** Пункты модального окна настроек (левое меню). */
export const settingsSections = [
'profile',
'security',
'appearance',
'notifications',
'voice',
'language',
'instance',
] as const;
export type SettingsSection = (typeof settingsSections)[number];
/** Вкладки пункта «Профиль»: глобальный профиль и профиль на сервере. */
export const profileTabs = ['main', 'server'] as const;
export type ProfileTab = (typeof profileTabs)[number];
/** Куда открыть окно настроек. */
export interface SettingsTarget {
section?: SettingsSection;
tab?: ProfileTab;
/** Сервер для вкладки «Профиль → Сервер». */
guildId?: string | null;
}
interface UiState {
theme: Theme;
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
/** Открыто ли модальное окно настроек (открывается из панели пользователя). */
settingsOpen: boolean;
settingsSection: SettingsSection;
settingsProfileTab: ProfileTab;
openSettings: (target?: SettingsTarget) => void;
closeSettings: () => void;
setSettingsSection: (section: SettingsSection) => void;
setSettingsProfileTab: (tab: ProfileTab) => void;
}
const STORAGE_KEY = 'glchat.theme';
@@ -40,4 +74,27 @@ export const useUiStore = create<UiState>((set, get) => ({
toggleTheme: () => {
get().setTheme(get().theme === 'dark' ? 'light' : 'dark');
},
settingsOpen: false,
settingsSection: 'profile',
settingsProfileTab: 'main',
openSettings: (target) => {
// Выбранный сервер живёт в снапшоте сессии: так его видят и секции
// «Опасной зоны», которые сбрасывают выбор после выхода с сервера.
if (target?.guildId !== undefined) {
useSessionStore.getState().selectSettingsGuild(target.guildId);
}
set({
settingsOpen: true,
...(target?.section === undefined ? {} : { settingsSection: target.section }),
...(target?.tab === undefined ? {} : { settingsProfileTab: target.tab }),
});
},
closeSettings: () => set({ settingsOpen: false }),
setSettingsSection: (section) => set({ settingsSection: section }),
setSettingsProfileTab: (tab) => set({ settingsProfileTab: tab }),
}));
+177
View File
@@ -0,0 +1,177 @@
import { create } from 'zustand';
/**
* Локальные настройки «голосовой» панели: состояния кнопок микрофона, камеры и
* наушников, выбранные устройства и громкости.
*
* Голос появится в Фазе 3, поэтому сейчас это только предпочтения клиента:
* они хранятся в localStorage и не уходят на сервер.
*/
/** Виды устройств, которые отдаёт `navigator.mediaDevices.enumerateDevices()`. */
export type VoiceDeviceKind = 'audioinput' | 'videoinput' | 'audiooutput';
interface VoicePreferences {
micEnabled: boolean;
cameraEnabled: boolean;
headphonesEnabled: boolean;
/** Пустая строка — устройство по умолчанию. */
micDeviceId: string;
cameraDeviceId: string;
outputDeviceId: string;
/** Громкость микрофона и звука, 0–100. */
micVolume: number;
outputVolume: number;
}
interface VoiceState extends VoicePreferences {
setMicEnabled: (enabled: boolean) => void;
toggleMic: () => void;
setCameraEnabled: (enabled: boolean) => void;
toggleCamera: () => void;
setHeadphonesEnabled: (enabled: boolean) => void;
toggleHeadphones: () => void;
selectDevice: (kind: VoiceDeviceKind, deviceId: string) => void;
setMicVolume: (value: number) => void;
setOutputVolume: (value: number) => void;
/** Вернуть исходные предпочтения (используется в тестах). */
reset: () => void;
}
export const VOICE_STORAGE_KEY = 'glchat.voice.prefs';
const defaults: VoicePreferences = {
micEnabled: true,
cameraEnabled: false,
headphonesEnabled: true,
micDeviceId: '',
cameraDeviceId: '',
outputDeviceId: '',
micVolume: 100,
outputVolume: 100,
};
function clampVolume(value: number): number {
if (!Number.isFinite(value)) {
return 100;
}
return Math.min(100, Math.max(0, Math.round(value)));
}
function asBoolean(value: unknown, fallback: boolean): boolean {
return typeof value === 'boolean' ? value : fallback;
}
function asString(value: unknown, fallback: string): string {
return typeof value === 'string' ? value : fallback;
}
function asVolume(value: unknown, fallback: number): number {
return typeof value === 'number' ? clampVolume(value) : fallback;
}
/** Начальные значения: то, что уже лежит в localStorage, поверх умолчаний. */
function initialPreferences(): VoicePreferences {
if (typeof window === 'undefined') {
return { ...defaults };
}
const raw = window.localStorage.getItem(VOICE_STORAGE_KEY);
if (raw === null) {
return { ...defaults };
}
let parsed: unknown;
try {
parsed = JSON.parse(raw) as unknown;
} catch {
return { ...defaults };
}
if (typeof parsed !== 'object' || parsed === null) {
return { ...defaults };
}
const stored = parsed as Record<string, unknown>;
return {
micEnabled: asBoolean(stored['micEnabled'], defaults.micEnabled),
cameraEnabled: asBoolean(stored['cameraEnabled'], defaults.cameraEnabled),
headphonesEnabled: asBoolean(stored['headphonesEnabled'], defaults.headphonesEnabled),
micDeviceId: asString(stored['micDeviceId'], defaults.micDeviceId),
cameraDeviceId: asString(stored['cameraDeviceId'], defaults.cameraDeviceId),
outputDeviceId: asString(stored['outputDeviceId'], defaults.outputDeviceId),
micVolume: asVolume(stored['micVolume'], defaults.micVolume),
outputVolume: asVolume(stored['outputVolume'], defaults.outputVolume),
};
}
function persist(state: VoicePreferences): void {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(
VOICE_STORAGE_KEY,
JSON.stringify({
micEnabled: state.micEnabled,
cameraEnabled: state.cameraEnabled,
headphonesEnabled: state.headphonesEnabled,
micDeviceId: state.micDeviceId,
cameraDeviceId: state.cameraDeviceId,
outputDeviceId: state.outputDeviceId,
micVolume: state.micVolume,
outputVolume: state.outputVolume,
} satisfies VoicePreferences),
);
}
export const useVoiceStore = create<VoiceState>((set, get) => {
/** Изменение предпочтений всегда доезжает до localStorage. */
const patch = (values: Partial<VoicePreferences>): void => {
set(values);
persist(preferencesOf(get()));
};
return {
...initialPreferences(),
setMicEnabled: (enabled) => patch({ micEnabled: enabled }),
toggleMic: () => patch({ micEnabled: !get().micEnabled }),
setCameraEnabled: (enabled) => patch({ cameraEnabled: enabled }),
toggleCamera: () => patch({ cameraEnabled: !get().cameraEnabled }),
setHeadphonesEnabled: (enabled) => patch({ headphonesEnabled: enabled }),
toggleHeadphones: () => patch({ headphonesEnabled: !get().headphonesEnabled }),
selectDevice: (kind, deviceId) => {
if (kind === 'audioinput') {
patch({ micDeviceId: deviceId });
return;
}
if (kind === 'videoinput') {
patch({ cameraDeviceId: deviceId });
return;
}
patch({ outputDeviceId: deviceId });
},
setMicVolume: (value) => patch({ micVolume: clampVolume(value) }),
setOutputVolume: (value) => patch({ outputVolume: clampVolume(value) }),
reset: () => {
set({ ...defaults });
if (typeof window !== 'undefined') {
window.localStorage.removeItem(VOICE_STORAGE_KEY);
}
},
};
});
function preferencesOf(state: VoiceState): VoicePreferences {
return {
micEnabled: state.micEnabled,
cameraEnabled: state.cameraEnabled,
headphonesEnabled: state.headphonesEnabled,
micDeviceId: state.micDeviceId,
cameraDeviceId: state.cameraDeviceId,
outputDeviceId: state.outputDeviceId,
micVolume: state.micVolume,
outputVolume: state.outputVolume,
};
}