Refactor layout components to improve responsiveness and accessibility; integrate appShell classes for consistent styling across DesktopAIShell and BottomNav. Update SelectPage to enhance option selection UI with image support and language filtering logic in SystemPage for better localization handling.
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 46 KiB |
@@ -22,6 +22,7 @@ import AIPage from "./pages/AIPage";
|
|||||||
import CommunityPage from "./pages/CommunityPage";
|
import CommunityPage from "./pages/CommunityPage";
|
||||||
import { AIDrawerProvider } from "./contexts/AIDrawerContext";
|
import { AIDrawerProvider } from "./contexts/AIDrawerContext";
|
||||||
import DesktopAIShell from "./components/DesktopAIShell";
|
import DesktopAIShell from "./components/DesktopAIShell";
|
||||||
|
import { appShellColumnClass, appShellGutterClass } from "./lib/appShell";
|
||||||
|
|
||||||
function AIPageRoute() {
|
function AIPageRoute() {
|
||||||
return <AIPage />;
|
return <AIPage />;
|
||||||
@@ -59,9 +60,10 @@ function App() {
|
|||||||
<Toaster />
|
<Toaster />
|
||||||
<Router hook={useHashLocation}>
|
<Router hook={useHashLocation}>
|
||||||
<AIDrawerProvider>
|
<AIDrawerProvider>
|
||||||
{/* Desktop: narrow shell centered in the browser with side gutters */}
|
<div
|
||||||
<div className="min-h-dvh bg-[#0d1117]">
|
className={`min-h-dvh bg-[#0d1117] flex justify-center ${appShellGutterClass}`}
|
||||||
<div className="mx-auto min-h-dvh w-full max-w-full lg:max-w-[min(1180px,calc(100vw-3rem))]">
|
>
|
||||||
|
<div className={`min-h-dvh ${appShellColumnClass}`}>
|
||||||
<DesktopAIShell>
|
<DesktopAIShell>
|
||||||
<AppRouter />
|
<AppRouter />
|
||||||
</DesktopAIShell>
|
</DesktopAIShell>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Link, useLocation } from "wouter";
|
import { Link, useLocation } from "wouter";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { appShellColumnClass, appShellGutterClass } from "@/lib/appShell";
|
||||||
|
|
||||||
type BottomNavLocale = NonNullable<(typeof localeZh)["bottomNav"]>;
|
type BottomNavLocale = NonNullable<(typeof localeZh)["bottomNav"]>;
|
||||||
|
|
||||||
@@ -56,16 +57,19 @@ export default function BottomNav() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
className="fixed bottom-0 left-0 right-0 z-50 lg:left-1/2 lg:right-auto lg:w-[min(1180px,calc(100vw-3rem))] lg:-translate-x-1/2"
|
className={`fixed inset-x-0 bottom-0 z-50 flex justify-center pointer-events-none ${appShellGutterClass}`}
|
||||||
style={{
|
|
||||||
background: "rgba(10, 10, 12, 0.96)",
|
|
||||||
backdropFilter: "blur(40px) saturate(200%)",
|
|
||||||
WebkitBackdropFilter: "blur(40px) saturate(200%)",
|
|
||||||
borderTop: "0.5px solid rgba(255,255,255,0.1)",
|
|
||||||
boxShadow: "0 -1px 0 rgba(0,255,246,0.05)",
|
|
||||||
paddingBottom: "env(safe-area-inset-bottom, 8px)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
|
<div
|
||||||
|
className={`pointer-events-auto ${appShellColumnClass}`}
|
||||||
|
style={{
|
||||||
|
background: "rgba(10, 10, 12, 0.96)",
|
||||||
|
backdropFilter: "blur(40px) saturate(200%)",
|
||||||
|
WebkitBackdropFilter: "blur(40px) saturate(200%)",
|
||||||
|
borderTop: "0.5px solid rgba(255,255,255,0.1)",
|
||||||
|
boxShadow: "0 -1px 0 rgba(0,255,246,0.05)",
|
||||||
|
paddingBottom: "env(safe-area-inset-bottom, 8px)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="flex items-stretch justify-around">
|
<div className="flex items-stretch justify-around">
|
||||||
{NAV_ITEMS.map((item) => {
|
{NAV_ITEMS.map((item) => {
|
||||||
const isActive =
|
const isActive =
|
||||||
@@ -192,6 +196,7 @@ export default function BottomNav() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,9 @@ export default function DesktopAIShell({ children }: { children: ReactNode }) {
|
|||||||
"min-w-0",
|
"min-w-0",
|
||||||
showSplit
|
showSplit
|
||||||
? "h-full min-h-0 overflow-y-auto overflow-x-hidden"
|
? "h-full min-h-0 overflow-y-auto overflow-x-hidden"
|
||||||
: "min-h-dvh max-h-dvh overflow-x-hidden overflow-y-auto overscroll-y-contain [touch-action:pan-y]",
|
: isLgUp
|
||||||
|
? "min-h-dvh overflow-x-hidden"
|
||||||
|
: "min-h-dvh max-h-dvh overflow-x-hidden overflow-y-auto overscroll-y-contain [touch-action:pan-y]",
|
||||||
)}
|
)}
|
||||||
style={
|
style={
|
||||||
showSplit
|
showSplit
|
||||||
|
|||||||
@@ -116,12 +116,25 @@
|
|||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
font-family: -apple-system, 'SF Pro Display', 'SF Pro Text', 'Helvetica Neue', sans-serif;
|
font-family: -apple-system, 'SF Pro Display', 'SF Pro Text', 'Helvetica Neue', sans-serif;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
background-color: #000000;
|
background-color: #000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Desktop / ultrawide: page scrolls on window; shell stays centered */
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
html {
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
button:not(:disabled),
|
button:not(:disabled),
|
||||||
[role="button"]:not([aria-disabled="true"]),
|
[role="button"]:not([aria-disabled="true"]),
|
||||||
[type="button"]:not(:disabled),
|
[type="button"]:not(:disabled),
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/** Centered phone-style shell width (desktop / ultrawide). */
|
||||||
|
export const APP_SHELL_MAX_PX = 1180;
|
||||||
|
|
||||||
|
/** Inner column inside horizontal gutters — use with a flex/grid centering parent. */
|
||||||
|
export const appShellColumnClass =
|
||||||
|
"w-full min-w-0 max-w-[min(1180px,100%)]";
|
||||||
|
|
||||||
|
export const appShellGutterClass = "px-4 sm:px-6";
|
||||||
@@ -546,19 +546,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"index": 2,
|
"index": 2,
|
||||||
"label": "Chinese"
|
"label": "Simplified Chinese"
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 3,
|
|
||||||
"label": "German"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 4,
|
|
||||||
"label": "Spanish"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 5,
|
|
||||||
"label": "French"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -384,10 +384,7 @@
|
|||||||
"options": [
|
"options": [
|
||||||
{ "index": 0, "label": "英語" },
|
{ "index": 0, "label": "英語" },
|
||||||
{ "index": 1, "label": "中文(繁體)" },
|
{ "index": 1, "label": "中文(繁體)" },
|
||||||
{ "index": 2, "label": "中文(簡體)" },
|
{ "index": 2, "label": "中文(簡體)" }
|
||||||
{ "index": 3, "label": "德語" },
|
|
||||||
{ "index": 4, "label": "西語" },
|
|
||||||
{ "index": 5, "label": "法語" }
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"Automatic": {
|
"Automatic": {
|
||||||
|
|||||||
@@ -385,10 +385,7 @@
|
|||||||
"options": [
|
"options": [
|
||||||
{ "index": 0, "label": "英语" },
|
{ "index": 0, "label": "英语" },
|
||||||
{ "index": 1, "label": "中文(繁体)" },
|
{ "index": 1, "label": "中文(繁体)" },
|
||||||
{ "index": 2, "label": "中文(简体)" },
|
{ "index": 2, "label": "中文(简体)" }
|
||||||
{ "index": 3, "label": "德语" },
|
|
||||||
{ "index": 4, "label": "西班牙语" },
|
|
||||||
{ "index": 5, "label": "法语" }
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"Automatic": {
|
"Automatic": {
|
||||||
|
|||||||
@@ -13,8 +13,12 @@
|
|||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
import { ChevronLeft, Check } from "lucide-react";
|
import { ChevronLeft, Check } from "lucide-react";
|
||||||
import { useDevice } from "@/contexts/DeviceContext";
|
import { useDevice } from "@/contexts/DeviceContext";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
const iisModeImageUrl = (index: number) =>
|
||||||
|
`${import.meta.env.BASE_URL}IIS/iis_app${index + 1}.png`;
|
||||||
|
|
||||||
/* ── Key → API field mapping ── */
|
/* ── Key → API field mapping ── */
|
||||||
const KEY_TO_SETTING: Record<string, string> = {
|
const KEY_TO_SETTING: Record<string, string> = {
|
||||||
effect_value: "effect_value",
|
effect_value: "effect_value",
|
||||||
@@ -118,26 +122,68 @@ export default function SelectPage() {
|
|||||||
<div className="w-8" />
|
<div className="w-8" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Options list ── */}
|
|
||||||
<div className="px-4 pt-4 pb-24">
|
<div className="px-4 pt-4 pb-24">
|
||||||
<div className="ios-list-group">
|
{key === "IISMode" ? (
|
||||||
{options.map((opt, idx) => {
|
<div className="grid w-full grid-cols-2 gap-3">
|
||||||
const isSelected = idx === selectedIdx;
|
{options.map((opt, idx) => {
|
||||||
return (
|
const isSelected = idx === selectedIdx;
|
||||||
<button
|
return (
|
||||||
key={idx}
|
<button
|
||||||
className="ios-list-row w-full text-left active:bg-white/5 transition-colors"
|
key={idx}
|
||||||
onClick={() => void handleSelect(idx)}>
|
type="button"
|
||||||
<span className={`flex-1 text-[16px] ${isSelected ? "text-white font-medium" : "text-white/75"}`}>
|
disabled={saving}
|
||||||
{opt}
|
className={cn(
|
||||||
</span>
|
"flex flex-col items-center gap-2 rounded-xl border-2 p-1.5 transition-all active:scale-[0.98] disabled:opacity-60",
|
||||||
{isSelected && (
|
isSelected
|
||||||
<Check size={18} style={{ color: "#00FFF6" }} />
|
? "border-[#00FFF6] shadow-[0_0_14px_rgba(0,255,246,0.35)]"
|
||||||
)}
|
: "border-white/10",
|
||||||
</button>
|
)}
|
||||||
);
|
onClick={() => void handleSelect(idx)}
|
||||||
})}
|
aria-pressed={isSelected}
|
||||||
</div>
|
aria-label={opt}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={iisModeImageUrl(idx)}
|
||||||
|
alt={opt}
|
||||||
|
className="block w-full h-auto rounded-lg bg-black/40"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"w-6 h-6 rounded-full flex items-center justify-center shrink-0",
|
||||||
|
isSelected
|
||||||
|
? "bg-[#00FFF6] text-black shadow-[0_0_10px_rgba(0,255,246,0.45)]"
|
||||||
|
: "border-2 border-white/20",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isSelected && <Check size={12} strokeWidth={3} />}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="ios-list-group">
|
||||||
|
{options.map((opt, idx) => {
|
||||||
|
const isSelected = idx === selectedIdx;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
className="ios-list-row w-full text-left active:bg-white/5 transition-colors"
|
||||||
|
onClick={() => void handleSelect(idx)}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
<span className={`flex-1 text-[16px] ${isSelected ? "text-white font-medium" : "text-white/75"}`}>
|
||||||
|
{opt}
|
||||||
|
</span>
|
||||||
|
{isSelected && (
|
||||||
|
<Check size={18} style={{ color: "#00FFF6" }} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -107,6 +107,18 @@ 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 } = useDevice();
|
||||||
@@ -124,7 +136,8 @@ 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 langIdx = ds?.language ?? 2;
|
const rawLangIdx = ds?.language ?? 2;
|
||||||
|
const langIdx = SUPPORTED_LANGUAGE_INDICES.has(rawLangIdx) ? rawLangIdx : 2;
|
||||||
const knobBreathLightOn = (ds?.knob_breathlight ?? 1) === 0;
|
const knobBreathLightOn = (ds?.knob_breathlight ?? 1) === 0;
|
||||||
const localeData = LOCALE_BY_LANG[langIdx] ?? localeZh;
|
const localeData = LOCALE_BY_LANG[langIdx] ?? localeZh;
|
||||||
const settingsText = localeData.settings;
|
const settingsText = localeData.settings;
|
||||||
@@ -134,7 +147,11 @@ 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 = optionLabels(settingsText.language?.options, ["English", "繁體中文", "简体中文"]);
|
const LANG_OPTIONS = languageLabels(settingsText.language?.options, [
|
||||||
|
"English",
|
||||||
|
"繁體中文",
|
||||||
|
"简体中文",
|
||||||
|
]);
|
||||||
|
|
||||||
const sleepRowLabel =
|
const sleepRowLabel =
|
||||||
settingsText.sleepTime?.label ??
|
settingsText.sleepTime?.label ??
|
||||||
|
|||||||