feat(instance): сервер установщика больше не «главный»
Понятие главного сервера убрано из поведения: сервер, создаваемый установщиком, обычный (is_main = false), автовступления в него нет, а в настройках инстанса он остаётся лишь справочно (main_guild_id → AdminGuildID в ответе API). Клиент от него больше не зависит: новичок начинает с пустым списком серверов.
This commit is contained in:
@@ -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 }),
|
||||
}));
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user