♻️ refactor: centralize app settings

This commit is contained in:
2026-06-30 21:16:05 +07:00
parent 5fe93b26df
commit 9b90140cfd
20 changed files with 284 additions and 134 deletions
+43 -3
View File
@@ -2,18 +2,48 @@ import { app } from "electron";
import { join } from "node:path";
import { existsSync } from "node:fs";
import { jsonFile } from "../lib/jsonFile";
import type { AppConfig, PreferredRegion } from "../../shared/types/appConfig";
import type { AppConfig, AppPreferences, PreferredRegion } from "../../shared/types/appConfig";
import { DEFAULT_LOCALE, isAppLocale } from "../../shared/locales";
import { detectedGamePath } from "../game/steam";
const path = () => join(app.getPath("userData"), "app-config.json");
type StoredConfig = Omit<AppConfig, "version" | "detectedGamePath">;
const DEFAULTS: StoredConfig = { gamePath: null, preferredRegion: "auto" };
const DEFAULT_PREFERENCES: AppPreferences = {
schemeMode: "auto",
accent: null,
locale: DEFAULT_LOCALE,
showDebugNav: false,
};
const DEFAULTS: StoredConfig = {
gamePath: null,
preferredRegion: "auto",
preferences: DEFAULT_PREFERENCES,
};
function normalizePreferences(raw: Partial<AppPreferences> | undefined): AppPreferences {
const schemeMode = raw?.schemeMode;
const locale = raw?.locale;
const accent = raw?.accent?.trim() || null;
return {
schemeMode: schemeMode === "light" || schemeMode === "dark" || schemeMode === "auto" ? schemeMode : "auto",
accent: accent && /^#[\da-f]{6}$/i.test(accent) ? accent : null,
locale: isAppLocale(locale) ? locale : DEFAULT_LOCALE,
showDebugNav: raw?.showDebugNav === true,
};
}
const configFile = jsonFile<StoredConfig>(
path,
() => ({ ...DEFAULTS }),
(raw) => ({ ...DEFAULTS, ...(raw as Partial<StoredConfig>) }),
(raw) => {
const stored = raw as Partial<StoredConfig>;
return {
...DEFAULTS,
...stored,
preferences: normalizePreferences(stored.preferences),
};
},
);
const readStored = configFile.read;
@@ -39,6 +69,16 @@ export function setPreferredRegion(region: PreferredRegion): AppConfig {
return withDerived(next);
}
export function setPreferences(patch: Partial<AppPreferences>): AppConfig {
const stored = readStored();
const next: StoredConfig = {
...stored,
preferences: normalizePreferences({ ...stored.preferences, ...patch }),
};
writeStored(next);
return withDerived(next);
}
export function getConfig(): AppConfig {
return withDerived(readStored());
}
+1
View File
@@ -135,6 +135,7 @@ const handlers = {
if (p.region === "auto") void region.detectBestRegion();
return next;
}),
"config:setPreferences": (p) => guard(async () => appConfig.setPreferences(p)),
"region:detect": () => guard(() => region.detectBestRegion()),
"region:ping": () =>
+7 -1
View File
@@ -33,6 +33,7 @@ import { LaunchButton } from "../features/game/LaunchButton";
import { NavProvider, useNav, type View } from "../features/navigation/NavContext";
import { useI18n } from "../lib/i18n";
import { api, events } from "../lib/api";
import { useDebugNavVisible } from "../lib/debugSettings";
import "../styles/app-shell.css";
export function AppShell() {
@@ -58,6 +59,7 @@ function Shell() {
const { t } = useI18n();
const [leftOpen, setLeftOpen] = useState(true);
const [friendsOpen, setFriendsOpen] = useState(true);
const showDebugNav = useDebugNavVisible();
useEffect(
() =>
@@ -119,6 +121,8 @@ function Shell() {
divider: true,
onClick: () => nav.openSettings(),
},
...(showDebugNav
? [
{
id: "debug",
label: t("nav:debug"),
@@ -128,7 +132,9 @@ function Shell() {
if (e.ctrlKey || e.metaKey) void api.debug.cacheClear();
else void api.debug.openWindow();
},
},
} satisfies NavItem,
]
: []),
];
const openProfile = (id: "me" | string) => nav.openUser(id);
@@ -3,6 +3,7 @@ import {
AlertTriangle,
Boxes,
Check,
Bug,
Download,
Droplet,
FolderOpen,
@@ -26,6 +27,8 @@ import type { AppConfig, PreferredRegion, RegionPing } from "../../../../shared/
import type { UnityStatus } from "../../../../shared/types/unity";
import { api, errorMessage } from "../../lib/api";
import { useAsync } from "../../lib/useAsync";
import { useSetDebugNavVisible, useDebugNavVisible } from "../../lib/debugSettings";
import { useAppConfig } from "../../lib/AppConfigContext";
import { regionFlag, regionLabel } from "../../lib/vrchat";
import {
Button,
@@ -34,12 +37,13 @@ import {
PAGE_TITLE,
SettingsSection as Section,
Tabs,
Toggle,
} from "../../components/ui";
const SHELL = "mx-auto flex w-full max-w-[760px] flex-col gap-[18px] px-12 pb-16 pt-10";
const SECTIONS = "animate-rise flex flex-col gap-[18px]";
type SettingsTab = "appearance" | "game" | "creator" | "about";
type SettingsTab = "appearance" | "game" | "creator" | "debug" | "about";
export function SettingsView() {
const { t } = useI18n();
@@ -56,6 +60,7 @@ export function SettingsView() {
{ id: "appearance", label: t("settings:tabs.appearance") },
{ id: "game", label: t("settings:tabs.game") },
{ id: "creator", label: t("settings:tabs.creator") },
{ id: "debug", label: t("settings:tabs.debug") },
{ id: "about", label: t("settings:tabs.about") },
]}
active={tab}
@@ -83,6 +88,12 @@ export function SettingsView() {
</div>
) : null}
{tab === "debug" ? (
<div className={SECTIONS}>
<DebugSection />
</div>
) : null}
{tab === "about" ? (
<div className={SECTIONS}>
<AboutSection />
@@ -92,6 +103,27 @@ export function SettingsView() {
);
}
function DebugSection() {
const { t } = useI18n();
const showDebugNav = useDebugNavVisible();
const setDebugNavVisible = useSetDebugNavVisible();
return (
<Section
title={t("settings:debug.title")}
icon={<Bug size={16} />}
description={t("settings:debug.description")}
>
<div className="flex items-center justify-between gap-4 rounded-lg border border-border bg-surface-2 p-3">
<div>
<div className="text-[13px] font-semibold text-text">{t("settings:debug.sidebar")}</div>
<div className="mt-0.5 text-[12px] text-muted">{t("settings:debug.sidebarHint")}</div>
</div>
<Toggle checked={showDebugNav} onChange={setDebugNavVisible} />
</div>
</Section>
);
}
const SCHEME_OPTIONS: { mode: SchemeMode; labelKey: string; hintKey: string; icon: ReactNode }[] = [
{
mode: "auto",
@@ -259,18 +291,15 @@ function LanguageSection() {
function GameSection() {
const { t } = useI18n();
const [config, setConfig] = useState<AppConfig | null>(null);
const { config, setGamePath, pickGamePath } = useAppConfig();
const [path, setPath] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [ok, setOk] = useState<string | null>(null);
useEffect(() => {
api.config.get().then((c) => {
setConfig(c);
setPath(c.gamePath ?? "");
});
}, []);
setPath(config?.gamePath ?? "");
}, [config?.gamePath]);
async function run(p: Promise<AppConfig>, okMsg: string) {
setBusy(true);
@@ -278,7 +307,6 @@ function GameSection() {
setOk(null);
try {
const next = await p;
setConfig(next);
setPath(next.gamePath ?? "");
setOk(okMsg);
} catch (e) {
@@ -307,7 +335,7 @@ function GameSection() {
</div>
<Button
variant="ghost"
onClick={() => void run(api.config.pickGamePath(), t("settings:game.saved"))}
onClick={() => void run(pickGamePath(), t("settings:game.saved"))}
>
<FolderOpen size={14} />
{t("settings:game.browse")}
@@ -339,7 +367,7 @@ function GameSection() {
<div className="mt-3 flex flex-wrap items-center gap-2.5">
<Button
onClick={() =>
void run(api.config.setGamePath(path.trim() || null), t("settings:game.saved"))
void run(setGamePath(path.trim() || null), t("settings:game.saved"))
}
loading={busy}
disabled={!dirty}
@@ -349,7 +377,7 @@ function GameSection() {
{config?.gamePath ? (
<Button
variant="ghost"
onClick={() => void run(api.config.setGamePath(null), t("settings:game.resetDone"))}
onClick={() => void run(setGamePath(null), t("settings:game.resetDone"))}
disabled={busy}
>
{t("settings:game.reset")}
@@ -365,20 +393,16 @@ const REGION_OPTIONS: PreferredRegion[] = ["auto", "us", "use", "eu", "jp"];
function RegionSection() {
const { t } = useI18n();
const [region, setRegion] = useState<PreferredRegion>("auto");
const { config, setPreferredRegion } = useAppConfig();
const [ok, setOk] = useState<string | null>(null);
const [pings, setPings] = useState<RegionPing[] | null>(null);
const [testing, setTesting] = useState(false);
useEffect(() => {
api.config.get().then((c) => setRegion(c.preferredRegion));
}, []);
const region = config?.preferredRegion ?? "auto";
const choose = async (r: PreferredRegion) => {
setRegion(r);
setOk(null);
const next = await api.config.setPreferredRegion(r);
setRegion(next.preferredRegion);
await setPreferredRegion(r);
setOk(t("settings:region.saved"));
};
+52
View File
@@ -0,0 +1,52 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import type { AppConfig, AppPreferences, PreferredRegion } from "../../../shared/types/appConfig";
import { api } from "./api";
interface AppConfigContextValue {
config: AppConfig | null;
setGamePath: (gamePath: string | null) => Promise<AppConfig>;
pickGamePath: () => Promise<AppConfig>;
setPreferredRegion: (region: PreferredRegion) => Promise<AppConfig>;
setPreferences: (patch: Partial<AppPreferences>) => Promise<AppConfig>;
}
const AppConfigContext = createContext<AppConfigContextValue | null>(null);
export function AppConfigProvider({ children }: { children: ReactNode }) {
const [config, setConfig] = useState<AppConfig | null>(null);
useEffect(() => {
let cancelled = false;
void api.config.get().then((next) => {
if (!cancelled) setConfig(next);
});
return () => {
cancelled = true;
};
}, []);
const commit = useCallback(async (next: Promise<AppConfig>) => {
const config = await next;
setConfig(config);
return config;
}, []);
const value = useMemo<AppConfigContextValue>(
() => ({
config,
setGamePath: (gamePath) => commit(api.config.setGamePath(gamePath)),
pickGamePath: () => commit(api.config.pickGamePath()),
setPreferredRegion: (region) => commit(api.config.setPreferredRegion(region)),
setPreferences: (patch) => commit(api.config.setPreferences(patch)),
}),
[config, commit],
);
return <AppConfigContext.Provider value={value}>{children}</AppConfigContext.Provider>;
}
export function useAppConfig(): AppConfigContextValue {
const ctx = useContext(AppConfigContext);
if (!ctx) throw new Error("useAppConfig must be used within <AppConfigProvider>");
return ctx;
}
+31 -11
View File
@@ -4,6 +4,7 @@ import {
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
@@ -12,13 +13,11 @@ import {
applyTheme,
builtInThemes,
initialTheme,
persistScheme,
storedAccent,
storedScheme,
systemScheme,
type SchemeMode,
type Theme,
} from "./theme";
import { useAppConfig } from "./AppConfigContext";
interface ThemeContextValue {
theme: Theme;
@@ -34,44 +33,64 @@ interface ThemeContextValue {
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
const { config, setPreferences } = useAppConfig();
const [themes, setThemes] = useState<Theme[]>(builtInThemes);
const [theme, setActive] = useState<Theme>(() => {
const t = initialTheme(builtInThemes);
const t = initialTheme(builtInThemes, "auto");
applyTheme(t);
applyAccent(null);
return t;
});
const [schemeMode, setMode] = useState<SchemeMode>(() => storedScheme());
const [accent, setAccentState] = useState<string | null>(() => storedAccent());
const [schemeMode, setMode] = useState<SchemeMode>("auto");
const [accent, setAccentState] = useState<string | null>(null);
const lastApplied = useRef<string>("");
const setAccent = useCallback((hex: string | null) => {
applyAccent(hex);
setAccentState(hex);
}, []);
void setPreferences({ accent: hex });
}, [setPreferences]);
const setTheme = useCallback(
(id: string) => {
const next = themes.find((t) => t.id === id);
if (!next) return;
applyTheme(next);
applyAccent(accent);
setActive(next);
},
[themes],
[themes, accent],
);
const setSchemeMode = useCallback(
(mode: SchemeMode) => {
persistScheme(mode);
setMode(mode);
const target = mode === "auto" ? systemScheme() : mode;
const next = themes.find((t) => t.scheme === target);
if (next) {
applyTheme(next);
applyAccent(accent);
setActive(next);
}
void setPreferences({ schemeMode: mode });
},
[themes],
[themes, accent, setPreferences],
);
useEffect(() => {
const prefs = config?.preferences;
if (!prefs) return;
const key = `${prefs.schemeMode}:${prefs.accent ?? ""}`;
if (key === lastApplied.current) return;
lastApplied.current = key;
setMode(prefs.schemeMode);
setAccentState(prefs.accent);
const next = initialTheme(themes, prefs.schemeMode);
applyTheme(next);
applyAccent(prefs.accent);
setActive(next);
}, [config?.preferences, themes]);
useEffect(() => {
if (schemeMode !== "auto") return;
const mq = window.matchMedia("(prefers-color-scheme: light)");
@@ -79,12 +98,13 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
const next = themes.find((t) => t.scheme === systemScheme());
if (next) {
applyTheme(next);
applyAccent(accent);
setActive(next);
}
};
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, [schemeMode, themes]);
}, [schemeMode, themes, accent]);
const registerTheme = useCallback((custom: Theme, activate = false) => {
setThemes((prev) => {
+2 -1
View File
@@ -4,7 +4,7 @@ import type { ContentFilterKey } from "../../../shared/types/settings";
import type { UserStatus } from "../../../shared/types/user";
import type { EnhancementId } from "../../../shared/types/enhancements";
import type { CreateInstanceInput } from "../../../shared/types/instance";
import type { PreferredRegion } from "../../../shared/types/appConfig";
import type { AppPreferences, PreferredRegion } from "../../../shared/types/appConfig";
import type { AvatarEdit, FavoriteGroupEdit, MoveResult } from "../../../shared/types/avatar";
import type {
FavoriteGroupEdit as WorldFavoriteGroupEdit,
@@ -150,6 +150,7 @@ export const api = {
setGamePath: (gamePath: string | null) => call("config:setGamePath", { gamePath }),
pickGamePath: () => call("config:pickGamePath"),
setPreferredRegion: (region: PreferredRegion) => call("config:setPreferredRegion", { region }),
setPreferences: (p: Partial<AppPreferences>) => call("config:setPreferences", p),
},
region: {
detect: () => call("region:detect"),
+16
View File
@@ -0,0 +1,16 @@
import { useCallback } from "react";
import { useAppConfig } from "./AppConfigContext";
export function useDebugNavVisible(): boolean {
return useAppConfig().config?.preferences.showDebugNav ?? false;
}
export function useSetDebugNavVisible(): (visible: boolean) => void {
const { setPreferences } = useAppConfig();
return useCallback(
(visible: boolean) => {
void setPreferences({ showDebugNav: visible });
},
[setPreferences],
);
}
+11 -4
View File
@@ -1,9 +1,15 @@
import { useCallback, type ReactNode } from "react";
import { useCallback, useEffect, type ReactNode } from "react";
import { I18nextProvider, useTranslation } from "react-i18next";
import type { Locale, LocaleCode } from "./types";
import { availableLocales, getLocale, i18n, persistLocale } from "./registry";
import { availableLocales, getLocale, i18n } from "./registry";
import { useAppConfig } from "../AppConfigContext";
export function I18nProvider({ children }: { children: ReactNode }) {
const { config } = useAppConfig();
useEffect(() => {
const locale = config?.preferences.locale;
if (locale && i18n.language !== locale) void i18n.changeLanguage(locale);
}, [config?.preferences.locale]);
return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>;
}
@@ -16,15 +22,16 @@ interface I18nValue {
}
export function useI18n(): I18nValue {
const { setPreferences } = useAppConfig();
const { t, i18n: instance } = useTranslation();
const locale = instance.language as LocaleCode;
const setLocale = useCallback(
(code: LocaleCode) => {
persistLocale(code);
void instance.changeLanguage(code);
void setPreferences({ locale: code });
},
[instance],
[instance, setPreferences],
);
return { locale, current: getLocale(locale), locales: availableLocales(), setLocale, t };
-2
View File
@@ -4,8 +4,6 @@ export {
NAMESPACES,
availableLocales,
getLocale,
storedLocale,
persistLocale,
i18n,
} from "./registry";
export type { Locale, LocaleCode, LocaleMeta, Messages } from "./types";
@@ -5,6 +5,7 @@
"appearance": "Appearance",
"game": "Game",
"creator": "Creator",
"debug": "Debug",
"about": "About"
},
"appearance": {
@@ -83,6 +84,12 @@
"version": "Version",
"disclaimer": "Unofficial. Not affiliated with or endorsed by VRChat Inc."
},
"debug": {
"title": "Debug",
"description": "Developer and troubleshooting options.",
"sidebar": "Show Debug in the sidebar",
"sidebarHint": "Adds the Debug shortcut back to the left navigation."
},
"region": {
"title": "Instance Region",
"description": "The region new instances you create are hosted in. Auto picks the lowest-latency region for your connection.",
@@ -5,6 +5,7 @@
"appearance": "外観",
"game": "ゲーム",
"creator": "クリエイター",
"debug": "デバッグ",
"about": "情報"
},
"appearance": {
@@ -83,6 +84,12 @@
"version": "バージョン",
"disclaimer": "非公式です。VRChat Inc. とは提携しておらず、承認も受けていません。"
},
"debug": {
"title": "デバッグ",
"description": "開発者向けとトラブルシューティング用の設定です。",
"sidebar": "サイドバーにデバッグを表示",
"sidebarHint": "左のナビゲーションにデバッグのショートカットを表示します。"
},
"region": {
"title": "インスタンスのリージョン",
"description": "作成する新しいインスタンスのホスト地域です。自動を選ぶと、接続に最も遅延の少ないリージョンが選ばれます。",
@@ -5,6 +5,7 @@
"appearance": "รูปลักษณ์",
"game": "เกม",
"creator": "ครีเอเตอร์",
"debug": "ดีบัก",
"about": "เกี่ยวกับ"
},
"appearance": {
@@ -83,6 +84,12 @@
"version": "เวอร์ชัน",
"disclaimer": "ไม่เป็นทางการ ไม่ได้มีส่วนเกี่ยวข้องหรือได้รับการรับรองจาก VRChat Inc."
},
"debug": {
"title": "ดีบัก",
"description": "ตัวเลือกสำหรับนักพัฒนาและการแก้ปัญหา",
"sidebar": "แสดงดีบักในแถบด้านข้าง",
"sidebarHint": "เพิ่มทางลัดดีบักกลับไปที่เมนูนำทางด้านซ้าย"
},
"region": {
"title": "ภูมิภาคของอินสแตนซ์",
"description": "ภูมิภาคที่ใช้โฮสต์อินสแตนซ์ใหม่ที่คุณสร้าง อัตโนมัติจะเลือกภูมิภาคที่มีความหน่วงต่ำที่สุดสำหรับการเชื่อมต่อของคุณ",
+6 -27
View File
@@ -1,16 +1,9 @@
import i18n, { type Resource, type ResourceLanguage } from "i18next";
import { initReactI18next } from "react-i18next";
import type { Locale, LocaleCode, LocaleMeta } from "./types";
import { DEFAULT_LOCALE, LOCALES } from "../../../../shared/locales";
import type { Locale, LocaleCode } from "./types";
export const DEFAULT_LOCALE: LocaleCode = "en";
const STORAGE_KEY = "vrc-circle.locale";
const META: Record<LocaleCode, LocaleMeta> = {
en: { code: "en", nativeName: "English", englishName: "English" },
ja: { code: "ja", nativeName: "日本語", englishName: "Japanese" },
th: { code: "th", nativeName: "ไทย", englishName: "Thai" },
};
export { DEFAULT_LOCALE } from "../../../../shared/locales";
const files = import.meta.glob<{ default: Record<string, unknown> }>("./locales/*/*.json", {
eager: true,
@@ -29,11 +22,11 @@ export const NAMESPACES = [
];
function buildLocale(code: LocaleCode): Locale {
return { meta: META[code], messages: resources[code] ?? {} };
return { meta: LOCALES.find((locale) => locale.code === code)!, messages: resources[code] ?? {} };
}
const registry = new Map<LocaleCode, Locale>(
(Object.keys(META) as LocaleCode[]).map((code) => [code, buildLocale(code)]),
LOCALES.map((locale) => [locale.code, buildLocale(locale.code)]),
);
export function availableLocales(): Locale[] {
@@ -44,22 +37,8 @@ export function getLocale(code: LocaleCode): Locale {
return registry.get(code) ?? registry.get(DEFAULT_LOCALE)!;
}
export function storedLocale(): LocaleCode {
try {
const v = localStorage.getItem(STORAGE_KEY);
if (v && registry.has(v as LocaleCode)) return v as LocaleCode;
} catch {}
return DEFAULT_LOCALE;
}
export function persistLocale(code: LocaleCode): void {
try {
localStorage.setItem(STORAGE_KEY, code);
} catch {}
}
void i18n.use(initReactI18next).init({
lng: storedLocale(),
lng: DEFAULT_LOCALE,
fallbackLng: DEFAULT_LOCALE,
ns: NAMESPACES,
defaultNS: "common",
+3 -1
View File
@@ -1,4 +1,6 @@
export type LocaleCode = "en" | "ja" | "th";
import type { AppLocale } from "../../../../shared/locales";
export type LocaleCode = AppLocale;
export interface LocaleMeta {
code: LocaleCode;
+1 -45
View File
@@ -88,10 +88,6 @@ export const lightTheme: Theme = {
export const builtInThemes: Theme[] = [darkTheme, lightTheme];
const STORAGE_KEY = "vrc-circle.theme";
const ACCENT_KEY = "vrc-circle.accent";
const SCHEME_KEY = "vrc-circle.scheme";
export type SchemeMode = "light" | "dark" | "auto";
export const ACCENT_PRESETS: { key: string; name: string; value: string }[] = [
@@ -114,10 +110,6 @@ export function applyTheme(theme: Theme): void {
}
root.style.colorScheme = theme.scheme;
root.dataset.theme = theme.id;
try {
localStorage.setItem(STORAGE_KEY, theme.id);
} catch {}
applyAccent(storedAccent());
}
export function applyAccent(hex: string | null): void {
@@ -126,18 +118,6 @@ export function applyAccent(hex: string | null): void {
root.style.setProperty("--accent", accent);
root.style.setProperty("--on-accent", readableOn(accent));
root.style.setProperty("--accent-weak", withAlpha(accent, 0.14));
try {
if (hex) localStorage.setItem(ACCENT_KEY, hex);
else localStorage.removeItem(ACCENT_KEY);
} catch {}
}
export function storedAccent(): string | null {
try {
return localStorage.getItem(ACCENT_KEY);
} catch {
return null;
}
}
function parseHex(hex: string): [number, number, number] | null {
@@ -164,21 +144,11 @@ function readableOn(hex: string): string {
return luminance > 0.45 ? "#15151b" : "#ffffff";
}
export function initialTheme(registry: Theme[]): Theme {
const mode = storedScheme();
export function initialTheme(registry: Theme[], mode: SchemeMode): Theme {
if (mode !== "auto") {
const pick = registry.find((t) => t.scheme === mode);
if (pick) return pick;
}
let storedId: string | null = null;
try {
storedId = localStorage.getItem(STORAGE_KEY);
} catch {}
if (mode !== "auto") {
const stored = registry.find((t) => t.id === storedId);
if (stored) return stored;
}
return systemScheme() === "light" ? lightTheme : darkTheme;
}
@@ -187,17 +157,3 @@ export function systemScheme(): "light" | "dark" {
typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: light)").matches;
return prefersLight ? "light" : "dark";
}
export function storedScheme(): SchemeMode {
try {
const v = localStorage.getItem(SCHEME_KEY);
if (v === "light" || v === "dark" || v === "auto") return v;
} catch {}
return "auto";
}
export function persistScheme(mode: SchemeMode): void {
try {
localStorage.setItem(SCHEME_KEY, mode);
} catch {}
}
+3
View File
@@ -2,6 +2,7 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { ThemeProvider } from "./lib/ThemeContext";
import { I18nProvider } from "./lib/i18n";
import { AppConfigProvider } from "./lib/AppConfigContext";
import { AuthProvider } from "./features/auth/AuthContext";
import { App } from "./App";
import { MAC_CONTENT_INSET } from "../../shared/window";
@@ -19,6 +20,7 @@ if (window.api.platform === "darwin") {
createRoot(document.getElementById("root")!).render(
<StrictMode>
<AppConfigProvider>
<ThemeProvider>
<I18nProvider>
{isDebugWindow ? (
@@ -30,5 +32,6 @@ createRoot(document.getElementById("root")!).render(
)}
</I18nProvider>
</ThemeProvider>
</AppConfigProvider>
</StrictMode>,
);
+2 -1
View File
@@ -29,7 +29,7 @@ import type { AccountSettings, ContentFilterKey, Pending2Fa, RecoveryCode } from
import type { Group, GroupSnapshot } from "./types/group";
import type { EnhancementId, EnhancementsSnapshot } from "./types/enhancements";
import type { GallerySnapshot, Photo, ThumbCacheStats } from "./types/gallery";
import type { AppConfig, PreferredRegion, RegionPing } from "./types/appConfig";
import type { AppConfig, AppPreferences, PreferredRegion, RegionPing } from "./types/appConfig";
import type { GameStatus } from "./types/game";
import type { IpcResult } from "./types/result";
import type { CacheEntryInfo, CacheStats, DebugSnapshot, LogEntry, WsEvent } from "./types/debug";
@@ -150,6 +150,7 @@ export interface IpcRequests {
"config:setGamePath": (p: { gamePath: string | null }) => IpcResult<AppConfig>;
"config:pickGamePath": () => IpcResult<AppConfig>;
"config:setPreferredRegion": (p: { region: PreferredRegion }) => IpcResult<AppConfig>;
"config:setPreferences": (p: Partial<AppPreferences>) => IpcResult<AppConfig>;
"region:detect": () => IpcResult<InstanceRegion>;
"region:ping": () => IpcResult<RegionPing[]>;
+13
View File
@@ -0,0 +1,13 @@
export const DEFAULT_LOCALE = "en" as const;
export const LOCALES = [
{ code: "en", nativeName: "English", englishName: "English" },
{ code: "ja", nativeName: "日本語", englishName: "Japanese" },
{ code: "th", nativeName: "ไทย", englishName: "Thai" },
] as const;
export type AppLocale = (typeof LOCALES)[number]["code"];
export function isAppLocale(value: unknown): value is AppLocale {
return typeof value === "string" && LOCALES.some((locale) => locale.code === value);
}
+10
View File
@@ -1,12 +1,22 @@
import type { InstanceRegion } from "./instance";
import type { AppLocale } from "../locales";
export type PreferredRegion = InstanceRegion | "auto";
export type AppSchemeMode = "light" | "dark" | "auto";
export interface AppPreferences {
schemeMode: AppSchemeMode;
accent: string | null;
locale: AppLocale;
showDebugNav: boolean;
}
export interface AppConfig {
version: string;
gamePath: string | null;
detectedGamePath: string | null;
preferredRegion: PreferredRegion;
preferences: AppPreferences;
}
export interface RegionPing {