feat: Implement responsive page layout components and CSS variables

- Added PageScaffold component for shared page layout with safe-area padding and scroll management.
- Introduced ResponsiveMobileShell for mobile canvas layout with customizable backgrounds.
- Created ScrollablePage component for scrollable content areas.
- Updated CSS styles for coins rules screen, sidebar components, and subscription sections to use responsive design tokens.
- Added viewport CSS variables provider to synchronize CSS variables with the visual viewport.
- Refactored breakpoints and dimensions for better responsiveness across devices.
This commit is contained in:
2026-07-02 18:05:17 +08:00
parent 3592c6bb56
commit 34c8e2db4e
23 changed files with 497 additions and 141 deletions
+21 -18
View File
@@ -16,6 +16,7 @@
import type { ReactNode } from "react";
import { ViewportCssVarsProvider } from "@/providers/viewport-css-vars-provider";
import { AuthProvider } from "@/stores/auth/auth-context";
import { AuthStatusChecker } from "@/stores/auth/auth-status-checker";
import { OAuthSessionSync } from "@/stores/auth/oauth-session-sync";
@@ -34,23 +35,25 @@ export interface RootProvidersProps {
export function RootProviders({ children }: RootProvidersProps) {
return (
<AuthProvider>
{/* AuthStatusChecker 必须在 AuthProvider 内部(依赖 useAuthDispatch),
* 必须在 UserProvider/ChatProvider 部(它们可能在 init 完成前就 mount */}
<AuthStatusChecker />
{/* OAuthSessionSync 同样依赖 AuthProvider 上下文 */}
<OAuthSessionSync />
<UserProvider>
<UserAuthSync />
<PaymentProvider>
<ChatProvider>
<ChatAuthSync />
<PaymentSuccessSync />
{children}
<div id="toast-portal" />
</ChatProvider>
</PaymentProvider>
</UserProvider>
</AuthProvider>
<ViewportCssVarsProvider>
<AuthProvider>
{/* AuthStatusChecker 必须在 AuthProvider 部(依赖 useAuthDispatch),
* 必须在 UserProvider/ChatProvider 外部(它们可能在 init 完成前就 mount */}
<AuthStatusChecker />
{/* OAuthSessionSync 同样依赖 AuthProvider 上下文 */}
<OAuthSessionSync />
<UserProvider>
<UserAuthSync />
<PaymentProvider>
<ChatProvider>
<ChatAuthSync />
<PaymentSuccessSync />
{children}
<div id="toast-portal" />
</ChatProvider>
</PaymentProvider>
</UserProvider>
</AuthProvider>
</ViewportCssVarsProvider>
);
}
@@ -0,0 +1,87 @@
"use client";
import { useEffect, type ReactNode } from "react";
const CSS_VAR_VIEWPORT_HEIGHT = "--app-viewport-height";
const CSS_VAR_VISIBLE_HEIGHT = "--app-visible-height";
const CSS_VAR_VISIBLE_OFFSET_TOP = "--app-visible-offset-top";
const CSS_VAR_SAFE_TOP = "--app-safe-top";
const CSS_VAR_SAFE_RIGHT = "--app-safe-right";
const CSS_VAR_SAFE_BOTTOM = "--app-safe-bottom";
const CSS_VAR_SAFE_LEFT = "--app-safe-left";
export interface ViewportCssVarsProviderProps {
children: ReactNode;
}
/**
* Keeps viewport-related CSS variables in sync with the real visual viewport.
*
* CSS already has safe defaults (`100dvh` and `env(safe-area-inset-*)`). This
* client provider improves Safari/PWA/WebView behavior after hydration without
* pushing viewport measurements into React state.
*/
export function ViewportCssVarsProvider({
children,
}: ViewportCssVarsProviderProps) {
useEffect(() => {
if (typeof window === "undefined") return;
let frameId: number | null = null;
const applyVars = () => {
frameId = null;
const root = document.documentElement;
const viewport = window.visualViewport;
const layoutHeight = window.innerHeight;
const layoutWidth = window.innerWidth;
const visibleHeight = viewport?.height ?? layoutHeight;
const visibleWidth = viewport?.width ?? layoutWidth;
const visibleOffsetTop = viewport?.offsetTop ?? 0;
root.style.setProperty(CSS_VAR_VIEWPORT_HEIGHT, `${layoutHeight}px`);
root.style.setProperty(CSS_VAR_VISIBLE_HEIGHT, `${visibleHeight}px`);
root.style.setProperty(
CSS_VAR_VISIBLE_OFFSET_TOP,
`${visibleOffsetTop}px`,
);
root.style.setProperty(CSS_VAR_SAFE_TOP, "env(safe-area-inset-top, 0px)");
root.style.setProperty(
CSS_VAR_SAFE_RIGHT,
"env(safe-area-inset-right, 0px)",
);
root.style.setProperty(
CSS_VAR_SAFE_BOTTOM,
"env(safe-area-inset-bottom, 0px)",
);
root.style.setProperty(
CSS_VAR_SAFE_LEFT,
"env(safe-area-inset-left, 0px)",
);
root.dataset.viewportWidth = String(Math.round(visibleWidth));
root.dataset.viewportHeight = String(Math.round(visibleHeight));
};
const scheduleApply = () => {
if (frameId != null) return;
frameId = window.requestAnimationFrame(applyVars);
};
applyVars();
window.addEventListener("resize", scheduleApply);
window.addEventListener("orientationchange", scheduleApply);
window.visualViewport?.addEventListener("resize", scheduleApply);
window.visualViewport?.addEventListener("scroll", scheduleApply);
return () => {
if (frameId != null) window.cancelAnimationFrame(frameId);
window.removeEventListener("resize", scheduleApply);
window.removeEventListener("orientationchange", scheduleApply);
window.visualViewport?.removeEventListener("resize", scheduleApply);
window.visualViewport?.removeEventListener("scroll", scheduleApply);
};
}, []);
return children;
}