feat: Thêm các tiện ích an toàn cho khu vực di động, cải thiện cấu trúc footer và cập nhật layout cho trải nghiệm ứng dụng gốc.

This commit is contained in:
Ho Ngoc Hai
2026-01-04 18:20:33 +07:00
parent d404689531
commit df5545e7b5
8 changed files with 798 additions and 177 deletions

View File

@@ -0,0 +1,39 @@
'use client';
import { MobileAppDemo } from '@/features/shared/components/layout/mobile-layout';
/**
* EN: Mobile App Demo Page
* VI: Trang Demo Mobile App
*
* Showcases the native app-style mobile layout components.
* Trình diễn các component mobile layout theo phong cách native app.
*/
export default function MobileDemoPage() {
return (
<div className="min-h-screen bg-gray-100 p-8">
<div className="max-w-4xl mx-auto">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-4">
Mobile App Layout Demo
</h1>
<p className="text-gray-600 max-w-2xl mx-auto">
Experience native app-style mobile layouts with pull-to-refresh,
bottom navigation, and touch-optimized interactions.
</p>
</div>
<div className="flex justify-center">
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden">
<MobileAppDemo />
</div>
</div>
<div className="mt-8 text-center text-sm text-gray-500">
<p>Resize your browser window to mobile size to see the full experience!</p>
<p className="mt-2">Try pulling down on the Home screen to test pull-to-refresh.</p>
</div>
</div>
</div>
);
}

View File

