forked from 77media/video-flow
H5顶部导航栏设计与实现
This commit is contained in:
parent
967fe95ccc
commit
bc0269983d
21
app/page.tsx
21
app/page.tsx
@ -1,11 +1,30 @@
|
||||
|
||||
"use client";
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { TopBar } from "@/components/layout/top-bar";
|
||||
import { HomePage2 } from "@/components/pages/home-page2";
|
||||
import { isAuthenticated } from '@/lib/auth';
|
||||
import { useDeviceType } from '@/hooks/useDeviceType';
|
||||
import H5TopBar from '@/components/layout/H5TopBar';
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
const { deviceType, isMobile, isTablet, isDesktop } = useDeviceType();
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated()) {
|
||||
router.replace('/movies');
|
||||
}
|
||||
}, [router]);
|
||||
return (
|
||||
<>
|
||||
<TopBar collapsed={true} />
|
||||
{isMobile || isTablet ? (
|
||||
<H5TopBar />
|
||||
) : (
|
||||
<TopBar collapsed={true} />
|
||||
)}
|
||||
<HomePage2 />
|
||||
</>
|
||||
);
|
||||
|
||||
@ -186,10 +186,10 @@ export default function SharePage(): JSX.Element {
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div data-alt="share-page" className="w-full h-full overflow-y-auto bg-black text-white">
|
||||
<div data-alt="share-page" className="w-full h-full overflow-y-auto overflow-x-hidden bg-black text-white">
|
||||
<div
|
||||
data-alt="container"
|
||||
className="w-full max-w-[95%] mx-auto px-4 py-10 sm:px-6 md:px-8 lg:px-12 xl:px-16 2xl:px-20"
|
||||
className="w-full max-w-[100%] mx-auto px-4 py-2 sm:px-6 md:px-8 lg:px-12 xl:px-16 2xl:px-20"
|
||||
>
|
||||
<header data-alt="page-header" className="mb-8 flex items-end justify-between">
|
||||
<div data-alt="title-box">
|
||||
@ -206,14 +206,7 @@ export default function SharePage(): JSX.Element {
|
||||
<div data-alt="link-container" className="relative w-full max-w-2xl overflow-hidden rounded-md border border-white/20 bg-white/10">
|
||||
<div
|
||||
data-alt="link-content"
|
||||
className="relative px-4 py-2 text-sm font-mono text-white/90 whitespace-nowrap overflow-hidden"
|
||||
style={{
|
||||
background: 'linear-gradient(90deg, rgba(255,255,255,0.9) 0%, rgba(255,255,255,0.7) 50%, rgba(255,255,255,0.3) 70%, rgba(255,255,255,0.1) 90%, rgba(255,255,255,0) 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
backgroundClip: 'text',
|
||||
maskImage: 'linear-gradient(90deg, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 70%, rgba(0,0,0,0.5) 85%, rgba(0,0,0,0) 100%)',
|
||||
WebkitMaskImage: 'linear-gradient(90deg, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 70%, rgba(0,0,0,0.5) 85%, rgba(0,0,0,0) 100%)'
|
||||
}}
|
||||
className="relative px-4 py-2 text-xs sm:text-sm font-mono text-white/90 break-all sm:truncate"
|
||||
>
|
||||
{inviteCode ? `${'https://www.movieflow.ai'}/signup?inviteCode=${inviteCode}` : '-'}
|
||||
</div>
|
||||
@ -258,21 +251,21 @@ export default function SharePage(): JSX.Element {
|
||||
<li data-alt="step" className="rounded-md p-4">
|
||||
<div data-alt="step-header" className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-custom-blue/50">Step 1</span>
|
||||
<span className="rounded bg-white/10 px-2 py-0.5 text-xs text-white bg-custom-purple/50">Share</span>
|
||||
<span className="rounded px-2 py-0.5 text-xs text-white bg-custom-purple/50">Share</span>
|
||||
</div>
|
||||
<p data-alt="step-desc" className="mt-2 text-sm text-white/70">Copy your invitation link and share it with friends.</p>
|
||||
</li>
|
||||
<li data-alt="step" className="rounded-md p-4">
|
||||
<div data-alt="step-header" className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-custom-blue/50">Step 2</span>
|
||||
<span className="rounded bg-white/10 px-2 py-0.5 text-xs text-white bg-custom-purple/50">Register</span>
|
||||
<span className="rounded px-2 py-0.5 text-xs text-white bg-custom-purple/50">Register</span>
|
||||
</div>
|
||||
<p data-alt="step-desc" className="mt-2 text-sm text-white/70">Friends click the link and register directly.</p>
|
||||
</li>
|
||||
<li data-alt="step" className="rounded-md p-4">
|
||||
<div data-alt="step-header" className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-custom-blue/50">Step 3</span>
|
||||
<span className="rounded bg-white/10 px-2 py-0.5 text-xs text-white bg-custom-purple/50">Reward</span>
|
||||
<span className="rounded px-2 py-0.5 text-xs text-white bg-custom-purple/50">Reward</span>
|
||||
</div>
|
||||
<p data-alt="step-desc" className="mt-2 text-sm text-white/70">You both receive rewards after your friend activates their account.</p>
|
||||
</li>
|
||||
@ -351,8 +344,8 @@ export default function SharePage(): JSX.Element {
|
||||
<table data-alt="records-table" className="min-w-full divide-y divide-white/10 table-fixed">
|
||||
<thead data-alt="table-head" className="bg-black">
|
||||
<tr data-alt="table-head-row">
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-white/60 w-48">Invited Username</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-white/60 w-56">Registered At</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-white/60 sm:w-48">Invited Username</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-white/60 sm:w-56">Registered At</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-white/60">Reward</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -365,8 +358,8 @@ export default function SharePage(): JSX.Element {
|
||||
return (
|
||||
<React.Fragment key={r.user_id}>
|
||||
<tr data-alt="table-row" className="hover:bg-white/5">
|
||||
<td className="px-4 py-3 text-sm text-white w-48">{r.user_name}</td>
|
||||
<td className="px-4 py-3 text-sm text-white/80 w-56 whitespace-nowrap">{formatLocalTime(r.created_at * 1000)}</td>
|
||||
<td className="px-4 py-3 text-sm text-white sm:w-48">{r.user_name}</td>
|
||||
<td className="px-4 py-3 text-sm text-white/80 whitespace-nowrap sm:w-56">{formatLocalTime(r.created_at * 1000)}</td>
|
||||
<td className="px-4 py-3 text-sm text-white/90">
|
||||
<div data-alt="reward-cell" className="flex items-center justify-between gap-3">
|
||||
<div data-alt="reward-summary" className="flex-1 truncate text-[#FFCC6D]">
|
||||
@ -387,8 +380,8 @@ export default function SharePage(): JSX.Element {
|
||||
</tr>
|
||||
{isExpanded && (
|
||||
<tr data-alt="row-details">
|
||||
<td className="px-4 py-0 w-48" />
|
||||
<td className="px-4 py-0 w-56" />
|
||||
<td className="px-4 py-0 sm:w-48" />
|
||||
<td className="px-4 py-0 sm:w-56" />
|
||||
<td className="px-4 py-3 bg-white/5">
|
||||
<div data-alt="details-wrapper" className="overflow-x-auto">
|
||||
<table data-alt="reward-subtable" className="min-w-[320px] text-sm">
|
||||
|
||||
42
components/layout/H5TopBar.css
Normal file
42
components/layout/H5TopBar.css
Normal file
@ -0,0 +1,42 @@
|
||||
.backdrop_backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
z-index: 58;
|
||||
display: grid;
|
||||
height: 4rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
.backdrop_backdrop:after {
|
||||
content: "";
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, .2) 60%, transparent);
|
||||
}
|
||||
.backdrop_backdrop:after, .backdrop_backdrop .backdrop_blur {
|
||||
grid-area: 1 / 1;
|
||||
transition: opacity .2s linear;
|
||||
}
|
||||
.backdrop_backdrop>.backdrop_blur:first-child {
|
||||
backdrop-filter: blur(1px);
|
||||
mask: linear-gradient(0deg, transparent, #000 8%);
|
||||
}
|
||||
.backdrop_backdrop>.backdrop_blur:nth-child(2) {
|
||||
backdrop-filter: blur(4px);
|
||||
mask: linear-gradient(0deg, transparent 8%, #000 16%);
|
||||
}
|
||||
.backdrop_backdrop>.backdrop_blur:nth-child(3) {
|
||||
backdrop-filter: blur(8px);
|
||||
mask: linear-gradient(0deg, transparent 16%, #000 24%);
|
||||
}
|
||||
.backdrop_backdrop>.backdrop_blur:nth-child(4) {
|
||||
backdrop-filter: blur(16px);
|
||||
mask: linear-gradient(0deg, transparent 24%, #000 36%);
|
||||
}
|
||||
.backdrop_backdrop>.backdrop_blur:nth-child(5) {
|
||||
backdrop-filter: blur(24px);
|
||||
mask: linear-gradient(0deg, transparent 36%, #000 48%);
|
||||
}
|
||||
.backdrop_backdrop>.backdrop_blur:nth-child(6) {
|
||||
backdrop-filter: blur(32px);
|
||||
mask: linear-gradient(0deg, transparent 48%, #000 60%);
|
||||
}
|
||||
495
components/layout/H5TopBar.tsx
Normal file
495
components/layout/H5TopBar.tsx
Normal file
@ -0,0 +1,495 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { Menu, Rocket, LogOut, User as UserIcon, X, Info, CalendarDays } from 'lucide-react';
|
||||
import { Drawer } from 'antd';
|
||||
import { fetchTabsByCode, HomeTabItem } from '@/api/serversetting';
|
||||
import { getSigninStatus } from '@/api/signin';
|
||||
import { getCurrentUser, isAuthenticated, logoutUser } from '@/lib/auth';
|
||||
import { getUserSubscriptionInfo, createPortalSession, redirectToPortal } from '@/lib/stripe';
|
||||
import { GradientText } from '@/components/ui/gradient-text';
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||
import SigninBox from './signin-box';
|
||||
import { navigationItems } from './type';
|
||||
import './H5TopBar.css';
|
||||
|
||||
interface H5TopBarProps {
|
||||
/** 点击首页 tab 时的回调,用于页面内滚动 */
|
||||
onSelectHomeTab?: (key: string) => void;
|
||||
}
|
||||
|
||||
interface CurrentUserMinimal {
|
||||
id?: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动端顶栏(抽屉式菜单)
|
||||
* - 未登录:左 LOGO → '/',右侧 Signup + 菜单;抽屉显示 homeTabs 与 登录/注册
|
||||
* - 已登录:左 LOGO → '/movies',右侧 升级图标 + 菜单;抽屉显示 用户卡片、快捷充值、入口与登出
|
||||
*/
|
||||
export default function H5TopBar({ onSelectHomeTab }: H5TopBarProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const [isLogin, setIsLogin] = useState<boolean>(false);
|
||||
const [user, setUser] = useState<CurrentUserMinimal | null>(null);
|
||||
const [credits, setCredits] = useState<number>(0);
|
||||
const [isBuyingTokens, setIsBuyingTokens] = useState<boolean>(false);
|
||||
const [homeTabs, setHomeTabs] = useState<HomeTabItem[]>([]);
|
||||
const [drawerOpen, setDrawerOpen] = useState<boolean>(false);
|
||||
const [customAmount, setCustomAmount] = useState<string>("");
|
||||
const [isSigninModalOpen, setIsSigninModalOpen] = useState(false);
|
||||
const [isManagingSubscription, setIsManagingSubscription] = useState(false);
|
||||
const [subscriptionStatus, setSubscriptionStatus] = useState<string>("");
|
||||
const [planName, setPlanName] = useState<string>("");
|
||||
const [needsSigninBadge, setNeedsSigninBadge] = useState<boolean>(false);
|
||||
const [isGlassActive, setIsGlassActive] = useState<boolean>(false);
|
||||
|
||||
const isHome = useMemo(() => pathname === '/', [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLogin(isAuthenticated());
|
||||
const u = getCurrentUser();
|
||||
setUser(u || null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.id) return;
|
||||
let ignore = false;
|
||||
const load = async () => {
|
||||
try {
|
||||
const res = await getUserSubscriptionInfo(String(user.id));
|
||||
if (!ignore && res?.data?.credits !== undefined) {
|
||||
setCredits(res.data.credits);
|
||||
if (typeof res.data.subscription_status === 'string') {
|
||||
setSubscriptionStatus(res.data.subscription_status);
|
||||
}
|
||||
if (typeof res.data.plan_name === 'string') {
|
||||
setPlanName(res.data.plan_name);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
load();
|
||||
return () => {
|
||||
ignore = true;
|
||||
};
|
||||
}, [user?.id]);
|
||||
|
||||
// 仅首页时加载 homeTabs,用于未登录抽屉导航
|
||||
useEffect(() => {
|
||||
if (!isHome) return;
|
||||
let mounted = true;
|
||||
const loadTabs = async () => {
|
||||
try {
|
||||
const tabs = await fetchTabsByCode('homeTab');
|
||||
if (mounted && Array.isArray(tabs)) setHomeTabs(tabs);
|
||||
} catch {}
|
||||
};
|
||||
loadTabs();
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [isHome]);
|
||||
|
||||
// 获取今日签到状态,未签到则显示红点
|
||||
useEffect(() => {
|
||||
const loadSignin = async () => {
|
||||
if (!isLogin) return;
|
||||
try {
|
||||
const data: any = await getSigninStatus();
|
||||
const hasSignin = !!data?.data?.has_signin;
|
||||
setNeedsSigninBadge(!hasSignin);
|
||||
} catch {}
|
||||
};
|
||||
loadSignin();
|
||||
}, [isLogin]);
|
||||
|
||||
// 首页滚动 30vh 后开启玻璃质感背景
|
||||
useEffect(() => {
|
||||
if (!isHome) return;
|
||||
const computeAndSet = (evt?: Event) => {
|
||||
const threshold = Math.max(0, window.innerHeight * 0.3);
|
||||
const winY = window.scrollY || window.pageYOffset || 0;
|
||||
const docElY = (document.documentElement && document.documentElement.scrollTop) || 0;
|
||||
const scrollingElY = (document.scrollingElement as any)?.scrollTop || 0;
|
||||
const bodyY = (document.body && (document.body as any).scrollTop) || 0;
|
||||
let currentY = Math.max(winY, docElY, scrollingElY, bodyY);
|
||||
const target = evt?.target as any;
|
||||
if (target && typeof target.scrollTop === 'number') {
|
||||
currentY = Math.max(currentY, target.scrollTop);
|
||||
}
|
||||
const nextActive = currentY >= threshold;
|
||||
setIsGlassActive(nextActive);
|
||||
};
|
||||
// 初始计算一次
|
||||
computeAndSet();
|
||||
// 监听 window 与 document(捕获阶段,捕获内部滚动容器事件)
|
||||
window.addEventListener('scroll', computeAndSet, { passive: true });
|
||||
document.addEventListener('scroll', computeAndSet, { passive: true, capture: true });
|
||||
return () => {
|
||||
window.removeEventListener('scroll', computeAndSet);
|
||||
document.removeEventListener('scroll', computeAndSet, { capture: true } as any);
|
||||
};
|
||||
}, [isHome]);
|
||||
|
||||
// 离开首页时,移除玻璃背景
|
||||
useEffect(() => {
|
||||
if (!isHome) {
|
||||
setIsGlassActive(false);
|
||||
} else {
|
||||
}
|
||||
}, [isHome]);
|
||||
|
||||
const handleLogoClick = () => {
|
||||
if (isLogin) {
|
||||
router.push('/movies');
|
||||
} else {
|
||||
router.push('/');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpgrade = () => {
|
||||
router.push('/pricing');
|
||||
};
|
||||
|
||||
const handleBuyTokens = async (amount: number) => {
|
||||
if (!user?.id) return;
|
||||
setIsBuyingTokens(true);
|
||||
try {
|
||||
const url = `/pay-redirect?type=token&amount=${encodeURIComponent(amount)}&pkg=basic`;
|
||||
window.open(url, '_blank');
|
||||
window.postMessage({ type: 'waiting-payment', paymentType: 'subscription' }, '*');
|
||||
} finally {
|
||||
setIsBuyingTokens(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomAmountBuy = async () => {
|
||||
const amount = parseInt(customAmount);
|
||||
if (isNaN(amount) || amount <= 0) return;
|
||||
await handleBuyTokens(amount);
|
||||
setCustomAmount("");
|
||||
};
|
||||
|
||||
const handleSignin = () => {
|
||||
setIsSigninModalOpen(true);
|
||||
};
|
||||
|
||||
const handleManageSubscription = async () => {
|
||||
if (!user?.id) return;
|
||||
setIsManagingSubscription(true);
|
||||
try {
|
||||
const response = await createPortalSession({
|
||||
user_id: String(user.id),
|
||||
return_url: window.location.origin + '/dashboard',
|
||||
});
|
||||
if (response.successful && response.data?.portal_url) {
|
||||
redirectToPortal(response.data.portal_url);
|
||||
}
|
||||
} finally {
|
||||
setIsManagingSubscription(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={`backdrop_backdrop ${isHome && isGlassActive ? 'grid' : 'hidden'}`}>
|
||||
<span className='backdrop_blur'></span>
|
||||
<span className='backdrop_blur'></span>
|
||||
<span className='backdrop_blur'></span>
|
||||
<span className='backdrop_blur'></span>
|
||||
<span className='backdrop_blur'></span>
|
||||
<span className='backdrop_blur'></span>
|
||||
</span>
|
||||
<div data-alt="h5-topbar" className={`fixed left-0 right-0 top-0 h-16 header z-[60] ${drawerOpen ? 'bg-[#0b0b0b] pointer-events-auto' : ''}` }>
|
||||
<div data-alt="bar" className="h-14 px-3 flex items-center justify-between">
|
||||
{/* 左侧 LOGO */}
|
||||
<div
|
||||
className={`flex items-center cursor-pointer space-x-1 link-logo roll event-on`}
|
||||
onClick={handleLogoClick}
|
||||
>
|
||||
<h1 className="logo text-2xl font-bold">
|
||||
<GradientText
|
||||
text="MovieFlow"
|
||||
startPercentage={30}
|
||||
endPercentage={70}
|
||||
/>
|
||||
</h1>
|
||||
{/* beta标签 */}
|
||||
<div className="relative transform translate-y-[-1px]">
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 text-[10px] font-semibold tracking-wider text-[rgb(212 202 202)] border border-[rgba(106,244,249,0.2)] rounded-full shadow-[0_0_10px_rgba(106,244,249,0.1)]">
|
||||
Beta
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧操作区 */}
|
||||
<div data-alt="actions" className="flex items-center gap-2">
|
||||
{
|
||||
!drawerOpen && (
|
||||
<>
|
||||
{isLogin ? (
|
||||
<button
|
||||
data-alt="upgrade-icon-button"
|
||||
className="h-9 w-9 rounded-full flex items-center justify-center text-gray-800 bg-gray-100 hover:bg-gray-200 border border-black/10 dark:text-white dark:bg-white/10 dark:hover:bg-white/20 dark:border-white/20"
|
||||
onClick={handleUpgrade}
|
||||
aria-label="Upgrade"
|
||||
>
|
||||
<Rocket className="h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
data-alt="signup-button"
|
||||
className="px-3 h-9 rounded-full text-sm bg-white text-black hover:bg-white/90 border border-black/10 dark:bg-white/10 dark:text-white dark:hover:bg-white/20 dark:border-white/20"
|
||||
onClick={() => router.push('/signup')}
|
||||
>
|
||||
Sign up
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
{/* 菜单抽屉(antd Drawer) */}
|
||||
<button
|
||||
data-alt="menu-trigger"
|
||||
className="relative h-9 w-9 rounded-full flex items-center justify-center bg-gray-100 hover:bg-gray-200 text-gray-800 border border-black/10 dark:bg-white/10 dark:hover:bg-white/20 dark:text-white dark:border-white/20"
|
||||
aria-expanded={drawerOpen}
|
||||
onClick={() => setDrawerOpen((v) => !v)}
|
||||
>
|
||||
{
|
||||
drawerOpen ? <X className="h-4 w-4" /> : <Menu className="h-4 w-4" />
|
||||
}
|
||||
{needsSigninBadge && !drawerOpen && (
|
||||
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-red-500 border border-white dark:border-[#0b0b0b]"></span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<Drawer
|
||||
placement="top"
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
title={null}
|
||||
closable
|
||||
height={undefined}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
maskClosable
|
||||
zIndex={59}
|
||||
// 64px 顶栏高度 + 8px 安全间距
|
||||
maskStyle={{ position: 'absolute', top: '4rem', height: 'calc(100dvh - 4rem)', backgroundColor: 'transparent' }}
|
||||
styles={{
|
||||
content: { position: 'absolute', top: '4rem', height: isHome ? 'auto' : 'calc(100dvh - 4rem)' },
|
||||
body: { padding: 0 },
|
||||
header: { display: 'none' },
|
||||
mask: { position: 'absolute', top: '4rem', height: 'calc(100dvh - 4rem)', backgroundColor: 'transparent' },
|
||||
}}
|
||||
rootStyle={{ zIndex: 59 }}
|
||||
className="[&_.ant-drawer-content]:bg-white [&_.ant-drawer-content]:text-black dark:[&_.ant-drawer-content]:bg-[#0b0b0b] dark:[&_.ant-drawer-content]:text-white dark:[&_.ant-drawer-body]:bg-[#0b0b0b] dark:[&_.ant-drawer-body]:text-white"
|
||||
>
|
||||
<div data-alt="drawer-container" className="h-full flex flex-col">
|
||||
{/* 用户信息/未登录头部 */}
|
||||
{isLogin ? (
|
||||
<div data-alt="user-header" className="p-4 border-b border-t border-black/10 dark:border-white/10 flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-full bg-[#C73BFF] text-white flex items-center justify-center">
|
||||
{(user?.username || user?.name || 'MF').slice(0, 1)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{user?.name || user?.username || 'User'}</div>
|
||||
<div className="text-xs text-black/60 dark:text-white/60 truncate">{user?.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
{/* 内容区 */}
|
||||
<div data-alt="drawer-content" className="flex-1 overflow-y-auto">
|
||||
{isLogin ? (
|
||||
<div className="p-4 space-y-4">
|
||||
{/* 积分中心 */}
|
||||
<div data-alt="wallet-card" className="rounded-xl border border-black/10 dark:border-white/10 p-4 bg-white dark:bg-white/5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 签到 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSignin}
|
||||
className="w-9 h-9 p-2 rounded-full bg-white/10 backdrop-blur-sm hover:bg-white/20 transition-colors relative"
|
||||
data-alt="share-entry-button"
|
||||
title="Share"
|
||||
>
|
||||
<span className={`inline-block ${needsSigninBadge ? 'motion-safe:animate-wiggle' : ''}`}>
|
||||
<CalendarDays className="h-5 w-5 text-white" />
|
||||
</span>
|
||||
{needsSigninBadge && (
|
||||
<span className="absolute top-0 right-0 h-2 w-2 rounded-full bg-red-500 border border-white dark:border-[#0b0b0b]"></span>
|
||||
)}
|
||||
</button>
|
||||
{/* 积分 */}
|
||||
<div className="text-2xl font-semibold mt-1">{credits} <span className="text-xs text-black/60 dark:text-white/60">credits</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="relative h-8 w-8 rounded-full bg-white/10 dark:bg-white/10 flex items-center justify-center hover:bg-white/20"
|
||||
onClick={() => router.push("/usage")}
|
||||
title="Usage"
|
||||
>
|
||||
<Info className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 快捷充值 */}
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button className="px-2 py-1 text-xs rounded bg-black text-white" disabled={isBuyingTokens} onClick={() => handleBuyTokens(100)}>+100 ($1)</button>
|
||||
<button className="px-2 py-1 text-xs rounded bg-black text-white" disabled={isBuyingTokens} onClick={() => handleBuyTokens(500)}>+500 ($5)</button>
|
||||
<button className="px-2 py-1 text-xs rounded bg-black text-white" disabled={isBuyingTokens} onClick={() => handleBuyTokens(1000)}>+1000 ($10)</button>
|
||||
</div>
|
||||
|
||||
{/* 自定义充值 */}
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={customAmount}
|
||||
onChange={(e) => setCustomAmount(e.target.value)}
|
||||
placeholder="Custom amount"
|
||||
className="w-[120px] h-9 px-2 text-sm bg-white/10 text-black dark:text-white placeholder-black/40 dark:placeholder-white/60 border border-black/20 dark:border-white/20 rounded focus:outline-none"
|
||||
min={1}
|
||||
/>
|
||||
<button
|
||||
className="px-3 h-9 rounded bg-black text-white text-sm disabled:opacity-50"
|
||||
disabled={!customAmount || parseInt(customAmount) <= 0 || isBuyingTokens}
|
||||
onClick={handleCustomAmountBuy}
|
||||
>
|
||||
Buy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* 菜单目录 */}
|
||||
<div data-alt="menu-links" className="rounded-xl border border-black/10 dark:border-white/10 divide-y divide-black/10 dark:divide-white/10 overflow-hidden">
|
||||
{navigationItems.map((group) => (
|
||||
group.items.map((nav) => {
|
||||
const isActive = pathname === nav.href || pathname.startsWith(nav.href + '/');
|
||||
return (
|
||||
<button
|
||||
key={nav.href}
|
||||
data-alt={`link-${nav.name.toLowerCase()}`}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-white/10 ${isActive ? 'bg-gray-100 dark:bg-white/10' : ''}`}
|
||||
onClick={() => router.push(nav.href)}
|
||||
>
|
||||
<nav.icon className="h-4 w-4" />
|
||||
<span>{nav.name}</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 其他功能 */}
|
||||
<div className="space-y-2">
|
||||
<button data-alt="upgrade-button" className="w-full h-10 rounded-full border border-black/20 dark:border-white/20 hover:bg-gray-50 dark:hover:bg-white/10" onClick={handleUpgrade}>Upgrade</button>
|
||||
{planName !== 'none' && subscriptionStatus !== 'INACTIVE' && (
|
||||
<button data-alt="manage-button" className="w-full h-10 rounded-full border border-black/20 dark:border-white/20 hover:bg-gray-50 dark:hover:bg-white/10" onClick={handleManageSubscription} disabled={isManagingSubscription}>Manage</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 space-y-4 pt-0">
|
||||
{isHome && homeTabs.length > 0 && (
|
||||
<div data-alt="home-tabs" className="rounded-xl border border-black/10 dark:border-white/10 overflow-hidden">
|
||||
{homeTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.title}
|
||||
data-alt={`home-tab-${tab.title.toLowerCase()}`}
|
||||
className="w-full text-left px-4 py-3 border-b border-black/10 dark:border-white/10 last:border-b-0 hover:bg-gray-50 dark:hover:bg-white/10"
|
||||
onClick={() => {
|
||||
if (onSelectHomeTab) onSelectHomeTab(tab.title.toLowerCase() as any);
|
||||
setDrawerOpen(false);
|
||||
}}
|
||||
>
|
||||
{tab.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
data-alt="login-button"
|
||||
className="flex-1 h-10 rounded-full border border-black/20 dark:border-white/20"
|
||||
onClick={() => router.push('/login')}
|
||||
>
|
||||
Log in
|
||||
</button>
|
||||
<button
|
||||
data-alt="signup-button-secondary"
|
||||
className="flex-1 h-10 rounded-full bg-black text-white hover:bg-black/90"
|
||||
onClick={() => router.push('/signup')}
|
||||
>
|
||||
Sign up
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部操作 */}
|
||||
{isLogin && (
|
||||
<div data-alt="drawer-footer" className="p-4 border-t border-black/10 dark:border-white/10">
|
||||
<button
|
||||
data-alt="logout-button"
|
||||
className="w-full h-10 rounded-full border border-black/20 dark:border-white/20 flex items-center justify-center gap-2"
|
||||
onClick={() => logoutUser()}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Drawer>
|
||||
{/* Sign-in Modal */}
|
||||
<Dialog open={isSigninModalOpen} onOpenChange={(open) => {
|
||||
setIsSigninModalOpen(open);
|
||||
if (!open) {
|
||||
// 刷新积分
|
||||
if (user?.id) { getUserSubscriptionInfo(String(user.id)).then((res:any)=>{ if(res?.data?.credits!==undefined){ setCredits(res.data.credits) } }).catch(()=>{}); }
|
||||
// 关闭签到弹窗后,重新检查红点
|
||||
if (isLogin) { getSigninStatus().then((d:any)=> setNeedsSigninBadge(!d?.data?.has_signin)).catch(()=>{}); }
|
||||
}
|
||||
}}>
|
||||
<DialogContent
|
||||
className="max-w-md mx-auto bg-white/95 dark:bg-gray-900/95 backdrop-blur-xl border-0 shadow-2xl"
|
||||
data-alt="signin-modal"
|
||||
>
|
||||
<DialogTitle></DialogTitle>
|
||||
<SigninBox onSuccess={async () => {
|
||||
try {
|
||||
if (user?.id) {
|
||||
const res = await getUserSubscriptionInfo(String(user.id));
|
||||
if (res?.data?.credits !== undefined) {
|
||||
setCredits(res.data.credits);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
// 成功签到后去除红点
|
||||
setNeedsSigninBadge(false);
|
||||
}} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
|
||||
import { Sidebar } from './sidebar';
|
||||
import { TopBar } from './top-bar';
|
||||
import { useDeviceType } from '@/hooks/useDeviceType';
|
||||
import H5TopBar from '@/components/layout/H5TopBar';
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
children: React.ReactNode;
|
||||
@ -58,15 +59,20 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<TopBar collapsed={sidebarCollapsed} isDesktop={isDesktop} />
|
||||
{isMobile || isTablet ? (
|
||||
<H5TopBar />
|
||||
) : (
|
||||
<TopBar collapsed={sidebarCollapsed} isDesktop={isDesktop} />
|
||||
)}
|
||||
{isDesktop && <Sidebar collapsed={sidebarCollapsed} onToggle={setSidebarCollapsed} />}
|
||||
<div
|
||||
className={`top-[4rem] fixed right-0 bottom-0 px-4 ${getMobileContainerClasses()}`}
|
||||
className={`fixed right-0 bottom-0 px-4 ${getMobileContainerClasses()}`}
|
||||
style={{
|
||||
...getLayoutStyles(),
|
||||
top: (isMobile || isTablet) ? '3.5rem' : '4rem',
|
||||
// 移动端使用动态高度计算
|
||||
height: (isMobile || isTablet)
|
||||
? 'calc(100dvh - 4rem)'
|
||||
? 'calc(100dvh - 3.5rem)'
|
||||
: 'calc(100vh - 4rem)'
|
||||
}}
|
||||
>
|
||||
|
||||
@ -1,44 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { cn } from '@/public/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { GradientText } from '@/components/ui/gradient-text';
|
||||
import {
|
||||
Home,
|
||||
FolderOpen,
|
||||
Users,
|
||||
Type,
|
||||
Image,
|
||||
History,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Video,
|
||||
PanelsLeftBottom,
|
||||
ArrowLeftToLine,
|
||||
BookHeart,
|
||||
PanelRightClose,
|
||||
Gift
|
||||
PanelRightClose
|
||||
} from 'lucide-react';
|
||||
import { navigationItems } from './type';
|
||||
|
||||
interface SidebarProps {
|
||||
collapsed: boolean;
|
||||
onToggle: (collapsed: boolean) => void;
|
||||
}
|
||||
|
||||
const navigationItems = [
|
||||
{
|
||||
title: 'Main',
|
||||
items: [
|
||||
{ name: 'My Portfolio', href: '/movies', icon: BookHeart },
|
||||
{ name: 'Share', href: '/share', icon: Gift },
|
||||
],
|
||||
}
|
||||
];
|
||||
|
||||
export function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
const pathname = usePathname();
|
||||
|
||||
|
||||
@ -7,7 +7,7 @@ import { Coins, Trophy, HelpCircle } from "lucide-react"
|
||||
import { getSigninStatus, performSignin, SigninData } from "@/api/signin"
|
||||
|
||||
|
||||
export default function SigninPage() {
|
||||
export default function SigninPage({ onSuccess }: { onSuccess?: () => void } = {}) {
|
||||
const [signinData, setSigninData] = useState<SigninData>({
|
||||
has_signin: false,
|
||||
credits: 0
|
||||
@ -50,6 +50,8 @@ export default function SigninPage() {
|
||||
if (response.successful) {
|
||||
// Refresh status after successful signin
|
||||
await fetchSigninStatus()
|
||||
// Notify parent to refresh credits
|
||||
try { onSuccess && onSuccess() } catch {}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Signin failed:', error)
|
||||
|
||||
22
components/layout/type.ts
Normal file
22
components/layout/type.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { BookHeart, Gift } from "lucide-react";
|
||||
|
||||
interface NavigationItem {
|
||||
name: string;
|
||||
href: string;
|
||||
icon: any;
|
||||
}
|
||||
|
||||
interface Navigations {
|
||||
title: string;
|
||||
items: NavigationItem[];
|
||||
}
|
||||
|
||||
export const navigationItems: Navigations[] = [
|
||||
{
|
||||
title: 'Main',
|
||||
items: [
|
||||
{ name: 'My Portfolio', href: '/movies', icon: BookHeart },
|
||||
{ name: 'Share', href: '/share', icon: Gift },
|
||||
],
|
||||
}
|
||||
];
|
||||
@ -20,6 +20,7 @@ import LazyLoad from "react-lazyload";
|
||||
import { fetchSubscriptionPlans, SubscriptionPlan } from "@/lib/stripe";
|
||||
import VideoCoverflow from "@/components/ui/VideoCoverflow";
|
||||
import { fetchTabsByCode, HomeTabItem } from "@/api/serversetting";
|
||||
import H5TopBar from "@/components/layout/H5TopBar";
|
||||
import { useCallbackModal } from "@/app/layout";
|
||||
import { useDeviceType } from "@/hooks/useDeviceType";
|
||||
|
||||
@ -241,26 +242,9 @@ export function HomePage2() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* 移动端开关移至 TopBar,保留占位对齐 */}
|
||||
{/* 移动端交由 H5TopBar 控制 */}
|
||||
<span className="md:hidden" data-alt="mobile-menu-toggle-placeholder"></span>
|
||||
</div>
|
||||
{/* 移动端下拉(仅三个项) */}
|
||||
{menuOpen && (
|
||||
<div data-alt="mobile-menu" className="md:hidden bg-black/80 backdrop-blur-md border-b border-white/10 px-4 py-2 text-white/90 text-sm">
|
||||
<div className="grid grid-cols-1 gap-1">
|
||||
{tabsToRender.map((tab) => (
|
||||
<button
|
||||
key={tab.title}
|
||||
data-alt={`m-nav-${tab.title.toLowerCase()}`}
|
||||
className="text-center py-2"
|
||||
onClick={() => scrollToSection(tab.title.toLowerCase() as any)}
|
||||
>
|
||||
{tab.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -268,7 +252,9 @@ export function HomePage2() {
|
||||
|
||||
return (
|
||||
<div className="w-full h-screen overflow-y-auto" id="home-page" ref={containerRef} style={{ paddingBottom: `2rem` }}>
|
||||
<NavBar />
|
||||
{/* 移动端顶部导航(抽屉式) */}
|
||||
<H5TopBar onSelectHomeTab={(key) => scrollToSection(key as any)} />
|
||||
{/* <NavBar /> */}
|
||||
<HomeModule1 />
|
||||
<LazyLoad once>
|
||||
<HomeModule2 />
|
||||
|
||||
@ -191,9 +191,9 @@ const UsageView: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div data-alt="usage-view-container" className="mx-auto max-w-5xl p-6">
|
||||
<div data-alt="usage-view-container" className="mx-auto max-w-5xl p-4 sm:p-6">
|
||||
<div data-alt="header" className="mb-6 flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-white">Credit Usage Details</h2>
|
||||
<h2 className="text-base sm:text-xl font-semibold text-white">Credit Usage Details</h2>
|
||||
<div data-alt="period-switch" className="inline-flex rounded-lg bg-white/5 p-1">
|
||||
{([7, 30, 90] as PeriodDays[]).map((d) => (
|
||||
<button
|
||||
@ -202,7 +202,7 @@ const UsageView: React.FC = () => {
|
||||
data-alt={`period-${d}`}
|
||||
onClick={() => handleChangeDays(d)}
|
||||
className={
|
||||
`px-3 py-1.5 text-sm rounded-md transition-colors ` +
|
||||
`px-2 py-1 text-xs sm:px-3 sm:py-1.5 sm:text-sm rounded-md transition-colors ` +
|
||||
(days === d
|
||||
? "bg-[#C039F6] text-white"
|
||||
: "text-white/80 hover:bg-white/10")
|
||||
@ -214,32 +214,32 @@ const UsageView: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-alt="meta" className="mb-3 text-sm text-white/70">
|
||||
<div data-alt="meta" className="mb-3 text-xs sm:text-sm text-white/70">
|
||||
<span data-alt="meta-period">Period: {periodLabel || "-"} days</span>
|
||||
</div>
|
||||
|
||||
<div data-alt="table-wrapper" className="overflow-hidden rounded-lg border border-white/10">
|
||||
<table data-alt="table" className="min-w-full table-fixed">
|
||||
<div data-alt="table-wrapper" className="overflow-hidden overflow-x-auto rounded-lg border border-white/10">
|
||||
<table data-alt="table" className="min-w-[32rem] sm:min-w-full table-auto">
|
||||
<thead className="bg-white/5">
|
||||
<tr>
|
||||
<th className="w-1/4 px-4 py-2 text-left text-sm font-medium text-white">Kind</th>
|
||||
<th className="w-1/4 px-4 py-2 text-right text-sm font-medium text-white">Credits</th>
|
||||
<th className="w-1/4 px-4 py-2 text-left text-sm font-medium text-white">From</th>
|
||||
<th className="w-1/4 px-4 py-2 text-left text-sm font-medium text-white">Date</th>
|
||||
<th className="px-2 py-1 text-left text-xs sm:px-4 sm:py-2 sm:text-sm font-medium text-white">Kind</th>
|
||||
<th className="px-2 py-1 text-left text-xs sm:px-4 sm:py-2 sm:text-sm font-medium text-white">Credits</th>
|
||||
<th className="px-2 py-1 text-left text-xs sm:px-4 sm:py-2 sm:text-sm font-medium text-white">From</th>
|
||||
<th className="px-2 py-1 text-left text-xs sm:px-4 sm:py-2 sm:text-sm font-medium text-white">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/10">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td className="px-4 py-3 text-white/70" colSpan={4} data-alt="row-loading">Loading...</td>
|
||||
<td className="px-2 py-2 text-xs sm:px-4 sm:py-3 sm:text-sm text-white/70" colSpan={4} data-alt="row-loading">Loading...</td>
|
||||
</tr>
|
||||
) : error ? (
|
||||
<tr>
|
||||
<td className="px-4 py-3 text-white/70" data-alt="row-error" colSpan={4}>-</td>
|
||||
<td className="px-2 py-2 text-xs sm:px-4 sm:py-3 sm:text-sm text-white/70" data-alt="row-error" colSpan={4}>-</td>
|
||||
</tr>
|
||||
) : items.length === 0 ? (
|
||||
<tr>
|
||||
<td className="px-4 py-3 text-white/70" data-alt="row-empty" colSpan={4}>-</td>
|
||||
<td className="px-2 py-2 text-xs sm:px-4 sm:py-3 sm:text-sm text-white/70" data-alt="row-empty" colSpan={4}>-</td>
|
||||
</tr>
|
||||
) : (
|
||||
items.map((it, idx) => {
|
||||
@ -248,7 +248,7 @@ const UsageView: React.FC = () => {
|
||||
return (
|
||||
<React.Fragment key={key}>
|
||||
<tr>
|
||||
<td className="px-4 py-2 text-white/90" data-alt="cell-transaction-type">
|
||||
<td className="px-2 py-1 text-xs sm:px-4 sm:py-2 sm:text-sm text-white/90" data-alt="cell-transaction-type">
|
||||
<div data-alt="type-cell" className="flex items-center gap-2">
|
||||
<span data-alt="type-text">{it?.transaction_type || '-'}</span>
|
||||
{it?.project_info ? (
|
||||
@ -272,47 +272,47 @@ const UsageView: React.FC = () => {
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
<td className={`px-4 py-2 text-right ${getAmountColorClass(it?.change_type)}`} data-alt="cell-amount">
|
||||
<td className={`px-2 py-1 text-left text-xs sm:px-4 sm:py-2 sm:text-sm ${getAmountColorClass(it?.change_type)}`} data-alt="cell-amount">
|
||||
{Number.isFinite(it?.amount as number)
|
||||
? `${it.change_type === 'INCREASE' ? '+' : it.change_type === 'DECREASE' ? '-' : ''}${it.amount}`
|
||||
: '-'}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-white/90" data-alt="cell-source-type">
|
||||
<td className="px-2 py-1 text-xs sm:px-4 sm:py-2 sm:text-sm text-white/90" data-alt="cell-source-type">
|
||||
{formatSource(it?.source_type)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-white/90" data-alt="cell-created-at">
|
||||
<td className="px-2 py-1 text-xs sm:px-4 sm:py-2 sm:text-sm text-white/90" data-alt="cell-created-at">
|
||||
{it?.created_at ? dateFormatter.format(new Date(it.created_at)) : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
{isExpanded && it.project_info && (
|
||||
<tr data-alt="row-details">
|
||||
<td colSpan={4} className="bg-white/5 px-4 py-3">
|
||||
<div data-alt="project-summary" className="mb-2 text-sm text-white/90">
|
||||
<td colSpan={4} className="bg-white/5 px-3 py-2 sm:px-4 sm:py-3">
|
||||
<div data-alt="project-summary" className="mb-2 text-xs sm:text-sm text-white/90">
|
||||
<div data-alt="project-name">Project: {it.project_info.project_name || '-'}</div>
|
||||
<div data-alt="project-id" className="text-white/60">ID: {it.project_info.project_id || '-'}</div>
|
||||
</div>
|
||||
<div data-alt="videos-table-wrapper" className="overflow-hidden rounded-md border border-white/10">
|
||||
<table data-alt="videos-table" className="min-w-full table-fixed">
|
||||
<table data-alt="videos-table" className="min-w-[28rem] sm:min-w-full table-auto">
|
||||
<tbody className="divide-y divide-white/10">
|
||||
{(it.project_info.videos || []).length === 0 ? (
|
||||
<tr>
|
||||
<td className="px-3 py-2 text-white/70" colSpan={4} data-alt="videos-empty">-</td>
|
||||
<td className="px-3 py-2 text-xs sm:text-sm text-white/70" colSpan={4} data-alt="videos-empty">-</td>
|
||||
</tr>
|
||||
) : (
|
||||
it.project_info.videos.map((v, vIdx) => (
|
||||
<tr key={`${v.created_at}-${vIdx}`}>
|
||||
<td className="w-1/4 px-3 py-2 text-white/90" data-alt="video-name">
|
||||
<td className="px-2 py-1 text-xs sm:px-3 sm:py-2 sm:text-sm text-white/90" data-alt="video-name">
|
||||
{v.video_name && v.video_name.trim() ? v.video_name : `video${vIdx + 1}`}
|
||||
</td>
|
||||
<td className={`w-1/4 px-3 py-2 text-right ${getAmountColorClass(v.change_type)}`} data-alt="video-amount">
|
||||
<td className={`px-2 py-1 text-left text-xs sm:px-3 sm:py-2 sm:text-sm ${getAmountColorClass(v.change_type)}`} data-alt="video-amount">
|
||||
{Number.isFinite(v.amount as number)
|
||||
? `${v.change_type === 'INCREASE' ? '+' : v.change_type === 'DECREASE' ? '-' : ''}${v.amount}`
|
||||
: '-'}
|
||||
</td>
|
||||
<td className="w-1/4 px-3 py-2 text-white/90" data-alt="video-source">
|
||||
<td className="px-2 py-1 text-xs sm:px-3 sm:py-2 sm:text-sm text-white/90" data-alt="video-source">
|
||||
{formatSource(v.source_type)}
|
||||
</td>
|
||||
<td className="w-1/4 px-3 py-2 text-white/90" data-alt="video-created-at">
|
||||
<td className="px-2 py-1 text-xs sm:px-3 sm:py-2 sm:text-sm text-white/90" data-alt="video-created-at">
|
||||
{v.created_at ? dateFormatter.format(new Date(v.created_at)) : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
@ -332,7 +332,7 @@ const UsageView: React.FC = () => {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div data-alt="pagination" className="mt-4 flex items-center justify-between text-sm text-white/80">
|
||||
<div data-alt="pagination" className="mt-4 flex items-center justify-between text-xs sm:text-sm text-white/80">
|
||||
<div data-alt="total-info">
|
||||
Total {Number.isFinite(total) ? total : 0}
|
||||
</div>
|
||||
@ -342,20 +342,20 @@ const UsageView: React.FC = () => {
|
||||
onClick={handlePrev}
|
||||
disabled={!canPrev}
|
||||
className={
|
||||
"rounded-md px-3 py-1.5 transition-colors " +
|
||||
"rounded-md px-2 py-1 sm:px-3 sm:py-1.5 transition-colors " +
|
||||
(canPrev ? "bg-white/10 hover:bg-white/20" : "bg-white/5 text-white/40 cursor-not-allowed")
|
||||
}
|
||||
data-alt="prev-page"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span data-alt="page-indicator" className="px-1 py-1.5">Page {page}</span>
|
||||
<span data-alt="page-indicator" className="px-1 py-1 sm:py-1.5">Page {page}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNext}
|
||||
disabled={!canNext}
|
||||
className={
|
||||
"rounded-md px-3 py-1.5 transition-colors " +
|
||||
"rounded-md px-2 py-1 sm:px-3 sm:py-1.5 transition-colors " +
|
||||
(canNext ? "bg-white/10 hover:bg-white/20" : "bg-white/5 text-white/40 cursor-not-allowed")
|
||||
}
|
||||
data-alt="next-page"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user