适配新语言
This commit is contained in:
@@ -99,7 +99,7 @@ export default function BottomNav() {
|
|||||||
const [location, setLocation] = useLocation();
|
const [location, setLocation] = useLocation();
|
||||||
const isLgUp = useIsLgUp();
|
const isLgUp = useIsLgUp();
|
||||||
const { open: aiDrawerOpen, setOpen: setAiDrawerOpen } = useAIDrawer();
|
const { open: aiDrawerOpen, setOpen: setAiDrawerOpen } = useAIDrawer();
|
||||||
const { deviceState } = useDevice();
|
const { effectiveLanguage } = useDevice();
|
||||||
const { isNavHidden } = useNavConfig();
|
const { isNavHidden } = useNavConfig();
|
||||||
const festival = useFestival();
|
const festival = useFestival();
|
||||||
|
|
||||||
@@ -110,9 +110,9 @@ export default function BottomNav() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const navText = useMemo((): BottomNavLocale => {
|
const navText = useMemo((): BottomNavLocale => {
|
||||||
const pack = resolveLocalePack(deviceState?.language);
|
const pack = resolveLocalePack(effectiveLanguage);
|
||||||
return (pack.bottomNav ?? localeEn.bottomNav) as BottomNavLocale;
|
return (pack.bottomNav ?? localeEn.bottomNav) as BottomNavLocale;
|
||||||
}, [deviceState?.language]);
|
}, [effectiveLanguage]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
|
|||||||
@@ -4,18 +4,18 @@ import { isLikelyNetworkFetchError } from "@/lib/networkError";
|
|||||||
import { Loader2, Wifi } from "lucide-react";
|
import { Loader2, Wifi } from "lucide-react";
|
||||||
import BottomNav from "@/components/BottomNav";
|
import BottomNav from "@/components/BottomNav";
|
||||||
import localeZh from "@/locales/data-zh.json";
|
import localeZh from "@/locales/data-zh.json";
|
||||||
import localeZhHK from "@/locales/data-zh-HK.json";
|
import { getUiLanguageOverride, isKnownLanguage, resolveLocalePack } from "@/locales/resolveLocale";
|
||||||
import localeEn from "@/locales/data-en.json";
|
|
||||||
|
|
||||||
type ConnectLocale = (typeof localeZh)["connect"];
|
type ConnectLocale = (typeof localeZh)["connect"];
|
||||||
|
|
||||||
function pickConnectLocale(): ConnectLocale {
|
function pickConnectLocale(effectiveLanguage: number): ConnectLocale {
|
||||||
|
// 前端语言覆盖(de/es/fr)优先;否则沿用 URL ?lang=(设备语言);最后回退到生效语言。
|
||||||
|
const override = getUiLanguageOverride();
|
||||||
|
if (override != null) return resolveLocalePack(override).connect as ConnectLocale;
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const lang = params.get("lang");
|
const langParam = Number(params.get("lang"));
|
||||||
if (lang === "0") return localeEn.connect;
|
const lang = isKnownLanguage(langParam) ? langParam : effectiveLanguage;
|
||||||
if (lang === "1") return localeZhHK.connect;
|
return resolveLocalePack(lang).connect as ConnectLocale;
|
||||||
if (lang === "2") return localeZh.connect;
|
|
||||||
return localeEn.connect;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmt(template: string, vars: Record<string, string>): string {
|
function fmt(template: string, vars: Record<string, string>): string {
|
||||||
@@ -28,8 +28,8 @@ function fmt(template: string, vars: Record<string, string>): string {
|
|||||||
|
|
||||||
/** 自动连接设备时的占位(替代原 ConnectScreen) */
|
/** 自动连接设备时的占位(替代原 ConnectScreen) */
|
||||||
export default function ConnectionPlaceholder() {
|
export default function ConnectionPlaceholder() {
|
||||||
const { ip, isConnecting, error, ipMissing } = useDevice();
|
const { ip, isConnecting, error, ipMissing, effectiveLanguage } = useDevice();
|
||||||
const text = useMemo(pickConnectLocale, []);
|
const text = useMemo(() => pickConnectLocale(effectiveLanguage), [effectiveLanguage]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-black flex flex-col items-center justify-center px-6 pb-24">
|
<div className="min-h-screen bg-black flex flex-col items-center justify-center px-6 pb-24">
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ import {
|
|||||||
} from "@/lib/luxsinApi";
|
} from "@/lib/luxsinApi";
|
||||||
import { getDeviceIpFromUrl, resolveInitialDeviceIp } from "@/lib/deviceIp";
|
import { getDeviceIpFromUrl, resolveInitialDeviceIp } from "@/lib/deviceIp";
|
||||||
import { isLikelyNetworkFetchError } from "@/lib/networkError";
|
import { isLikelyNetworkFetchError } from "@/lib/networkError";
|
||||||
|
import {
|
||||||
|
DEFAULT_DEVICE_LANGUAGE,
|
||||||
|
getUiLanguageOverride,
|
||||||
|
isDeviceLanguage,
|
||||||
|
setUiLanguageOverride,
|
||||||
|
} from "@/locales/resolveLocale";
|
||||||
|
|
||||||
interface DeviceContextType {
|
interface DeviceContextType {
|
||||||
ip: string;
|
ip: string;
|
||||||
@@ -22,6 +28,16 @@ interface DeviceContextType {
|
|||||||
isDemoMode: boolean;
|
isDemoMode: boolean;
|
||||||
setDemoMode: (demo: boolean) => void;
|
setDemoMode: (demo: boolean) => void;
|
||||||
deviceState: DeviceState | null;
|
deviceState: DeviceState | null;
|
||||||
|
/**
|
||||||
|
* 生效的界面显示语言索引(0-9)。前端语言覆盖(非设备语言 3-9)优先,
|
||||||
|
* 否则跟随设备真实语言(0/1/2)。所有界面文案都应基于此值解析语言包。
|
||||||
|
*/
|
||||||
|
effectiveLanguage: number;
|
||||||
|
/**
|
||||||
|
* 设置界面语言:选择设备语言(0/1/2)会下发设备并清除前端覆盖;
|
||||||
|
* 选择其他语言(3-9)仅做前端切换并持久化,绝不下发设备。
|
||||||
|
*/
|
||||||
|
setUiLanguage: (index: number) => Promise<void>;
|
||||||
peqState: PeqState | null;
|
peqState: PeqState | null;
|
||||||
lastUpdated: Date | null;
|
lastUpdated: Date | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
@@ -56,6 +72,10 @@ export function DeviceProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [api, setApi] = useState<LuxsinAPI | null>(null);
|
const [api, setApi] = useState<LuxsinAPI | null>(null);
|
||||||
|
// 前端语言覆盖(仅非设备语言 3-9),初始值从 localStorage 恢复;null 表示跟随设备语言。
|
||||||
|
const [uiLanguage, setUiLanguageState] = useState<number | null>(() => getUiLanguageOverride());
|
||||||
|
// 记录上一次设备真实语言,用于检测设备侧语言变化(如轮询/刷新)后清除前端覆盖。
|
||||||
|
const prevDeviceLanguageRef = useRef<number | undefined>(undefined);
|
||||||
const lastMsgCountRef = useRef<number>(-1);
|
const lastMsgCountRef = useRef<number>(-1);
|
||||||
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
/** 轮询重入保护:上一轮未 settle(如断网挂起至超时)则跳过本轮,避免挂起请求堆叠。 */
|
/** 轮询重入保护:上一轮未 settle(如断网挂起至超时)则跳过本轮,避免挂起请求堆叠。 */
|
||||||
@@ -179,6 +199,37 @@ export function DeviceProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setDeviceState(prev => prev ? { ...prev, ...params } : prev);
|
setDeviceState(prev => prev ? { ...prev, ...params } : prev);
|
||||||
}, [api, isDemoMode]);
|
}, [api, isDemoMode]);
|
||||||
|
|
||||||
|
// 生效的界面语言:前端覆盖优先,其次设备真实语言,最后默认。
|
||||||
|
const effectiveLanguage = uiLanguage ?? deviceState?.language ?? DEFAULT_DEVICE_LANGUAGE;
|
||||||
|
|
||||||
|
const setUiLanguage = useCallback(async (index: number) => {
|
||||||
|
if (isDeviceLanguage(index)) {
|
||||||
|
// 设备支持的语言:清除前端覆盖并下发设备(沿用既有 setting 流程)。
|
||||||
|
setUiLanguageOverride(null);
|
||||||
|
setUiLanguageState(null);
|
||||||
|
await updateSetting({ language: index });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 非设备语言(3-9):仅前端切换 + 持久化,不下发设备。
|
||||||
|
setUiLanguageOverride(index);
|
||||||
|
setUiLanguageState(getUiLanguageOverride());
|
||||||
|
}, [updateSetting]);
|
||||||
|
|
||||||
|
// 设备侧语言发生变化(用户在设备上切换语言,经 msgCount 轮询/刷新同步下来)时,
|
||||||
|
// 清除前端语言覆盖,让 controller 跟随设备语言。
|
||||||
|
// 说明:仅在设备语言「值发生变化」时触发;首次观测(prev 为 undefined)不清除,
|
||||||
|
// 以保留跨会话持久化的前端覆盖(如上次选择了德语,刷新后仍应为德语)。
|
||||||
|
useEffect(() => {
|
||||||
|
const devLang = deviceState?.language;
|
||||||
|
if (devLang === undefined) return;
|
||||||
|
const prev = prevDeviceLanguageRef.current;
|
||||||
|
prevDeviceLanguageRef.current = devLang;
|
||||||
|
if (prev !== undefined && prev !== devLang) {
|
||||||
|
setUiLanguageOverride(null);
|
||||||
|
setUiLanguageState(null);
|
||||||
|
}
|
||||||
|
}, [deviceState?.language]);
|
||||||
|
|
||||||
const updatePeq = useCallback(async (filters: PeqFilter[]) => {
|
const updatePeq = useCallback(async (filters: PeqFilter[]) => {
|
||||||
if (isDemoMode) {
|
if (isDemoMode) {
|
||||||
setPeqState({ filters });
|
setPeqState({ filters });
|
||||||
@@ -290,6 +341,7 @@ export function DeviceProvider({ children }: { children: React.ReactNode }) {
|
|||||||
ip, setIp: setDeviceIp, ipMissing,
|
ip, setIp: setDeviceIp, ipMissing,
|
||||||
isConnected, isConnecting, isDemoMode, setDemoMode,
|
isConnected, isConnecting, isDemoMode, setDemoMode,
|
||||||
deviceState, peqState, lastUpdated, error,
|
deviceState, peqState, lastUpdated, error,
|
||||||
|
effectiveLanguage, setUiLanguage,
|
||||||
connect, disconnect, refresh, api,
|
connect, disconnect, refresh, api,
|
||||||
updateSetting, updatePeq, upgradePeqChange, upgradePeqApply, syncPeqCatalog,
|
updateSetting, updatePeq, upgradePeqChange, upgradePeqApply, syncPeqCatalog,
|
||||||
setVolume, setInput, setOutput, setBalance,
|
setVolume, setInput, setOutput, setBalance,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"volumeMaxConfirmOk": "Auf Maximum setzen",
|
"volumeMaxConfirmOk": "Auf Maximum setzen",
|
||||||
"volumePassthroughLockedToast": "Die Lautstärke ist im Durchleitungsmodus und kann nicht angepasst werden.",
|
"volumePassthroughLockedToast": "Die Lautstärke ist im Durchleitungsmodus und kann nicht angepasst werden.",
|
||||||
"volumePassthroughLockedHint": "Die Lautstärke ist im Durchleitungsmodus und kann nicht angepasst werden.",
|
"volumePassthroughLockedHint": "Die Lautstärke ist im Durchleitungsmodus und kann nicht angepasst werden.",
|
||||||
|
"hpMaxVolumeHint": "Das maximale Kopfhörervolumen ist eingestellt. Maximum: {db}dB",
|
||||||
"powerOffToastDemo": "Demomodus: Abschaltbefehl gesendet.",
|
"powerOffToastDemo": "Demomodus: Abschaltbefehl gesendet.",
|
||||||
"powerOffToastSent": "Abschaltbefehl gesendet.",
|
"powerOffToastSent": "Abschaltbefehl gesendet.",
|
||||||
"doubleClickHint": "Doppelklick zum Eintreten",
|
"doubleClickHint": "Doppelklick zum Eintreten",
|
||||||
@@ -940,7 +941,12 @@
|
|||||||
"deviceName": "Gerätename",
|
"deviceName": "Gerätename",
|
||||||
"firmwareVersion": "Firmware-Version",
|
"firmwareVersion": "Firmware-Version",
|
||||||
"macAddress": "MAC-Adresse",
|
"macAddress": "MAC-Adresse",
|
||||||
"deviceIp": "Geräte-IP"
|
"deviceIp": "Geräte-IP",
|
||||||
|
"sectionAmbientLight": "Umgebungslicht",
|
||||||
|
"ambientLight": "Hauptschalter",
|
||||||
|
"ledRed": "Rot",
|
||||||
|
"ledGreen": "Grün",
|
||||||
|
"ledBlue": "Blau"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"prompt": {
|
"prompt": {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"volumeMaxConfirmOk": "Poner al máximo",
|
"volumeMaxConfirmOk": "Poner al máximo",
|
||||||
"volumePassthroughLockedToast": "El volumen está en modo de paso directo y no se puede ajustar.",
|
"volumePassthroughLockedToast": "El volumen está en modo de paso directo y no se puede ajustar.",
|
||||||
"volumePassthroughLockedHint": "El volumen está en modo de paso directo y no se puede ajustar.",
|
"volumePassthroughLockedHint": "El volumen está en modo de paso directo y no se puede ajustar.",
|
||||||
|
"hpMaxVolumeHint": "El volumen máximo de los auriculares está configurado. Máximo: {db}dB",
|
||||||
"powerOffToastDemo": "Modo demo: comando de apagado enviado.",
|
"powerOffToastDemo": "Modo demo: comando de apagado enviado.",
|
||||||
"powerOffToastSent": "Comando de apagado enviado.",
|
"powerOffToastSent": "Comando de apagado enviado.",
|
||||||
"doubleClickHint": "Doble clic para entrar",
|
"doubleClickHint": "Doble clic para entrar",
|
||||||
@@ -940,7 +941,12 @@
|
|||||||
"deviceName": "Nombre del dispositivo",
|
"deviceName": "Nombre del dispositivo",
|
||||||
"firmwareVersion": "Versión de firmware",
|
"firmwareVersion": "Versión de firmware",
|
||||||
"macAddress": "Dirección MAC",
|
"macAddress": "Dirección MAC",
|
||||||
"deviceIp": "IP del dispositivo"
|
"deviceIp": "IP del dispositivo",
|
||||||
|
"sectionAmbientLight": "Luz ambiental",
|
||||||
|
"ambientLight": "Interruptor principal",
|
||||||
|
"ledRed": "Rojo",
|
||||||
|
"ledGreen": "Verde",
|
||||||
|
"ledBlue": "Azul"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"prompt": {
|
"prompt": {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"volumeMaxConfirmOk": "Mettre au maximum",
|
"volumeMaxConfirmOk": "Mettre au maximum",
|
||||||
"volumePassthroughLockedToast": "Le volume est en mode passage et ne peut pas être ajusté.",
|
"volumePassthroughLockedToast": "Le volume est en mode passage et ne peut pas être ajusté.",
|
||||||
"volumePassthroughLockedHint": "Le volume est en mode passage et ne peut pas être ajusté.",
|
"volumePassthroughLockedHint": "Le volume est en mode passage et ne peut pas être ajusté.",
|
||||||
|
"hpMaxVolumeHint": "Le volume maximal du casque est défini. Maximum : {db}dB",
|
||||||
"powerOffToastDemo": "Mode démo : commande d'extinction envoyée.",
|
"powerOffToastDemo": "Mode démo : commande d'extinction envoyée.",
|
||||||
"powerOffToastSent": "Commande d'extinction envoyée.",
|
"powerOffToastSent": "Commande d'extinction envoyée.",
|
||||||
"doubleClickHint": "Double-clic pour entrer",
|
"doubleClickHint": "Double-clic pour entrer",
|
||||||
@@ -940,7 +941,12 @@
|
|||||||
"deviceName": "Nom de l'appareil",
|
"deviceName": "Nom de l'appareil",
|
||||||
"firmwareVersion": "Version du firmware",
|
"firmwareVersion": "Version du firmware",
|
||||||
"macAddress": "Adresse MAC",
|
"macAddress": "Adresse MAC",
|
||||||
"deviceIp": "IP de l'appareil"
|
"deviceIp": "IP de l'appareil",
|
||||||
|
"sectionAmbientLight": "Lumière ambiante",
|
||||||
|
"ambientLight": "Interrupteur principal",
|
||||||
|
"ledRed": "Rouge",
|
||||||
|
"ledGreen": "Vert",
|
||||||
|
"ledBlue": "Bleu"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"prompt": {
|
"prompt": {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,19 +1,119 @@
|
|||||||
import localeEn from "./data-en.json";
|
import localeEn from "./data-en.json";
|
||||||
import localeZh from "./data-zh.json";
|
import localeZh from "./data-zh.json";
|
||||||
import localeZhHK from "./data-zh-HK.json";
|
import localeZhHK from "./data-zh-HK.json";
|
||||||
|
import localeDe from "./data-de.json";
|
||||||
|
import localeEs from "./data-es.json";
|
||||||
|
import localeFr from "./data-fr.json";
|
||||||
|
import localeKo from "./data-ko.json";
|
||||||
|
import localeVi from "./data-vi.json";
|
||||||
|
import localePt from "./data-pt.json";
|
||||||
|
import localePl from "./data-pl.json";
|
||||||
|
|
||||||
/** Device language index: 0=en, 1=zh-HK, 2=zh-CN */
|
/**
|
||||||
|
* 语言索引映射:
|
||||||
|
* 0=en, 1=zh-HK, 2=zh-CN → 设备固件支持,选择后会下发 dev/info.cgi?action=setting&language=
|
||||||
|
* 3=de, 4=es, 5=fr, 6=ko, 7=vi, 8=pt, 9=pl → 设备不支持,仅在 controller 前端做界面语言切换,绝不下发设备
|
||||||
|
*/
|
||||||
export const DEFAULT_DEVICE_LANGUAGE = 0;
|
export const DEFAULT_DEVICE_LANGUAGE = 0;
|
||||||
|
|
||||||
|
/** 设备固件真正支持的语言索引,只有这些允许下发到设备。 */
|
||||||
|
export const DEVICE_LANGUAGE_INDICES = [0, 1, 2] as const;
|
||||||
|
|
||||||
export type LocalePack = typeof localeEn;
|
export type LocalePack = typeof localeEn;
|
||||||
|
|
||||||
|
const LOCALE_PACKS = [
|
||||||
|
localeEn,
|
||||||
|
localeZhHK,
|
||||||
|
localeZh,
|
||||||
|
localeDe,
|
||||||
|
localeEs,
|
||||||
|
localeFr,
|
||||||
|
localeKo,
|
||||||
|
localeVi,
|
||||||
|
localePt,
|
||||||
|
localePl,
|
||||||
|
] as LocalePack[];
|
||||||
|
|
||||||
|
/** 语言选择器可展示的全部语言数量(含仅前端语言)。 */
|
||||||
|
export const LANGUAGE_COUNT = LOCALE_PACKS.length;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 语言选择器固定显示的「母语名称」,与当前界面语言无关。
|
||||||
|
* 数组下标即语言索引(0=en … 9=pl),选择时直接作为语言值使用。
|
||||||
|
*/
|
||||||
|
export const LANGUAGE_NATIVE_LABELS = [
|
||||||
|
"English",
|
||||||
|
"繁體中文",
|
||||||
|
"简体中文",
|
||||||
|
"Deutsch",
|
||||||
|
"Español",
|
||||||
|
"Français",
|
||||||
|
"한국어",
|
||||||
|
"Tiếng Việt",
|
||||||
|
"Português (Portugal)",
|
||||||
|
"Polski",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 是否为设备支持的语言(0/1/2),只有这些才会下发设备。 */
|
||||||
|
export function isDeviceLanguage(index: number | undefined | null): boolean {
|
||||||
|
return index === 0 || index === 1 || index === 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否为已知的语言索引(0..LANGUAGE_COUNT-1)。 */
|
||||||
|
export function isKnownLanguage(index: number | undefined | null): boolean {
|
||||||
|
return typeof index === "number" && index >= 0 && index < LOCALE_PACKS.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设备真实语言(不含前端覆盖),仅用于需要区分设备语言的场景。 */
|
||||||
export function resolveDeviceLanguage(language: number | undefined): number {
|
export function resolveDeviceLanguage(language: number | undefined): number {
|
||||||
return language ?? DEFAULT_DEVICE_LANGUAGE;
|
return language ?? DEFAULT_DEVICE_LANGUAGE;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveLocalePack(language: number | undefined): LocalePack {
|
/* ── 前端语言覆盖(仅非设备语言 3..9,持久化到 localStorage,绝不下发设备) ── */
|
||||||
const lang = resolveDeviceLanguage(language);
|
const UI_LANGUAGE_OVERRIDE_KEY = "luxsin.uiLanguageOverride";
|
||||||
if (lang === 0) return localeEn;
|
|
||||||
if (lang === 1) return localeZhHK;
|
function readStoredOverride(): number | null {
|
||||||
return localeZh;
|
try {
|
||||||
|
const raw = localStorage.getItem(UI_LANGUAGE_OVERRIDE_KEY);
|
||||||
|
if (raw == null) return null;
|
||||||
|
const n = Number(raw);
|
||||||
|
return isKnownLanguage(n) && !isDeviceLanguage(n) ? n : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let uiLanguageOverride: number | null = readStoredOverride();
|
||||||
|
|
||||||
|
/** 读取当前前端语言覆盖;无覆盖(跟随设备语言)时返回 null。 */
|
||||||
|
export function getUiLanguageOverride(): number | null {
|
||||||
|
return uiLanguageOverride;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置前端语言覆盖。传入仅前端语言(3..9)时生效并持久化;
|
||||||
|
* 传入设备语言(0/1/2)或 null 时清除覆盖,恢复跟随设备语言。
|
||||||
|
*/
|
||||||
|
export function setUiLanguageOverride(index: number | null): number | null {
|
||||||
|
uiLanguageOverride =
|
||||||
|
isKnownLanguage(index) && !isDeviceLanguage(index) ? (index as number) : null;
|
||||||
|
try {
|
||||||
|
if (uiLanguageOverride == null) localStorage.removeItem(UI_LANGUAGE_OVERRIDE_KEY);
|
||||||
|
else localStorage.setItem(UI_LANGUAGE_OVERRIDE_KEY, String(uiLanguageOverride));
|
||||||
|
} catch {
|
||||||
|
/* localStorage 不可用时忽略,仅保留内存态 */
|
||||||
|
}
|
||||||
|
return uiLanguageOverride;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生效的显示语言:前端覆盖优先,其次设备语言,最后默认。 */
|
||||||
|
export function resolveEffectiveLanguage(language: number | undefined): number {
|
||||||
|
if (uiLanguageOverride != null) return uiLanguageOverride;
|
||||||
|
const l = language ?? DEFAULT_DEVICE_LANGUAGE;
|
||||||
|
return isKnownLanguage(l) ? l : DEFAULT_DEVICE_LANGUAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveLocalePack(language: number | undefined): LocalePack {
|
||||||
|
const l = language ?? DEFAULT_DEVICE_LANGUAGE;
|
||||||
|
return LOCALE_PACKS[isKnownLanguage(l) ? l : DEFAULT_DEVICE_LANGUAGE] ?? localeEn;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ import { Streamdown } from "streamdown";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import localeZh from "@/locales/data-zh.json";
|
import localeZh from "@/locales/data-zh.json";
|
||||||
import localeEn from "@/locales/data-en.json";
|
import localeEn from "@/locales/data-en.json";
|
||||||
import { resolveDeviceLanguage, resolveLocalePack } from "@/locales/resolveLocale";
|
import { resolveLocalePack } from "@/locales/resolveLocale";
|
||||||
|
|
||||||
type AILocale = NonNullable<(typeof localeZh)["ai"]>;
|
type AILocale = NonNullable<(typeof localeZh)["ai"]>;
|
||||||
|
|
||||||
@@ -224,9 +224,9 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {})
|
|||||||
const [routePath, setLocation] = useLocation();
|
const [routePath, setLocation] = useLocation();
|
||||||
const isLgUp = useIsLgUp();
|
const isLgUp = useIsLgUp();
|
||||||
const { setOpen: setAiPanelOpen } = useAIDrawer();
|
const { setOpen: setAiPanelOpen } = useAIDrawer();
|
||||||
const { isConnected, deviceState, api, updateSetting } = useDevice();
|
const { isConnected, deviceState, api, updateSetting, effectiveLanguage } = useDevice();
|
||||||
const mac = deviceState?.mac ?? "";
|
const mac = deviceState?.mac ?? "";
|
||||||
const language = resolveDeviceLanguage(deviceState?.language);
|
const language = effectiveLanguage;
|
||||||
const deviceName = deviceState?.device?.trim() || "Luxsin X8";
|
const deviceName = deviceState?.device?.trim() || "Luxsin X8";
|
||||||
|
|
||||||
const aiText = useMemo(() => resolveAILocale(language), [language]);
|
const aiText = useMemo(() => resolveAILocale(language), [language]);
|
||||||
|
|||||||
@@ -183,10 +183,10 @@ function bootVolumeRowLabel(dacText: LocaleDac["dac"]): string {
|
|||||||
|
|
||||||
export default function AudioPage() {
|
export default function AudioPage() {
|
||||||
const [location, setLocation] = useLocation();
|
const [location, setLocation] = useLocation();
|
||||||
const { deviceState, updateSetting, refresh, isConnected, api } = useDevice();
|
const { deviceState, updateSetting, refresh, isConnected, api, effectiveLanguage } = useDevice();
|
||||||
const ds = deviceState;
|
const ds = deviceState;
|
||||||
const vuSensor = (ds as ({ vuSensor?: number } | null))?.vuSensor;
|
const vuSensor = (ds as ({ vuSensor?: number } | null))?.vuSensor;
|
||||||
const localeData = resolveLocalePack(ds?.language) as LocaleDac;
|
const localeData = resolveLocalePack(effectiveLanguage) as LocaleDac;
|
||||||
const dacText = localeData.dac ?? {};
|
const dacText = localeData.dac ?? {};
|
||||||
|
|
||||||
// balance 从接口获取的是乘以 10 后的值,需要除以 10 显示(例如:-130 → -13.0)
|
// balance 从接口获取的是乘以 10 后的值,需要除以 10 显示(例如:-130 → -13.0)
|
||||||
|
|||||||
@@ -17,12 +17,12 @@ type HomeLocale = NonNullable<(typeof localeZh)["home"]>;
|
|||||||
|
|
||||||
export default function ChangelogPage() {
|
export default function ChangelogPage() {
|
||||||
const [, setLocation] = useLocation();
|
const [, setLocation] = useLocation();
|
||||||
const { deviceState } = useDevice();
|
const { effectiveLanguage } = useDevice();
|
||||||
|
|
||||||
const homeText = useMemo((): HomeLocale => {
|
const homeText = useMemo((): HomeLocale => {
|
||||||
const pack = resolveLocalePack(deviceState?.language);
|
const pack = resolveLocalePack(effectiveLanguage);
|
||||||
return (pack.home ?? {}) as HomeLocale;
|
return (pack.home ?? {}) as HomeLocale;
|
||||||
}, [deviceState?.language]);
|
}, [effectiveLanguage]);
|
||||||
|
|
||||||
const pageTitle = usePageTitleWithConnection(homeText.changelog ?? "更新说明");
|
const pageTitle = usePageTitleWithConnection(homeText.changelog ?? "更新说明");
|
||||||
|
|
||||||
|
|||||||
@@ -98,16 +98,17 @@ export default function EQPage() {
|
|||||||
upgradePeqChange,
|
upgradePeqChange,
|
||||||
upgradePeqApply,
|
upgradePeqApply,
|
||||||
syncPeqCatalog,
|
syncPeqCatalog,
|
||||||
|
effectiveLanguage,
|
||||||
} = useDevice();
|
} = useDevice();
|
||||||
const bypassOn = (deviceState?.dsp_enable ?? 0) === 0;
|
const bypassOn = (deviceState?.dsp_enable ?? 0) === 0;
|
||||||
const peqOn = (deviceState?.peqEnable ?? 0) === 1;
|
const peqOn = (deviceState?.peqEnable ?? 0) === 1;
|
||||||
const eqOn = peqOn && !bypassOn;
|
const eqOn = peqOn && !bypassOn;
|
||||||
|
|
||||||
const eqUi = useMemo((): PeqEqUi => {
|
const eqUi = useMemo((): PeqEqUi => {
|
||||||
const pack = resolveLocalePack(deviceState?.language);
|
const pack = resolveLocalePack(effectiveLanguage);
|
||||||
const peq = pack.peq as typeof localeZh.peq;
|
const peq = pack.peq as typeof localeZh.peq;
|
||||||
return (peq.eqUi ?? (localeEn.peq as typeof localeEn.peq).eqUi) as PeqEqUi;
|
return (peq.eqUi ?? (localeEn.peq as typeof localeEn.peq).eqUi) as PeqEqUi;
|
||||||
}, [deviceState?.language]);
|
}, [effectiveLanguage]);
|
||||||
|
|
||||||
const firmwareVersion = useMemo(
|
const firmwareVersion = useMemo(
|
||||||
() => parseFirmwareVersion(deviceState?.version),
|
() => parseFirmwareVersion(deviceState?.version),
|
||||||
@@ -116,9 +117,9 @@ export default function EQPage() {
|
|||||||
const peqReorderEnabled = supportsPeqPresetRenameAndSort(firmwareVersion);
|
const peqReorderEnabled = supportsPeqPresetRenameAndSort(firmwareVersion);
|
||||||
|
|
||||||
const peqCardLabels = useMemo(() => {
|
const peqCardLabels = useMemo(() => {
|
||||||
const pack = resolveLocalePack(deviceState?.language);
|
const pack = resolveLocalePack(effectiveLanguage);
|
||||||
return pack.peq ?? localeEn.peq;
|
return pack.peq ?? localeEn.peq;
|
||||||
}, [deviceState?.language]);
|
}, [effectiveLanguage]);
|
||||||
|
|
||||||
const eqPageTitle = usePageTitleWithConnection("HP-EQ");
|
const eqPageTitle = usePageTitleWithConnection("HP-EQ");
|
||||||
|
|
||||||
|
|||||||
@@ -97,12 +97,12 @@ function CyanSlider({
|
|||||||
|
|
||||||
export default function EffectsPage() {
|
export default function EffectsPage() {
|
||||||
const [, setLocation] = useLocation();
|
const [, setLocation] = useLocation();
|
||||||
const { deviceState, updateSetting } = useDevice();
|
const { deviceState, updateSetting, effectiveLanguage } = useDevice();
|
||||||
|
|
||||||
const effectText = useMemo((): EffectLocale => {
|
const effectText = useMemo((): EffectLocale => {
|
||||||
const pack = resolveLocalePack(deviceState?.language);
|
const pack = resolveLocalePack(effectiveLanguage);
|
||||||
return (pack.effect ?? localeEn.effect) as EffectLocale;
|
return (pack.effect ?? localeEn.effect) as EffectLocale;
|
||||||
}, [deviceState?.language]);
|
}, [effectiveLanguage]);
|
||||||
|
|
||||||
const pageTitle = usePageTitleWithConnection(effectText.pageTitle ?? "Effects");
|
const pageTitle = usePageTitleWithConnection(effectText.pageTitle ?? "Effects");
|
||||||
|
|
||||||
|
|||||||
@@ -70,8 +70,7 @@ import { useLocation } from "wouter";
|
|||||||
import { useRef, useState, useEffect, useCallback, useMemo } from "react";
|
import { useRef, useState, useEffect, useCallback, useMemo } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import localeZh from "@/locales/data-zh.json";
|
import localeZh from "@/locales/data-zh.json";
|
||||||
import localeZhHK from "@/locales/data-zh-HK.json";
|
import { resolveLocalePack } from "@/locales/resolveLocale";
|
||||||
import localeEn from "@/locales/data-en.json";
|
|
||||||
|
|
||||||
type HomeLocale = NonNullable<(typeof localeZh)["home"]>;
|
type HomeLocale = NonNullable<(typeof localeZh)["home"]>;
|
||||||
|
|
||||||
@@ -203,7 +202,7 @@ function ListRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const { isConnected, deviceState, setVolume, updateSetting, api, isDemoMode, ip } = useDevice();
|
const { isConnected, deviceState, setVolume, updateSetting, api, isDemoMode, ip, effectiveLanguage } = useDevice();
|
||||||
const [, setLocation] = useLocation();
|
const [, setLocation] = useLocation();
|
||||||
const festival = useFestival();
|
const festival = useFestival();
|
||||||
const isChristmas = festival === "christmas";
|
const isChristmas = festival === "christmas";
|
||||||
@@ -249,10 +248,8 @@ export default function Home() {
|
|||||||
|
|
||||||
const homeText = useMemo((): HomeLocale => {
|
const homeText = useMemo((): HomeLocale => {
|
||||||
if (!deviceState) return {} as HomeLocale;
|
if (!deviceState) return {} as HomeLocale;
|
||||||
const loc =
|
return (resolveLocalePack(effectiveLanguage).home ?? {}) as HomeLocale;
|
||||||
deviceState.language === 0 ? localeEn : deviceState.language === 1 ? localeZhHK : localeZh;
|
}, [deviceState, effectiveLanguage]);
|
||||||
return (loc.home ?? {}) as HomeLocale;
|
|
||||||
}, [deviceState]);
|
|
||||||
|
|
||||||
const volumeConfirmCopy = useMemo(() => {
|
const volumeConfirmCopy = useMemo(() => {
|
||||||
if (volumeConfirm === "min") {
|
if (volumeConfirm === "min") {
|
||||||
|
|||||||
@@ -178,12 +178,12 @@ function IOCircleButton({
|
|||||||
|
|
||||||
export default function IOPage() {
|
export default function IOPage() {
|
||||||
const [, setLocation] = useLocation();
|
const [, setLocation] = useLocation();
|
||||||
const { deviceState, setInput, setOutput } = useDevice();
|
const { deviceState, setInput, setOutput, effectiveLanguage } = useDevice();
|
||||||
|
|
||||||
const sourceText = useMemo((): SourceLocale => {
|
const sourceText = useMemo((): SourceLocale => {
|
||||||
const pack = resolveLocalePack(deviceState?.language);
|
const pack = resolveLocalePack(effectiveLanguage);
|
||||||
return (pack.source ?? {}) as SourceLocale;
|
return (pack.source ?? {}) as SourceLocale;
|
||||||
}, [deviceState?.language]);
|
}, [effectiveLanguage]);
|
||||||
|
|
||||||
const inputOptions = useMemo(
|
const inputOptions = useMemo(
|
||||||
() => withLabels(INPUT_OPTION_DEFS, sourceText.inputOptions, FALLBACK_INPUT_LABELS),
|
() => withLabels(INPUT_OPTION_DEFS, sourceText.inputOptions, FALLBACK_INPUT_LABELS),
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export function navigateToSelect(title: string, options: string[], selected: num
|
|||||||
|
|
||||||
export default function SelectPage() {
|
export default function SelectPage() {
|
||||||
const [location, setLocation] = useLocation();
|
const [location, setLocation] = useLocation();
|
||||||
const { updateSetting, deviceState } = useDevice();
|
const { updateSetting, effectiveLanguage, setUiLanguage } = useDevice();
|
||||||
const [state, setState] = useState<{
|
const [state, setState] = useState<{
|
||||||
title: string;
|
title: string;
|
||||||
options: string[];
|
options: string[];
|
||||||
@@ -119,9 +119,9 @@ export default function SelectPage() {
|
|||||||
const pageTitle = usePageTitleWithConnection(state?.title ?? "");
|
const pageTitle = usePageTitleWithConnection(state?.title ?? "");
|
||||||
|
|
||||||
const volumeDirectConfirmText = useMemo((): DacVolumeDirectConfirmLocale => {
|
const volumeDirectConfirmText = useMemo((): DacVolumeDirectConfirmLocale => {
|
||||||
const pack = resolveLocalePack(deviceState?.language);
|
const pack = resolveLocalePack(effectiveLanguage);
|
||||||
return (pack.dac?.dacVolumeDirectConfirm ?? localeEn.dac!.dacVolumeDirectConfirm) as DacVolumeDirectConfirmLocale;
|
return (pack.dac?.dacVolumeDirectConfirm ?? localeEn.dac!.dacVolumeDirectConfirm) as DacVolumeDirectConfirmLocale;
|
||||||
}, [deviceState?.language]);
|
}, [effectiveLanguage]);
|
||||||
|
|
||||||
// Re-read global state whenever we enter /select (avoids stale key/options after remount)
|
// Re-read global state whenever we enter /select (avoids stale key/options after remount)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -140,6 +140,20 @@ export default function SelectPage() {
|
|||||||
if (saving) return;
|
if (saving) return;
|
||||||
selectPageResult = { key, selected: idx };
|
selectPageResult = { key, selected: idx };
|
||||||
|
|
||||||
|
// 语言选择:设备语言(0/1/2)下发设备,de/es/fr(3/4/5)仅前端切换,
|
||||||
|
// 由 setUiLanguage 内部区分,避免向不支持多语言的设备下发无效值。
|
||||||
|
if (key === "language") {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await setUiLanguage(idx);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
selectPageState = null;
|
||||||
|
setLocation(back);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const apiField = KEY_TO_SETTING[key];
|
const apiField = KEY_TO_SETTING[key];
|
||||||
if (apiField) {
|
if (apiField) {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
readCachedFirmwareVersionName,
|
readCachedFirmwareVersionName,
|
||||||
} from "@/lib/luxsinApi";
|
} from "@/lib/luxsinApi";
|
||||||
import localeEn from "@/locales/data-en.json";
|
import localeEn from "@/locales/data-en.json";
|
||||||
import { DEFAULT_DEVICE_LANGUAGE, resolveDeviceLanguage, resolveLocalePack } from "@/locales/resolveLocale";
|
import { DEFAULT_DEVICE_LANGUAGE, isKnownLanguage, LANGUAGE_NATIVE_LABELS, resolveLocalePack } from "@/locales/resolveLocale";
|
||||||
import { usePageTitleWithConnection } from "@/hooks/usePageTitleWithConnection";
|
import { usePageTitleWithConnection } from "@/hooks/usePageTitleWithConnection";
|
||||||
|
|
||||||
function IOSToggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
function IOSToggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||||||
@@ -168,21 +168,9 @@ function optionLabels(items: Array<{ index: number; label: string }> | undefined
|
|||||||
return sorted.map((item) => item.label);
|
return sorted.map((item) => item.label);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Device language index: 0=en, 1=zh-HK, 2=zh-CN */
|
|
||||||
const SUPPORTED_LANGUAGE_INDICES = new Set([0, 1, 2]);
|
|
||||||
|
|
||||||
function languageLabels(
|
|
||||||
items: Array<{ index: number; label: string }> | undefined,
|
|
||||||
fallback: string[],
|
|
||||||
) {
|
|
||||||
const filtered = (items ?? []).filter((o) => SUPPORTED_LANGUAGE_INDICES.has(o.index));
|
|
||||||
if (filtered.length === 0) return fallback;
|
|
||||||
return [...filtered].sort((a, b) => a.index - b.index).map((o) => o.label);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SystemPage() {
|
export default function SystemPage() {
|
||||||
const [, setLocation] = useLocation();
|
const [, setLocation] = useLocation();
|
||||||
const { deviceState, updateSetting, refresh, isConnected, api, ip } = useDevice();
|
const { deviceState, updateSetting, refresh, isConnected, api, ip, effectiveLanguage } = useDevice();
|
||||||
const ds = deviceState;
|
const ds = deviceState;
|
||||||
|
|
||||||
// 页面加载且已连接时刷新数据
|
// 页面加载且已连接时刷新数据
|
||||||
@@ -232,8 +220,7 @@ export default function SystemPage() {
|
|||||||
const screenOffIdx = ds?.screenOff ?? 2;
|
const screenOffIdx = ds?.screenOff ?? 2;
|
||||||
const sleepIdx = ds?.sleep ?? 0;
|
const sleepIdx = ds?.sleep ?? 0;
|
||||||
const autoHomeIdx = ds?.autoHome ?? 1;
|
const autoHomeIdx = ds?.autoHome ?? 1;
|
||||||
const rawLangIdx = resolveDeviceLanguage(ds?.language);
|
const langIdx = isKnownLanguage(effectiveLanguage) ? effectiveLanguage : DEFAULT_DEVICE_LANGUAGE;
|
||||||
const langIdx = SUPPORTED_LANGUAGE_INDICES.has(rawLangIdx) ? rawLangIdx : DEFAULT_DEVICE_LANGUAGE;
|
|
||||||
const knobBreathLightOn = (ds?.knob_breathlight ?? 1) === 0;
|
const knobBreathLightOn = (ds?.knob_breathlight ?? 1) === 0;
|
||||||
const firmwareVersion = parseFirmwareVersion(ds?.version);
|
const firmwareVersion = parseFirmwareVersion(ds?.version);
|
||||||
const showAmbientLed = supportsAmbientLed(firmwareVersion);
|
const showAmbientLed = supportsAmbientLed(firmwareVersion);
|
||||||
@@ -258,7 +245,7 @@ export default function SystemPage() {
|
|||||||
if (!draggingLedBlue.current) setLocalLedBlue(clampLedChannel(ds?.led_blue));
|
if (!draggingLedBlue.current) setLocalLedBlue(clampLedChannel(ds?.led_blue));
|
||||||
}, [ds?.led_blue]);
|
}, [ds?.led_blue]);
|
||||||
|
|
||||||
const localeData = resolveLocalePack(ds?.language) as LocaleSettings;
|
const localeData = resolveLocalePack(effectiveLanguage) as LocaleSettings;
|
||||||
const settingsText = localeData.settings;
|
const settingsText = localeData.settings;
|
||||||
const ui = settingsText.systemPage;
|
const ui = settingsText.systemPage;
|
||||||
const SCREEN_LIGHT_OPTIONS = optionLabels(settingsText.screenBrightness?.options, ["亮", "中等", "暗"]);
|
const SCREEN_LIGHT_OPTIONS = optionLabels(settingsText.screenBrightness?.options, ["亮", "中等", "暗"]);
|
||||||
@@ -266,11 +253,9 @@ export default function SystemPage() {
|
|||||||
const SCREEN_OFF_OPTIONS = optionLabels(settingsText.turnOffScreen?.options, ["常亮", "30秒", "1分钟", "3分钟", "5分钟"]);
|
const SCREEN_OFF_OPTIONS = optionLabels(settingsText.turnOffScreen?.options, ["常亮", "30秒", "1分钟", "3分钟", "5分钟"]);
|
||||||
const SLEEP_OPTIONS = optionLabels(settingsText.sleepTime?.options ?? settingsText.sleep?.options, ["关闭", "1分钟", "5分钟", "10分钟"]);
|
const SLEEP_OPTIONS = optionLabels(settingsText.sleepTime?.options ?? settingsText.sleep?.options, ["关闭", "1分钟", "5分钟", "10分钟"]);
|
||||||
const AUTO_HOME_OPTIONS = optionLabels(settingsText.Automatic?.options, ["关闭", "20秒", "40秒", "60秒"]);
|
const AUTO_HOME_OPTIONS = optionLabels(settingsText.Automatic?.options, ["关闭", "20秒", "40秒", "60秒"]);
|
||||||
const LANG_OPTIONS = languageLabels(settingsText.language?.options, [
|
// 语言选项固定显示各语言的母语名称,与当前界面语言无关;
|
||||||
"English",
|
// 数组下标即语言索引(0=en … 9=pl),与 SelectPage 回传的值一致。
|
||||||
"繁體中文",
|
const LANG_OPTIONS = [...LANGUAGE_NATIVE_LABELS];
|
||||||
"简体中文",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const sleepRowLabel =
|
const sleepRowLabel =
|
||||||
settingsText.sleepTime?.label ??
|
settingsText.sleepTime?.label ??
|
||||||
|
|||||||
@@ -32,13 +32,13 @@ const VU_STYLES: VUStyle[] = Array.from({ length: 16 }, (_, i) => ({
|
|||||||
|
|
||||||
export default function VUPage() {
|
export default function VUPage() {
|
||||||
const [, setLocation] = useLocation();
|
const [, setLocation] = useLocation();
|
||||||
const { deviceState, updateSetting } = useDevice();
|
const { deviceState, updateSetting, effectiveLanguage } = useDevice();
|
||||||
const currentVU = deviceState?.vu ?? 0;
|
const currentVU = deviceState?.vu ?? 0;
|
||||||
|
|
||||||
const vuPageText = useMemo((): VUPageLocale => {
|
const vuPageText = useMemo((): VUPageLocale => {
|
||||||
const loc = resolveLocalePack(deviceState?.language);
|
const loc = resolveLocalePack(effectiveLanguage);
|
||||||
return (loc.vuPage ?? localeEn.vuPage ?? {}) as VUPageLocale;
|
return (loc.vuPage ?? localeEn.vuPage ?? {}) as VUPageLocale;
|
||||||
}, [deviceState?.language]);
|
}, [effectiveLanguage]);
|
||||||
|
|
||||||
const pageTitle = usePageTitleWithConnection(vuPageText.title ?? "VU");
|
const pageTitle = usePageTitleWithConnection(vuPageText.title ?? "VU");
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user