Merge branch 'dev' into prod

This commit is contained in:
moux1024 2025-09-25 19:13:38 +08:00
commit 2a11235314
16 changed files with 292 additions and 182 deletions

View File

@ -95,8 +95,10 @@
--accent-foreground: 0 0% 98%;
/* 自定义渐变色变量 */
--custom-blue: 186 100% 70%; /* rgb(106, 244, 249) */
--custom-purple: 280 100% 62%; /* rgb(199, 59, 255) */
--custom-blue: 186 100% 70%;
/* rgb(106, 244, 249) */
--custom-purple: 280 100% 62%;
/* rgb(199, 59, 255) */
--custom-blue-rgb: 106, 244, 249;
--custom-purple-rgb: 199, 59, 255;
--destructive: 0 62.8% 30.6%;
@ -162,6 +164,7 @@ body {
.hide-scrollbar::-webkit-scrollbar {
display: none !important;
}
*::-webkit-scrollbar {
display: none !important;
}
@ -184,6 +187,7 @@ body {
border-radius: 8px;
padding: 8px;
}
.button-NxtqWZ:hover {
background-color: #2f3237 !important;
}
@ -226,6 +230,7 @@ body {
height: 0;
pointer-events: none;
}
.ant-spin-nested-loading .ant-spin {
max-height: none !important;
}
@ -236,6 +241,7 @@ body {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
@ -262,3 +268,41 @@ body {
padding: unset !important;
margin: 0.25rem !important;
}
/* 安全区域变量定义 */
:root {
--sat: env(safe-area-inset-top, 0px);
--sar: env(safe-area-inset-right, 0px);
--sab: env(safe-area-inset-bottom, 0px);
--sal: env(safe-area-inset-left, 0px);
}
/* 移动端适配:使用 dvh 动态视口高度 */
@supports (height: 100dvh) {
body {
height: 100dvh;
}
}
/* 移动端安全区域处理 */
@media (max-width: 768px) {
body {
/* 使用动态视口高度,考虑移动端浏览器地址栏 */
height: 100dvh;
height: calc(var(--vh, 1vh) * 100);
padding-bottom: env(safe-area-inset-bottom);
}
}
/* 针对移动端底部导航栏/状态栏的特殊处理 */
@media (max-width: 768px) and (display-mode: browser) {
.mobile-safe-bottom {
padding-bottom: max(1rem, env(safe-area-inset-bottom));
margin-bottom: max(1rem, env(safe-area-inset-bottom));
}
.mobile-viewport-height {
height: 100dvh;
height: calc(var(--vh, 1vh) * 100);
}
}

View File

@ -43,7 +43,7 @@ export default function RootLayout({
<head>
<title>MovieFlow - AI Movie Studio</title>
<meta name="description" content="Share your story, or just a few words, and our AI turns it into a great film. We remove the barriers to creation. At MovieFlow, everyone is a movie master." />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<link rel="icon" type="image/x-icon" sizes="16x16" href="/favicon.ico?v=1" />
<link rel="icon" type="image/x-icon" sizes="32x32" href="/favicon.ico?v=1" />
<link rel="shortcut icon" type="image/x-icon" href="/favicon.ico?v=1" />

View File

@ -2,38 +2,75 @@
import React from "react";
import { TailwindSpinner } from "@/components/common/GlobalLoad";
import { useSearchParams } from "next/navigation";
import { createCheckoutSession, buyTokens } from "@/lib/stripe";
export default function PayRedirectPage() {
const [status, setStatus] = React.useState<string>("Waiting for secure checkout redirect...");
const [status, setStatus] = React.useState<string>("Preparing checkout...");
const [error, setError] = React.useState<string>("");
const searchParams = useSearchParams();
React.useEffect(() => {
const handleMessage = (event: MessageEvent) => {
try {
if (event.origin !== window.location.origin) return;
const data = event.data || {};
if (data?.type === "redirect-to-payment" && typeof data?.url === "string") {
setStatus("Redirecting to Stripe Checkout...");
window.location.href = data.url as string;
} else if (data?.type === "redirect-error") {
setError(typeof data?.message === "string" ? data.message : "Failed to create payment. Please close this page and try again.");
}
} catch {
setError("An error occurred while processing redirect info. Please close this page and try again.");
}
};
window.addEventListener("message", handleMessage);
const timeoutId = window.setTimeout(() => {
const type = (searchParams.get("type") || "").toLowerCase();
if (!type) {
setStatus("");
setError("No redirect instruction received. It may be a network issue or the page was blocked. Please go back and try again.");
}, 15000);
setError("Missing payment type.");
return;
}
return () => {
window.removeEventListener("message", handleMessage);
window.clearTimeout(timeoutId);
const redirectTo = async () => {
try {
if (type === "token") {
const amountStr = searchParams.get("amount");
const pkg = searchParams.get("pkg") || "basic";
const amount = Number(amountStr);
if (!amount || amount <= 0) {
throw new Error("Invalid token amount");
}
setStatus("Creating token purchase session...");
const resp = await buyTokens({ token_amount: amount, package_type: pkg });
if (resp?.successful && resp?.data?.checkout_url) {
setStatus("Redirecting to Stripe Checkout...");
window.location.href = resp.data.checkout_url as string;
return;
}
throw new Error(resp?.message || "Failed to create token checkout session");
}
if (type === "subscription") {
const plan = searchParams.get("plan");
const billing = (searchParams.get("billing") || "month") as "month" | "year";
if (!plan) {
throw new Error("Missing plan name");
}
const currentUser = JSON.parse(localStorage.getItem("currentUser") || "{}");
if (!currentUser?.id) {
throw new Error("Not logged in. Please sign in and try again.");
}
setStatus("Creating subscription session...");
const result = await createCheckoutSession({
user_id: String(currentUser.id),
plan_name: plan,
billing_cycle: billing,
});
if (result?.successful && result?.data?.checkout_url) {
setStatus("Redirecting to Stripe Checkout...");
window.location.href = result.data.checkout_url as string;
return;
}
throw new Error(result?.message || "Failed to create subscription session");
}
throw new Error("Unsupported payment type");
} catch (e: unknown) {
setStatus("");
setError(e instanceof Error ? e.message : "Failed to prepare checkout.");
}
};
}, []);
redirectTo();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams]);
return (
<div data-alt="pay-redirect-page" className="min-h-screen w-full flex items-center justify-center bg-black text-white">

View File

@ -85,53 +85,17 @@ function HomeModule5() {
const handleSubscribe = async (planName: string) => {
setLoadingPlan(planName);
// 先同步打开同域重定向页,避免拦截
const redirectWindow = window.open('/pay-redirect', '_blank');
if (!redirectWindow) {
setLoadingPlan(null);
throw new Error('Unable to open redirect window, please check popup settings');
}
try {
const { createCheckoutSession } = await import("@/lib/stripe");
const User = JSON.parse(localStorage.getItem("currentUser") || "{}");
if (!User.id) {
throw new Error("Unable to obtain user ID, please log in again");
}
const result = await createCheckoutSession({
user_id: String(User.id),
plan_name: planName,
billing_cycle: billingType,
});
if (!result.successful || !result.data?.checkout_url) {
throw new Error("create checkout session failed");
}
// 改为直接携带参数打开 pay-redirect由其内部完成创建与跳转
const url = `/pay-redirect?type=subscription&plan=${encodeURIComponent(planName)}&billing=${encodeURIComponent(billingType)}`;
const win = window.open(url, '_blank');
// 通知当前窗口等待支付显示loading模态框
window.postMessage({
type: 'waiting-payment',
paymentType: 'subscription',
}, '*');
// 通过 postMessage 通知新页面执行重定向
redirectWindow.postMessage({
type: 'redirect-to-payment',
url: result.data.checkout_url,
}, window.location.origin);
} catch (error) {
// 通知新页错误信息
try {
redirectWindow.postMessage({
type: 'redirect-error',
message: 'Failed to create checkout session',
}, window.location.origin);
} catch {}
if (!win) {
setLoadingPlan(null);
throw new Error("create checkout session failed, please try again later");
throw new Error('Unable to open redirect window, please check popup settings');
}
};
return (

View File

@ -169,7 +169,7 @@ export default function SmartChatBox({
}, [messages]);
return (
<div className={`${isMobile ? 'z-[49]' : 'h-full'} w-full text-gray-100 flex flex-col`} data-alt="smart-chat-box">
<div className={`${isMobile ? 'z-[49] relative' : 'h-full'} w-full text-gray-100 flex flex-col`} data-alt="smart-chat-box">
{/* Header */}
<div className={`px-4 py-3 border-b border-white/10 flex items-center justify-between ${isMobile ? 'sticky top-0 bg-[#141414] z-[1]' : ''}`} data-alt="chat-header">
<div className="font-semibold flex items-center gap-2">

View File

@ -1,6 +1,6 @@
"use client";
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { Sidebar } from './sidebar';
import { TopBar } from './top-bar';
import { useDeviceType } from '@/hooks/useDeviceType';
@ -10,9 +10,28 @@ interface DashboardLayoutProps {
}
export function DashboardLayout({ children }: DashboardLayoutProps) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(true); // 默认收起状态
const [sidebarCollapsed, setSidebarCollapsed] = useState(true);
const { deviceType, isMobile, isTablet, isDesktop } = useDeviceType();
// 处理移动端视口高度动态计算
useEffect(() => {
if (isMobile || isTablet) {
const setVH = () => {
const vh = window.innerHeight * 0.01;
document.documentElement.style.setProperty('--vh', `${vh}px`);
};
setVH();
window.addEventListener('resize', setVH);
window.addEventListener('orientationchange', setVH);
return () => {
window.removeEventListener('resize', setVH);
window.removeEventListener('orientationchange', setVH);
};
}
}, [isMobile, isTablet]);
// 根据设备类型设置布局样式
const getLayoutStyles = () => {
if (isMobile || isTablet) {
@ -29,13 +48,28 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
};
};
// 获取移动端容器类名
const getMobileContainerClasses = () => {
if (isMobile || isTablet) {
return "mobile-viewport-height mobile-safe-bottom";
}
return "";
};
return (
<div className="min-h-screen bg-background">
<TopBar collapsed={sidebarCollapsed} isDesktop={isDesktop} />
{isDesktop && <Sidebar collapsed={sidebarCollapsed} onToggle={setSidebarCollapsed} />}
<div
className="h-[calc(100vh-4rem)] top-[4rem] fixed right-0 bottom-0 px-4"
style={getLayoutStyles()}>
className={`top-[4rem] fixed right-0 bottom-0 px-4 ${getMobileContainerClasses()}`}
style={{
...getLayoutStyles(),
// 移动端使用动态高度计算
height: (isMobile || isTablet)
? 'calc(100dvh - 4rem)'
: 'calc(100vh - 4rem)'
}}
>
{children}
</div>
</div>

View File

@ -88,7 +88,7 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe
}
};
// 处理Token购买同域新页 + postMessage 方式重定向
// 处理Token购买改为携带参数打开 pay-redirect
const handleBuyTokens = async (tokenAmount: number) => {
if (!currentUser?.id) {
console.error("用户未登录");
@ -99,52 +99,14 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe
console.error("Token数量必须大于0");
return;
}
// 先同步打开同域新页面,避免被拦截
const redirectWindow = window.open("/pay-redirect", "_blank");
if (!redirectWindow) {
console.error("无法打开支付重定向页面,可能被浏览器拦截");
return;
}
setIsBuyingTokens(true);
try {
const response = await buyTokens({
token_amount: tokenAmount,
package_type: "basic"
});
if (response.successful && response.data?.checkout_url) {
// 通知当前窗口等待支付标识为Token购买
// 直接打开带参数的 pay-redirect新窗口内自行创建会话并跳转
const url = `/pay-redirect?type=token&amount=${encodeURIComponent(tokenAmount)}&pkg=basic`;
window.open(url, "_blank");
// 通知当前窗口等待支付显示loading模态框
window.postMessage({
type: "waiting-payment",
paymentType: "token"
}, "*");
sessionStorage.setItem('session_id', response.data.session_id);
// 通过 postMessage 通知新页面进行重定向
redirectWindow.postMessage({
type: "redirect-to-payment",
url: response.data.checkout_url
}, window.location.origin);
} else {
console.error("创建Token购买失败:", response.message);
// 通知新页显示错误
redirectWindow.postMessage({
type: "redirect-error",
message: response.message || "创建支付失败"
}, window.location.origin);
}
} catch (error: unknown) {
console.error("Token购买失败:", error);
try {
redirectWindow.postMessage({
type: "redirect-error",
message: "网络或服务异常,请关闭此页重试"
}, window.location.origin);
} catch {}
} finally {
setIsBuyingTokens(false);
}
type: 'waiting-payment',
paymentType: 'subscription',
}, '*');
};
// 处理自定义金额购买
@ -255,7 +217,7 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe
return (
<div
className="fixed right-0 top-0 h-16 header z-[999]"
className="fixed right-0 top-0 h-16 header z-[20]"
style={{
isolation: "isolate",
left: (pathname === "/" || !isDesktop) ? "0" : (collapsed ? "2.5rem" : "16rem")

View File

@ -223,7 +223,7 @@ export function HomePage2() {
return () => window.removeEventListener('home-menu-toggle' as any, handler as any);
}, []);
return (
<div data-alt="home-navbar" className="fixed h-16 top-0 left-0 right-0 z-50">
<div data-alt="home-navbar" className="fixed h-16 top-0 left-0 right-0 z-[19]">
<div className="mx-auto h-full">
<div className="flex h-full items-center justify-center px-4 sm:px-6 py-3 bg-black/60 backdrop-blur-md border-b border-white/10">
{/* 桌面端菜单(居中,仅三个项) */}
@ -1249,34 +1249,16 @@ function HomeModule5() {
const handleSubscribe = async (planName: string) => {
localStorage.setItem("callBackUrl", pathname);
try {
// 使用新的Checkout Session方案更简单
const { createCheckoutSession, redirectToCheckout } = await import(
"@/lib/stripe"
);
// 从localStorage获取当前用户信息
const User = JSON.parse(localStorage.getItem("currentUser") || "{}");
if (!User.id) {
throw new Error("无法获取用户ID请重新登录");
}
// 1. 创建Checkout Session
const result = await createCheckoutSession({
user_id: String(User.id),
plan_name: planName,
billing_cycle: billingType,
});
if (!result.successful || !result.data) {
throw new Error("create checkout session failed");
}
setShowCallbackModal(true);
window.open(result.data.checkout_url, "_blank");
} catch (error) {
throw new Error("create checkout session failed, please try again later");
// 改为直接携带参数打开 pay-redirect由其内部完成创建与跳转
const url = `/pay-redirect?type=subscription&plan=${encodeURIComponent(planName)}&billing=${encodeURIComponent(billingType)}`;
const win = window.open(url, "_blank");
// 通知当前窗口等待支付显示loading模态框
window.postMessage({
type: 'waiting-payment',
paymentType: 'subscription',
}, '*');
if (!win) {
throw new Error("Unable to open redirect window, please check popup settings");
}
};
return (

View File

@ -301,7 +301,7 @@ export function H5MediaViewer({
<button
type="button"
data-alt="open-catalog-button"
className="fixed bottom-4 right-4 z-[60] w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-blue-600 text-white shadow-lg flex items-center justify-center active:scale-95"
className="fixed bottom-[6rem] right-4 z-[60] w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-blue-600 text-white shadow-lg flex items-center justify-center active:scale-95"
aria-label="open-catalog"
onClick={() => setIsCatalogOpen(true)}
>
@ -371,13 +371,13 @@ export function H5MediaViewer({
// 其他阶段:使用 Carousel
return (
<div ref={rootRef} data-alt="h5-media-viewer" className="w-[100vw] relative px-4">
<div ref={rootRef} data-alt="h5-media-viewer" className={`w-[100vw] relative ${stage === 'final_video' ? '' : 'px-4'}`}>
{/* 左侧最终视频缩略图栏H5 视频暂停时展示 */}
{taskObject?.final?.url && !isPlaying && (
<div className="absolute left-4 top-0 z-[60]" data-alt="final-sidebar-h5">
<div className={`absolute left-0 top-4 z-[10] ${stage === 'final_video' ? 'left-0' : 'left-4'}`} data-alt="final-sidebar-h5">
<div className="flex items-start">
{isFinalBarOpen && (
<div className="w-20 max-h-[50vh] overflow-y-auto rounded-md backdrop-blur-lg bg-black/30 border border-white/20 shadow-xl p-1 mr-2" data-alt="final-thumbnails">
<div className="w-[3rem] max-h-[50vh] overflow-y-auto rounded-md backdrop-blur-lg bg-black/30 border border-white/20 shadow-xl p-1" data-alt="final-thumbnails">
<button
type="button"
onClick={() => onSelectView && onSelectView('final')}
@ -491,8 +491,11 @@ export function H5MediaViewer({
</div>
)}
<style jsx global>{`
[data-alt='carousel-wrapper'] .slick-slide { display: block; }
[data-alt='carousel-wrapper'] .slick-slide { display: flex !important;justify-content: center; }
.slick-slider { height: 100% !important;display: flex !important; }
.ant-carousel { height: 100% !important; }
.slick-list { width: 100%;height: 100% !important;max-height: calc(100vh - 20rem); }
.slick-track { display: flex !important; align-items: center;height: 100% !important; }
`}</style>
</div>
);

View File

@ -125,14 +125,14 @@ const H5TaskInfo: React.FC<H5TaskInfoProps> = ({
return (
<div
data-alt="h5-header"
className={`absolute top-0 left-0 right-0 z-[50] pr-1 ${className || ''}`}
className={`${className || ''}`}
>
<div
data-alt="h5-header-bar"
className="flex items-start gap-3"
>
{/* 左侧标题区域 */}
<div data-alt="title-area" className="flex-1 min-w-0 bg-gradient-to-b from-slate-900/80 via-slate-900/40 to-transparent backdrop-blur-sm rounded-lg py-4 px-4">
<div data-alt="title-area" className="flex-1 min-w-0">
<h1
data-alt="title"
className="text-white text-lg font-bold"

View File

@ -356,7 +356,7 @@ export function ThumbnailGrid({
<div
ref={thumbnailsRef}
tabIndex={0}
className={`w-full h-full grid grid-flow-col gap-2 overflow-x-auto hide-scrollbar px-1 py-1 cursor-grab active:cursor-grabbing focus:outline-none select-none auto-cols-[${cols}] ${aspectRatio === 'VIDEO_ASPECT_RATIO_LANDSCAPE' ? '' : '!auto-cols-max'}`}
className={`w-full h-full grid grid-flow-col gap-2 overflow-x-auto hide-scrollbar px-1 py-1 cursor-grab active:cursor-grabbing focus:outline-none select-none auto-cols-[${cols}] ${aspectRatio === 'VIDEO_ASPECT_RATIO_LANDSCAPE' ? '' : '!auto-cols-min'}`}
autoFocus
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}

View File

@ -102,7 +102,7 @@ const H5ProgressToastUI: React.FC<UIProps> = ({ open, title, progress }) => {
</div>
<div className="mt-1 flex items-center justify-between text-xs text-white/70">
<span data-alt="percent">{pct}%</span>
<span data-alt="hint">{pct >= 100 ? '即将完成' : ''}</span>
<span data-alt="hint">{pct >= 100 ? 'completed' : ''}</span>
</div>
</div>
</div>

View File

@ -140,7 +140,7 @@ export function ScriptModal({ isOpen, onClose, currentStage = 0, roles, currentL
{/* 弹窗内容 */}
<motion.div
className="fixed inset-0 z-50 flex items-center justify-center"
className="fixed inset-0 z-[61] flex items-center justify-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}

View File

@ -2,16 +2,16 @@ import { useState, useEffect } from 'react';
// 定义设备类型枚举
export enum DeviceType {
MOBILE = 'mobile', // 手机
TABLET = 'tablet', // 平板
DESKTOP = 'desktop' // 桌面端
MOBILE = 'mobile',
TABLET = 'tablet',
DESKTOP = 'desktop'
}
// 定义屏幕断点
const BREAKPOINTS = {
MOBILE: 480, // 0-480px 为手机
TABLET: 1024, // 481-1024px 为平板
DESKTOP: 1025 // 1025px 及以上为桌面端
MOBILE: 480,
TABLET: 1024,
DESKTOP: 1025
};
export function useDeviceType() {
@ -22,35 +22,39 @@ export function useDeviceType() {
});
useEffect(() => {
/**
*
*/
const getDeviceType = (width: number): DeviceType => {
if (width <= BREAKPOINTS.MOBILE) return DeviceType.MOBILE;
if (width <= BREAKPOINTS.TABLET) return DeviceType.TABLET;
return DeviceType.DESKTOP;
};
/**
*
*/
const handleResize = () => {
const width = window.innerWidth;
const height = window.innerHeight;
setWindowSize({ width, height });
setDeviceType(getDeviceType(width));
// 移动端动态视口高度处理
if (width <= BREAKPOINTS.TABLET) {
const vh = height * 0.01;
document.documentElement.style.setProperty('--vh', `${vh}px`);
}
};
// 初始化设备类型
// 初始化
handleResize();
// 添加窗口大小变化监听
// 添加事件监听
window.addEventListener('resize', handleResize);
window.addEventListener('orientationchange', () => {
// 延迟处理以确保获取正确的视口尺寸
setTimeout(handleResize, 100);
});
// 清理监听器
return () => {
window.removeEventListener('resize', handleResize);
window.removeEventListener('orientationchange', handleResize);
};
}, []);
@ -59,6 +63,8 @@ export function useDeviceType() {
windowSize,
isMobile: deviceType === DeviceType.MOBILE,
isTablet: deviceType === DeviceType.TABLET,
isDesktop: deviceType === DeviceType.DESKTOP
isDesktop: deviceType === DeviceType.DESKTOP,
/** 是否为移动端设备(包括平板) */
isMobileDevice: deviceType === DeviceType.MOBILE || deviceType === DeviceType.TABLET
};
}

64
hooks/useSafeArea.ts Normal file
View File

@ -0,0 +1,64 @@
import { useState, useEffect } from 'react';
interface SafeAreaInsets {
top: number;
right: number;
bottom: number;
left: number;
}
export function useSafeArea() {
const [safeAreaInsets, setSafeAreaInsets] = useState<SafeAreaInsets>({
top: 0,
right: 0,
bottom: 0,
left: 0
});
const [viewportHeight, setViewportHeight] = useState(0);
useEffect(() => {
const updateSafeArea = () => {
// 获取 CSS 环境变量
const computedStyle = getComputedStyle(document.documentElement);
const top = parseInt(computedStyle.getPropertyValue('--sat').replace('px', '')) || 0;
const right = parseInt(computedStyle.getPropertyValue('--sar').replace('px', '')) || 0;
const bottom = parseInt(computedStyle.getPropertyValue('--sab').replace('px', '')) || 0;
const left = parseInt(computedStyle.getPropertyValue('--sal').replace('px', '')) || 0;
setSafeAreaInsets({ top, right, bottom, left });
// 设置动态视口高度
const vh = window.innerHeight;
setViewportHeight(vh);
document.documentElement.style.setProperty('--vh', `${vh * 0.01}px`);
};
updateSafeArea();
window.addEventListener('resize', updateSafeArea);
window.addEventListener('orientationchange', updateSafeArea);
// 延迟更新以处理移动端浏览器地址栏变化
const timeoutId = setTimeout(updateSafeArea, 500);
return () => {
window.removeEventListener('resize', updateSafeArea);
window.removeEventListener('orientationchange', updateSafeArea);
clearTimeout(timeoutId);
};
}, []);
return {
safeAreaInsets,
viewportHeight,
/** 获取考虑安全区域的样式 */
getSafeAreaStyle: (includeBottom = true) => ({
paddingTop: `max(1rem, ${safeAreaInsets.top}px)`,
paddingRight: `max(1rem, ${safeAreaInsets.right}px)`,
paddingBottom: includeBottom ? `max(1rem, ${safeAreaInsets.bottom}px)` : undefined,
paddingLeft: `max(1rem, ${safeAreaInsets.left}px)`,
})
};
}

View File

@ -90,6 +90,20 @@ module.exports = {
'100': '100ms',
'200': '200ms',
'300': '300ms',
},
spacing: {
'safe-top': 'env(safe-area-inset-top)',
'safe-right': 'env(safe-area-inset-right)',
'safe-bottom': 'env(safe-area-inset-bottom)',
'safe-left': 'env(safe-area-inset-left)',
},
height: {
'dvh': '100dvh',
'safe-screen': 'calc(100dvh - env(safe-area-inset-top) - env(safe-area-inset-bottom))',
},
minHeight: {
'dvh': '100dvh',
'safe-screen': 'calc(100dvh - env(safe-area-inset-top) - env(safe-area-inset-bottom))',
}
},
},