@@ -1,9 +1,6 @@
'use client';
import React from 'react';
import { Github, Twitter, Linkedin, Mail } from 'lucide-react';
import { BrandLogo } from '../../brand';
import { useTranslation } from '@/shared/hooks/use-translation';
import { cn } from '@/shared/lib/utils';
/**
@@ -16,118 +13,72 @@ export interface FooterProps {
}
/**
* EN: Footer - Responsive footer with brand elements, navigation links, and social media
* VI: Footer - Footer responsive với brand elements, navigation links và social media
*
* EN: Footer - x.ai style footer with clean layout
* VI: Footer - Footer theo phong cách x.ai với layout sạch sẽ
*
* Features:
* - Responsive design (mobile: stacked, desktop: 4-column grid)
* - Brand logo and tagline
* - Navigation links (Product, Company, Support)
* - Social media links
* - Copyright notice with dynamic year
* - i18n support (EN/VI)
* - Accessibility (ARIA labels, semantic HTML)
*
* - Clean minimal design like x.ai
* - Four column layout (Try Grok On, Products, Company, Resources)
* - Dark theme with white text
* - No social media clutter
*
* @example
* ```tsx
* <Footer />
* ```
*/
export function Footer({ className }: FooterProps) {
// EN: Translation hook / VI: Hook translation
const { t } = useTranslation();
// EN: Current year for copyright / VI: Năm hiện tại cho copyright
const currentYear = new Date().getFullYear();
// EN: Navigation link sections / VI: Các section navigation links
const navigationSections = [
// EN: Footer sections like x.ai / VI: Các section footer như x.ai
const footerSections = [
{
title: t('footer.product'),
title: 'Try Grok On',
links: [
{ label: t('footer.features'), href: '#features' },
{ label: t('footer.pricing'), href: '#pricing' },
{ label: t('footer.documentation'), href: '#docs' },
{ label: 'Web', href: '/chat' },
{ label: 'iOS', href: 'https://apps.apple.com/app/apple-store/id6670324846?pt=126952307&ct=x.ai%20Direct%20Link&mt=8' },
{ label: 'Android', href: 'https://play.google.com/store/apps/details?id=ai.x.grok&hl=en' },
{ label: 'Grok on X', href: 'https://x.com/i/grok' },
],
},
{
title: t('footer.company'),
title: 'Products',
links: [
{ label: t('footer.about'), href: '#about' },
{ label: t('footer.blog'), href: '#blog' },
{ label: t('footer.careers'), href: '#careers' },
{ label: 'Grok', href: '/chat' },
{ label: '𝕏', href: 'https://x.com' },
{ label: 'API', href: '/api' },
{ label: 'Grok Enterprise', href: '/grok/business' },
{ label: 'Grokipedia', href: 'https://grokipedia.com' },
],
},
{
title: t('footer.support'),
title: 'Company',
links: [
{ label: t('footer.helpCenter'), href: '#help' },
{ label: t('footer.contact'), href: '#contact' },
{ label: t('footer.status'), href: '#status' },
{ label: 'Company', href: '/company' },
{ label: 'Careers', href: '/careers' },
{ label: 'Contact', href: '/contact' },
{ label: 'News', href: '/news' },
],
},
{
title: 'Resources',
links: [
{ label: 'Documentation', href: 'https://docs.x.ai' },
{ label: 'Privacy policy', href: '/privacy-policy' },
{ label: 'Security', href: '/security' },
{ label: 'Safety', href: '/safety' },
{ label: 'Legal', href: '/legal' },
{ label: 'Status', href: 'https://status.x.ai' },
],
},
];
// EN: Social media links / VI: Links mạng xã hội
const socialLinks = [
{ icon: Github, label: 'GitHub', href: 'https://github.com/goodgo', ariaLabel: 'Visit our GitHub' },
{ icon: Twitter, label: 'Twitter', href: 'https://twitter.com/goodgo', ariaLabel: 'Follow us on Twitter' },
{ icon: Linkedin, label: 'LinkedIn', href: 'https://linkedin.com/company/goodgo', ariaLabel: 'Connect on LinkedIn' },
{ icon: Mail, label: 'Email', href: 'mailto:hello@goodgo.com', ariaLabel: 'Send us an email' },
];
return (
<footer
className={cn(
'border-t border-border-primary bg-bg-secondary',
'transition-all duration-normal',
className
)}
>
<div className="container-responsive py-12">
{/* EN: Main footer content / VI: Nội dung footer chính */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8 mb-8">
{/* EN: Column 1 - Brand / VI: Cột 1 - Thương hiệu */}
<div className="space-y-4">
<BrandLogo variant="wordmark" size="sm" />
<p className="text-sm text-text-secondary max-w-xs">
{t('footer.tagline')}
</p>
{/* EN: Social media links / VI: Links mạng xã hội */}
<div className="flex gap-3">
<p className="text-sm text-text-secondary sr-only">
{t('footer.followUs')}
</p>
{socialLinks.map((social) => {
const Icon = social.icon;
return (
<a
key={social.label}
href={social.href}
target="_blank"
rel="noopener noreferrer"
aria-label={social.ariaLabel}
className={cn(
'p-2 rounded-md',
'text-text-secondary hover:text-brand-primary',
'bg-bg-tertiary hover:bg-bg-primary',
'transition-colors duration-fast',
'focus-visible:outline-2 focus-visible:outline-accent-primary',
'btn-touch'
)}
>
<Icon className="h-5 w-5" />
</a>
);
})}
</div>
</div>
{/* EN: Columns 2-4 - Navigation sections / VI: Cột 2-4 - Các section navigation */}
{navigationSections.map((section) => (
<footer className={cn('bg-black text-white py-16', className)}>
<div className="container mx-auto px-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
{/* Footer sections like x.ai */}
{footerSections.map((section) => (
<div key={section.title} className="space-y-4">
<h3 className="text-sm font-semibold text-text-primary uppercase tracking-wider">
<h3 className="text-white text-sm font-medium mb-6">
{section.title}
</h3>
<ul className="space-y-3">
@@ -135,12 +86,9 @@ export function Footer({ className }: FooterProps) {
<li key={link.label}>
<a
href={link.href}
className={cn(
'text-sm text-text-secondary hover:text-brand-primary',
'transition-colors duration-fast',
'focus-visible:outline-2 focus-visible:outline-accent-primary',
'inline-block'
)}
className="text-white/60 hover:text-white transition-colors text-sm"
target={link.href.startsWith('http') ? '_blank' : undefined}
rel={link.href.startsWith('http') ? 'noopener noreferrer' : undefined}
>
{link.label}
</a>
@@ -150,68 +98,26 @@ export function Footer({ className }: FooterProps) {
</div>
))}
</div>
{/* EN: Bottom bar - Copyright and legal links / VI: Bottom bar - Copyright và legal links */}
<div className="border-t border-border-primary pt-8">
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
{/* EN: Copyright notice / VI: Thông báo copyright */}
<p className="text-sm text-text-tertiary text-center md:text-left">
{t('footer.copyright', { year: currentYear })}
</p>
{/* EN: Legal links / VI: Legal links */}
<div className="flex gap-6">
<a
href="#privacy"
className={cn(
'text-sm text-text-tertiary hover:text-white',
'transition-colors duration-fast',
'focus-visible:outline-2 focus-visible:outline-accent-primary'
)}
>
{t('footer.privacyPolicy')}
</a>
<a
href="#terms"
className={cn(
'text-sm text-text-tertiary hover:text-white',
'transition-colors duration-fast',
'focus-visible:outline-2 focus-visible:outline-accent-primary'
)}
>
{t('footer.termsOfService')}
</a>
</div>
</div>
</div>
</div>
</footer>
);
}
/**
* EN: Minimal Footer - Simplified version with just copyright
* VI: Footer tối giản - Phiên bản đơn giản chỉ có copyright
*
* EN: Minimal Footer - Clean x.ai style minimal footer
* VI: Footer tối giản - Footer tối giản theo phong cách x.ai
*
* @example
* ```tsx
* <MinimalFooter />
* ```
*/
export function MinimalFooter({ className }: FooterProps) {
const { t } = useTranslation();
const currentYear = new Date().getFullYear();
return (
<footer
className={cn(
'border-t border-border-primary bg-bg-secondary',
className
)}
>
<div className="container-responsive py-6">
<p className="text-sm text-text-secondary text-center">
{t('footer.copyright', { year: currentYear })}
<footer className={cn('bg-black text-white py-8', className)}>
<div className="container mx-auto px-6">
<p className="text-white/40 text-sm text-center">
© 2024 GoodGo. All rights reserved.
</p>
</div>
</footer>

View File

@@ -5,3 +5,8 @@
export { MobileLayout } from './mobile-layout';
export type { MobileLayoutProps } from './mobile-layout';
export { MobileBottomNav, useBottomNav } from './mobile-bottom-nav';
export type { MobileBottomNavProps } from './mobile-bottom-nav';
export { MobileAppDemo } from './mobile-app-demo';

View File

@@ -0,0 +1,261 @@
'use client';
import React, { useState } from 'react';
import { MobileLayout, MobileBottomNav, useBottomNav } from './index';
import { MessageCircle, User, Settings, Home, Search, ChevronLeft, MoreHorizontal } from 'lucide-react';
import { Button } from '@/ui';
/**
* EN: Mobile App Demo - Showcases native app-style mobile layout
* VI: Mobile App Demo - Trình diễn mobile layout theo phong cách native app
*
* Demonstrates:
* - Native-style bottom navigation
* - Pull-to-refresh functionality
* - App-like header with back button
* - Touch-optimized interactions
*
* @example
* ```tsx
* <MobileAppDemo />
* ```
*/
export function MobileAppDemo() {
const { activeItem, handleNavPress } = useBottomNav('home');
const [refreshCount, setRefreshCount] = useState(0);
// Simulate async refresh
const handleRefresh = async () => {
await new Promise(resolve => setTimeout(resolve, 2000));
setRefreshCount(prev => prev + 1);
};
// Render different content based on active nav item
const renderContent = () => {
switch (activeItem) {
case 'home':
return <HomeScreen refreshCount={refreshCount} />;
case 'search':
return <SearchScreen />;
case 'chat':
return <ChatScreen />;
case 'profile':
return <ProfileScreen />;
case 'settings':
return <SettingsScreen />;
default:
return <HomeScreen refreshCount={refreshCount} />;
}
};
// Header content based on active screen
const getHeader = () => {
const screenTitles = {
home: 'Home',
search: 'Search',
chat: 'Messages',
profile: 'Profile',
settings: 'Settings',
};
return (
<div className="flex items-center justify-between w-full px-4">
<div className="flex items-center space-x-3">
<Button variant="ghost" size="sm" className="p-2">
<ChevronLeft className="w-5 h-5 text-white" />
</Button>
<h1 className="text-lg font-semibold text-white">
{screenTitles[activeItem as keyof typeof screenTitles]}
</h1>
</div>
<Button variant="ghost" size="sm" className="p-2">
<MoreHorizontal className="w-5 h-5 text-white" />
</Button>
</div>
);
};
return (
<MobileLayout
showBottomNav
bottomNavItems={[
{
id: 'home',
label: 'Home',
icon: <Home className="w-6 h-6" />,
},
{
id: 'search',
label: 'Search',
icon: <Search className="w-6 h-6" />,
},
{
id: 'chat',
label: 'Chat',
icon: <MessageCircle className="w-6 h-6" />,
badge: 3,
},
{
id: 'profile',
label: 'Profile',
icon: <User className="w-6 h-6" />,
},
{
id: 'settings',
label: 'Settings',
icon: <Settings className="w-6 h-6" />,
},
]}
activeNavItem={activeItem}
onNavItemPress={handleNavPress}
header={getHeader()}
enablePullToRefresh
onRefresh={handleRefresh}
className="max-w-sm mx-auto border border-white/20 rounded-2xl overflow-hidden"
>
{renderContent()}
</MobileLayout>
);
}
// Screen Components
function HomeScreen({ refreshCount }: { refreshCount: number }) {
return (
<div className="p-6 space-y-6">
<div className="text-center">
<h2 className="text-2xl font-bold text-white mb-2">Welcome Home</h2>
<p className="text-white/70">
Refreshed {refreshCount} time{refreshCount !== 1 ? 's' : ''}
</p>
</div>
<div className="space-y-4">
{[1, 2, 3, 4, 5].map((item) => (
<div key={item} className="bg-white/5 backdrop-blur-sm rounded-xl p-4 border border-white/10">
<h3 className="text-white font-medium mb-2">Card {item}</h3>
<p className="text-white/60 text-sm">
This is a sample card with some content. Pull down to refresh!
</p>
</div>
))}
</div>
</div>
);
}
function SearchScreen() {
return (
<div className="p-6">
<div className="mb-6">
<div className="bg-white/10 backdrop-blur-sm rounded-xl px-4 py-3 border border-white/20">
<div className="flex items-center space-x-3">
<Search className="w-5 h-5 text-white/50" />
<input
type="text"
placeholder="Search..."
className="bg-transparent text-white placeholder-white/50 flex-1 outline-none"
/>
</div>
</div>
</div>
<div className="space-y-4">
<h3 className="text-white font-medium">Recent Searches</h3>
{['React', 'TypeScript', 'Mobile UI', 'Design Systems'].map((search) => (
<div key={search} className="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<span className="text-white/80">{search}</span>
<ChevronLeft className="w-4 h-4 text-white/50 rotate-180" />
</div>
))}
</div>
</div>
);
}
function ChatScreen() {
return (
<div className="p-6">
<div className="space-y-4">
{[
{ name: 'Alice', message: 'Hey! How are you?', time: '2m ago' },
{ name: 'Bob', message: 'Meeting at 3 PM?', time: '1h ago' },
{ name: 'Charlie', message: 'Thanks for the help!', time: '2h ago' },
].map((chat, index) => (
<div key={index} className="flex items-center space-x-3 p-3 bg-white/5 rounded-xl border border-white/10">
<div className="w-10 h-10 bg-white/20 rounded-full flex items-center justify-center">
<span className="text-white font-medium text-sm">
{chat.name[0]}
</span>
</div>
<div className="flex-1">
<div className="flex items-center justify-between">
<span className="text-white font-medium">{chat.name}</span>
<span className="text-white/50 text-xs">{chat.time}</span>
</div>
<p className="text-white/70 text-sm">{chat.message}</p>
</div>
</div>
))}
</div>
</div>
);
}
function ProfileScreen() {
return (
<div className="p-6">
<div className="text-center mb-8">
<div className="w-20 h-20 bg-white/20 rounded-full mx-auto mb-4 flex items-center justify-center">
<User className="w-10 h-10 text-white" />
</div>
<h3 className="text-white text-xl font-bold">John Doe</h3>
<p className="text-white/60">john@example.com</p>
</div>
<div className="space-y-4">
{[
{ label: 'Edit Profile', icon: User },
{ label: 'Notifications', icon: Settings },
{ label: 'Privacy', icon: Settings },
].map((item, index) => (
<div key={index} className="flex items-center justify-between p-4 bg-white/5 rounded-xl border border-white/10">
<div className="flex items-center space-x-3">
<item.icon className="w-5 h-5 text-white/60" />
<span className="text-white">{item.label}</span>
</div>
<ChevronLeft className="w-4 h-4 text-white/50 rotate-180" />
</div>
))}
</div>
</div>
);
}
function SettingsScreen() {
return (
<div className="p-6">
<div className="space-y-6">
<h3 className="text-white text-lg font-bold">Settings</h3>
<div className="space-y-4">
{[
{ label: 'Account', description: 'Manage your account settings' },
{ label: 'Notifications', description: 'Configure notification preferences' },
{ label: 'Privacy', description: 'Control your privacy settings' },
{ label: 'Appearance', description: 'Customize app appearance' },
].map((setting, index) => (
<div key={index} className="p-4 bg-white/5 rounded-xl border border-white/10">
<div className="flex items-center justify-between">
<div>
<h4 className="text-white font-medium">{setting.label}</h4>
<p className="text-white/60 text-sm">{setting.description}</p>
</div>
<ChevronLeft className="w-4 h-4 text-white/50 rotate-180" />
</div>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,149 @@
'use client';
import React from 'react';
import { MessageCircle, User, Settings, Home, Search } from 'lucide-react';
import { cn } from '@/shared/lib/utils';
import { MobileLayout } from './mobile-layout';
/**
* EN: Native-style Bottom Navigation Component
* VI: Component Bottom Navigation theo phong cách Native
*
* Pre-configured bottom navigation for common app patterns.
* Bottom navigation được cấu hình sẵn cho các pattern app thông dụng.
*
* @example
* ```tsx
* <MobileBottomNav
* activeItem="chat"
* onItemPress={handleNavPress}
* showBadges={{ chat: 3 }}
* />
* ```
*/
export interface MobileBottomNavProps {
/** Active navigation item ID */
activeItem?: string;
/** Callback when item is pressed */
onItemPress?: (itemId: string) => void;
/** Show badges on items */
showBadges?: Record<string, number>;
/** Custom navigation items */
customItems?: MobileLayout['bottomNavItems'];
/** Hide labels on inactive items */
hideLabels?: boolean;
}
/**
* Pre-configured bottom navigation items for common apps
*/
const DEFAULT_NAV_ITEMS: MobileLayout['bottomNavItems'] = [
{
id: 'home',
label: 'Home',
icon: <Home className="w-6 h-6" />,
},
{
id: 'search',
label: 'Search',
icon: <Search className="w-6 h-6" />,
},
{
id: 'chat',
label: 'Chat',
icon: <MessageCircle className="w-6 h-6" />,
},
{
id: 'profile',
label: 'Profile',
icon: <User className="w-6 h-6" />,
},
{
id: 'settings',
label: 'Settings',
icon: <Settings className="w-6 h-6" />,
},
];
export function MobileBottomNav({
activeItem,
onItemPress,
showBadges = {},
customItems,
hideLabels = false,
}: MobileBottomNavProps) {
const navItems = customItems || DEFAULT_NAV_ITEMS;
return (
<nav className="flex items-center justify-around w-full h-full px-2">
{navItems.map((item) => {
const badge = showBadges[item.id];
const isActive = activeItem === item.id;
return (
<button
key={item.id}
onClick={() => onItemPress?.(item.id)}
className={cn(
'flex flex-col items-center justify-center',
'min-w-[60px] h-12 rounded-lg',
'transition-all duration-200',
'relative group',
'active:scale-95',
isActive
? 'text-white'
: 'text-white/50 hover:text-white/80'
)}
>
<div className={cn(
'relative mb-1 transition-transform',
isActive ? 'scale-110' : 'group-hover:scale-105'
)}>
{item.icon}
{badge && badge > 0 && (
<div className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full min-w-[18px] h-[18px] flex items-center justify-center px-1 font-medium">
{badge > 99 ? '99+' : badge}
</div>
)}
</div>
{(!hideLabels || isActive) && (
<span className={cn(
'text-xs font-medium transition-all',
isActive ? 'opacity-100' : 'opacity-70'
)}>
{item.label}
</span>
)}
{isActive && (
<div className="absolute bottom-0 left-1/2 transform -translate-x-1/2 w-6 h-0.5 bg-white rounded-full animate-fadeIn" />
)}
</button>
);
})}
</nav>
);
}
/**
* EN: Hook for managing bottom navigation state
* VI: Hook để quản lý trạng thái bottom navigation
*
* @example
* ```tsx
* const { activeItem, setActiveItem, handleNavPress } = useBottomNav('home');
* ```
*/
export function useBottomNav(initialItem: string = 'home') {
const [activeItem, setActiveItem] = React.useState(initialItem);
const handleNavPress = React.useCallback((itemId: string) => {
setActiveItem(itemId);
}, []);
return {
activeItem,
setActiveItem,
handleNavPress,
};
}

View File

@@ -1,13 +1,20 @@
/**
* EN: Mobile Layout Component
* VI: Component Layout cho Mobile
* EN: Native App-style Mobile Layout Component
* VI: Component Layout Mobile theo phong cách Native App
*
* A layout optimized for mobile devices with touch-friendly spacing and navigation.
* Layout được tối ưu cho thiết bị mobile với spacing và navigation thân thiện với touch.
* A mobile-first layout that mimics native app UX with iOS/Android design patterns.
* Layout mobile-first mô phỏng UX native app với design patterns của iOS/Android.
*
* Features:
* - iOS/Android safe areas support
* - Native-like bottom navigation
* - Pull-to-refresh capability
* - Touch-optimized interactions
* - App-like scrolling behavior
*
* @example
* ```tsx
* <MobileLayout>
* <MobileLayout showBottomNav>
* <YourContent />
* </MobileLayout>
* ```
@@ -15,7 +22,7 @@
'use client';
import React from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { cn } from '@/shared/utils';
export interface MobileLayoutProps {
@@ -53,29 +60,70 @@ export interface MobileLayoutProps {
* Whether to show the footer
*/
showFooter?: boolean;
/**
* Enable pull-to-refresh functionality
*/
enablePullToRefresh?: boolean;
/**
* Callback for pull-to-refresh
*/
onRefresh?: () => Promise<void>;
/**
* Show native-style bottom navigation bar
*/
showBottomNav?: boolean;
/**
* Bottom navigation items for native-style nav
*/
bottomNavItems?: BottomNavItem[];
/**
* Active bottom nav item
*/
activeNavItem?: string;
/**
* Callback when bottom nav item is pressed
*/
onNavItemPress?: (itemId: string) => void;
}
export interface BottomNavItem {
id: string;
label: string;
icon: React.ReactNode;
badge?: number;
}
/**
* Mobile Layout Component
* Native App-style Mobile Layout Component
*
* Features:
* - Full-height viewport layout
* - Sticky glass header (h-14 / 56px)
* - Scrollable main content area
* - Optional bottom navigation
* - Touch-optimized spacing (min 44px targets)
* - Glassmorphism styling
* - Full-screen native app experience
* - iOS/Android safe areas
* - Pull-to-refresh with native feel
* - Native-style bottom navigation
* - Touch-optimized interactions
* - App-like scrolling physics
*
* Layout structure:
* - Header: Fixed at top with glass effect
* - Main: Flexible scrollable content area
* - Footer/BottomNav: Fixed at bottom (optional)
* - Header: iOS/Android style with safe areas
* - Main: Native scrolling with pull-to-refresh
* - BottomNav: Native-style tab bar
*
* @example
* ```tsx
* <MobileLayout
* header={<MobileHeader />}
* bottomNav={<MobileBottomNav />}
* showBottomNav
* bottomNavItems={navItems}
* activeNavItem="chat"
* onNavItemPress={handleNavPress}
* enablePullToRefresh
* onRefresh={handleRefresh}
* >
* <ChatInterface />
* </MobileLayout>
@@ -89,24 +137,95 @@ export function MobileLayout({
bottomNav,
showHeader = true,
showFooter = false,
enablePullToRefresh = false,
onRefresh,
showBottomNav = false,
bottomNavItems = [],
activeNavItem,
onNavItemPress,
}: MobileLayoutProps) {
const [isRefreshing, setIsRefreshing] = useState(false);
const [pullDistance, setPullDistance] = useState(0);
const [isPulling, setIsPulling] = useState(false);
const mainRef = useRef<HTMLDivElement>(null);
const startYRef = useRef<number>(0);
const isDraggingRef = useRef(false);
// Pull-to-refresh logic
useEffect(() => {
if (!enablePullToRefresh || !mainRef.current) return;
const handleTouchStart = (e: TouchEvent) => {
if (mainRef.current!.scrollTop === 0) {
startYRef.current = e.touches[0].clientY;
isDraggingRef.current = true;
}
};
const handleTouchMove = (e: TouchEvent) => {
if (!isDraggingRef.current || mainRef.current!.scrollTop > 0) return;
const currentY = e.touches[0].clientY;
const diff = currentY - startYRef.current;
if (diff > 0) {
e.preventDefault();
setPullDistance(Math.min(diff * 0.5, 80)); // Max pull distance
setIsPulling(diff > 30); // Threshold for refresh trigger
}
};
const handleTouchEnd = async () => {
if (!isDraggingRef.current) return;
isDraggingRef.current = false;
if (isPulling && onRefresh) {
setIsRefreshing(true);
setPullDistance(0);
setIsPulling(false);
try {
await onRefresh();
} finally {
setIsRefreshing(false);
}
} else {
setPullDistance(0);
setIsPulling(false);
}
};
const element = mainRef.current;
element.addEventListener('touchstart', handleTouchStart, { passive: false });
element.addEventListener('touchmove', handleTouchMove, { passive: false });
element.addEventListener('touchend', handleTouchEnd);
return () => {
element.removeEventListener('touchstart', handleTouchStart);
element.removeEventListener('touchmove', handleTouchMove);
element.removeEventListener('touchend', handleTouchEnd);
};
}, [enablePullToRefresh, isPulling, onRefresh]);
return (
<div
className={cn(
'flex flex-col min-h-screen',
'mobile-layout',
'overflow-hidden', // Prevent double scrollbars
'bg-black text-white', // Native app dark theme
className
)}
>
{/* Mobile Header - Sticky with glass effect */}
{/* Mobile Header - iOS/Android style */}
{showHeader && header && (
<header
className={cn(
'glass-nav',
'bg-black/80 backdrop-blur-xl',
'sticky top-0 z-50',
'h-14 min-h-[56px]', // Touch-friendly height
'flex items-center px-4',
'border-b border-white/10',
'safe-area-inset-top' // iOS notch support
)}
>
@@ -114,16 +233,67 @@ export function MobileLayout({
</header>
)}
{/* Main Content - Scrollable */}
{/* Main Content - Native scrolling with pull-to-refresh */}
<main
ref={mainRef}
className={cn(
'flex-1',
'overflow-y-auto overflow-x-hidden',
'overscroll-behavior-contain', // Prevent pull-to-refresh issues
bottomNav ? 'pb-safe-bottom' : '' // Space for bottom nav if present
'relative',
showBottomNav ? 'pb-20' : '', // Space for native bottom nav
enablePullToRefresh ? 'overscroll-none' : 'overscroll-behavior-contain'
)}
style={{
transform: pullDistance > 0 ? `translateY(${pullDistance}px)` : undefined,
transition: isDraggingRef.current ? 'none' : 'transform 0.3s ease-out'
}}
>
{children}
{/* Pull-to-refresh indicator */}
{enablePullToRefresh && (
<div
className={cn(
'absolute top-0 left-0 right-0 z-10',
'flex items-center justify-center',
'h-16 bg-black/50 backdrop-blur-sm',
'transform transition-transform duration-200',
pullDistance > 0 ? 'translate-y-0' : '-translate-y-full'
)}
>
<div className="flex items-center space-x-2">
{isRefreshing ? (
<>
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
<span className="text-sm text-white/70">Refreshing...</span>
</>
) : (
<>
<div
className={cn(
'w-4 h-4 rounded-full border-2 border-white/30 flex items-center justify-center transition-all',
isPulling ? 'border-white scale-110' : ''
)}
>
<div className={cn(
'w-1 h-1 bg-white rounded-full transition-opacity',
isPulling ? 'opacity-100' : 'opacity-0'
)} />
</div>
<span className={cn(
'text-sm transition-colors',
isPulling ? 'text-white' : 'text-white/50'
)}>
{isPulling ? 'Release to refresh' : 'Pull to refresh'}
</span>
</>
)}
</div>
</div>
)}
{/* Content with top padding for pull indicator */}
<div className={enablePullToRefresh ? 'pt-4' : ''}>
{children}
</div>
</main>
{/* Footer (optional) */}
@@ -133,16 +303,68 @@ export function MobileLayout({
</footer>
)}
{/* Bottom Navigation (optional) */}
{bottomNav && (
{/* Native-style Bottom Navigation */}
{showBottomNav && bottomNavItems.length > 0 && (
<nav
className={cn(
'glass-nav',
'fixed bottom-0 left-0 right-0 z-50',
'bg-black/90 backdrop-blur-xl',
'h-16 min-h-[64px]', // iOS/Android standard height
'flex items-center justify-around px-2',
'border-t border-white/10',
'safe-area-inset-bottom', // iOS home indicator support
'pb-safe-bottom' // Additional safe area padding
)}
>
{bottomNavItems.map((item) => (
<button
key={item.id}
onClick={() => onNavItemPress?.(item.id)}
className={cn(
'flex flex-col items-center justify-center',
'min-w-[60px] h-12 rounded-lg',
'transition-all duration-200',
'relative',
activeNavItem === item.id
? 'text-white'
: 'text-white/50 hover:text-white/80'
)}
>
<div className={cn(
'relative mb-1',
activeNavItem === item.id ? 'scale-110' : ''
)}>
{item.icon}
{item.badge && item.badge > 0 && (
<div className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full min-w-[18px] h-[18px] flex items-center justify-center px-1">
{item.badge > 99 ? '99+' : item.badge}
</div>
)}
</div>
<span className={cn(
'text-xs font-medium transition-all',
activeNavItem === item.id ? 'opacity-100' : 'opacity-70'
)}>
{item.label}
</span>
{activeNavItem === item.id && (
<div className="absolute bottom-0 left-1/2 transform -translate-x-1/2 w-6 h-0.5 bg-white rounded-full" />
)}
</button>
))}
</nav>
)}
{/* Custom Bottom Navigation (legacy support) */}
{bottomNav && !showBottomNav && (
<nav
className={cn(
'bg-black/80 backdrop-blur-xl',
'sticky bottom-0 z-50',
'h-16 min-h-[64px]', // Touch-friendly height
'h-16 min-h-[64px]',
'flex items-center justify-around px-4',
'border-t border-glass-subtle',
'safe-area-inset-bottom' // iOS home indicator support
'border-t border-white/10',
'safe-area-inset-bottom'
)}
>
{bottomNav}

View File

@@ -288,6 +288,16 @@
--sidebar-width: 280px;
/* Conversation history sidebar */
/* Mobile Layout / Layout Mobile */
--mobile-header-height: 56px;
/* Standard mobile header height */
--mobile-bottom-nav-height: 64px;
/* iOS/Android bottom nav height */
--mobile-safe-area-top: env(safe-area-inset-top);
/* iOS notch safe area */
--mobile-safe-area-bottom: env(safe-area-inset-bottom);
/* iOS home indicator safe area */
/* Border Radius / Bo góc */
--radius-sm: 2px;
/* Small elements - sharp */

View File

@@ -239,6 +239,16 @@ module.exports = {
'container-2xl': 'var(--container-2xl)',
'chat-max': 'var(--chat-max-width)',
},
// EN: Safe area utilities for mobile
// VI: Utilities safe area cho mobile
padding: {
'safe-top': 'var(--mobile-safe-area-top)',
'safe-bottom': 'var(--mobile-safe-area-bottom)',
},
margin: {
'safe-top': 'var(--mobile-safe-area-top)',
'safe-bottom': 'var(--mobile-safe-area-bottom)',
},
// EN: Screen breakpoints (matching CSS variables)
// VI: Điểm ngắt màn hình (khớp với CSS variables)
screens: {
@@ -279,5 +289,24 @@ module.exports = {
};
addUtilities(glassUtilities);
},
// Mobile safe area utilities
function({ addUtilities }) {
const mobileUtilities = {
'.safe-area-inset-top': {
'padding-top': 'var(--mobile-safe-area-top)',
},
'.safe-area-inset-bottom': {
'padding-bottom': 'var(--mobile-safe-area-bottom)',
},
'.pb-safe-bottom': {
'padding-bottom': 'calc(var(--mobile-bottom-nav-height) + var(--mobile-safe-area-bottom))',
},
'.mobile-layout': {
'min-height': '100vh',
'min-height': '100dvh', // Dynamic viewport height for mobile
},
};
addUtilities(mobileUtilities);
},
],
};