mirror of
https://github.com/YuzuZensai/VRC-Circle.git
synced 2026-09-13 19:08:52 +00:00
♻️ refactor: centralize app settings
This commit is contained in:
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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": () =>
|
||||
|
||||
@@ -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"));
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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": "ภูมิภาคที่ใช้โฮสต์อินสแตนซ์ใหม่ที่คุณสร้าง อัตโนมัติจะเลือกภูมิภาคที่มีความหน่วงต่ำที่สุดสำหรับการเชื่อมต่อของคุณ",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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[]>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user