305 lines
11 KiB
TypeScript
305 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import * as React from 'react';
|
|
import { cn } from '@/shared/lib/utils';
|
|
import { Menu, X } from 'lucide-react';
|
|
import { useTranslations } from 'next-intl';
|
|
|
|
/**
|
|
* EN: Chat layout component props interface
|
|
* VI: Interface cho props của component Chat layout
|
|
*/
|
|
export interface ChatLayoutProps {
|
|
/**
|
|
* EN: Left sidebar content (conversation list, search, etc.)
|
|
* VI: Nội dung sidebar trái (danh sách cuộc trò chuyện, tìm kiếm, etc.)
|
|
*/
|
|
sidebar?: React.ReactNode;
|
|
/**
|
|
* EN: Main chat area content (messages, header, input)
|
|
* VI: Nội dung khu vực chat chính (tin nhắn, header, input)
|
|
*/
|
|
children: React.ReactNode;
|
|
/**
|
|
* EN: Right panel content (settings, participants, etc.) - optional
|
|
* VI: Nội dung panel bên phải (cài đặt, người tham gia, etc.) - tùy chọn
|
|
*/
|
|
rightPanel?: React.ReactNode;
|
|
/**
|
|
* EN: Whether the sidebar is visible (for mobile responsiveness)
|
|
* VI: Sidebar có hiển thị hay không (cho responsive mobile)
|
|
*/
|
|
sidebarVisible?: boolean;
|
|
/**
|
|
* EN: Whether the right panel is visible
|
|
* VI: Panel bên phải có hiển thị hay không
|
|
*/
|
|
rightPanelVisible?: boolean;
|
|
/**
|
|
* EN: Callback when sidebar visibility changes
|
|
* VI: Callback khi trạng thái hiển thị sidebar thay đổi
|
|
*/
|
|
onSidebarToggle?: (visible: boolean) => void;
|
|
/**
|
|
* EN: Callback when right panel visibility changes
|
|
* VI: Callback khi trạng thái hiển thị panel bên phải thay đổi
|
|
*/
|
|
onRightPanelToggle?: (visible: boolean) => void;
|
|
/**
|
|
* EN: Additional CSS classes
|
|
* VI: Các class CSS bổ sung
|
|
*/
|
|
className?: string;
|
|
}
|
|
|
|
/**
|
|
* EN: Chat layout component - Main layout structure for chat interface
|
|
* VI: Component Chat layout - Cấu trúc layout chính cho giao diện chat
|
|
*
|
|
* Layout structure:
|
|
* - Left Sidebar (280px): Conversation list, search, user profile
|
|
* - Main Chat Area (flex-1, max 768px centered): Messages, header, input
|
|
* - Right Panel (320px, optional): Settings, participants, shared files
|
|
*
|
|
* Responsive behavior:
|
|
* - Mobile (< 768px): Hide sidebar by default, full-width messages
|
|
* - Tablet (768px - 1024px): Sidebar + Main (two columns)
|
|
* - Desktop (> 1024px): Sidebar + Main + Right Panel (three columns)
|
|
*
|
|
* Cấu trúc layout:
|
|
* - Sidebar trái (280px): Danh sách cuộc trò chuyện, tìm kiếm, profile người dùng
|
|
* - Khu vực chat chính (flex-1, tối đa 768px căn giữa): Tin nhắn, header, input
|
|
* - Panel bên phải (320px, tùy chọn): Cài đặt, người tham gia, file đã chia sẻ
|
|
*
|
|
* Hành vi responsive:
|
|
* - Mobile (< 768px): Ẩn sidebar mặc định, tin nhắn full-width
|
|
* - Tablet (768px - 1024px): Sidebar + Main (hai cột)
|
|
* - Desktop (> 1024px): Sidebar + Main + Right Panel (ba cột)
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* <ChatLayout
|
|
* sidebar={<ConversationSidebar />}
|
|
* rightPanel={<ConversationSettings />}
|
|
* >
|
|
* <ChatMessages />
|
|
* </ChatLayout>
|
|
* ```
|
|
*/
|
|
export function ChatLayout({
|
|
sidebar,
|
|
children,
|
|
rightPanel,
|
|
sidebarVisible = true,
|
|
rightPanelVisible = false,
|
|
onSidebarToggle,
|
|
onRightPanelToggle: _onRightPanelToggle,
|
|
className,
|
|
}: ChatLayoutProps) {
|
|
// EN: Translation hook / VI: Hook translation
|
|
const t = useTranslations();
|
|
|
|
// EN: Mobile: Hide sidebar by default / VI: Mobile: Ẩn sidebar mặc định
|
|
const [mobileSidebarVisible, setMobileSidebarVisible] = React.useState(false);
|
|
const [touchStart, setTouchStart] = React.useState<number | null>(null);
|
|
const [touchEnd, setTouchEnd] = React.useState<number | null>(null);
|
|
|
|
// EN: Minimum swipe distance (px) / VI: Khoảng cách swipe tối thiểu (px)
|
|
const minSwipeDistance = 50;
|
|
|
|
// EN: Handle touch start / VI: Xử lý bắt đầu chạm
|
|
const onTouchStart = (e: React.TouchEvent) => {
|
|
setTouchEnd(null);
|
|
setTouchStart(e.targetTouches[0].clientX);
|
|
};
|
|
|
|
// EN: Handle touch move / VI: Xử lý di chuyển chạm
|
|
const onTouchMove = (e: React.TouchEvent) => {
|
|
setTouchEnd(e.targetTouches[0].clientX);
|
|
};
|
|
|
|
// EN: Handle touch end / VI: Xử lý kết thúc chạm
|
|
const onTouchEnd = () => {
|
|
if (!touchStart || !touchEnd) return;
|
|
const distance = touchStart - touchEnd;
|
|
const isLeftSwipe = distance > minSwipeDistance;
|
|
const isRightSwipe = distance < -minSwipeDistance;
|
|
|
|
// EN: Swipe right to open sidebar / VI: Vuốt phải để mở sidebar
|
|
if (isRightSwipe && !mobileSidebarVisible) {
|
|
setMobileSidebarVisible(true);
|
|
onSidebarToggle?.(true);
|
|
}
|
|
// EN: Swipe left to close sidebar / VI: Vuốt trái để đóng sidebar
|
|
if (isLeftSwipe && mobileSidebarVisible) {
|
|
setMobileSidebarVisible(false);
|
|
onSidebarToggle?.(false);
|
|
}
|
|
};
|
|
|
|
// EN: Sync mobile sidebar state with prop / VI: Đồng bộ state sidebar mobile với prop
|
|
React.useEffect(() => {
|
|
if (typeof window !== 'undefined' && window.innerWidth < 768) {
|
|
setMobileSidebarVisible(sidebarVisible);
|
|
}
|
|
}, [sidebarVisible]);
|
|
|
|
// EN: Tablet: Show sidebar by default, but toggleable / VI: Tablet: Hiện sidebar mặc định, nhưng có thể toggle
|
|
const [tabletSidebarVisible, setTabletSidebarVisible] = React.useState(true);
|
|
|
|
// EN: Determine if sidebar should be visible / VI: Xác định sidebar có nên hiển thị không
|
|
const getSidebarVisibility = () => {
|
|
if (typeof window === 'undefined') return sidebarVisible;
|
|
const width = window.innerWidth;
|
|
if (width < 768) {
|
|
// EN: Mobile: Use mobile state / VI: Mobile: Dùng state mobile
|
|
return mobileSidebarVisible;
|
|
} else if (width >= 768 && width < 1024) {
|
|
// EN: Tablet: Use tablet state / VI: Tablet: Dùng state tablet
|
|
return tabletSidebarVisible;
|
|
}
|
|
// EN: Desktop: Use prop / VI: Desktop: Dùng prop
|
|
return sidebarVisible;
|
|
};
|
|
|
|
const isSidebarVisible = getSidebarVisibility();
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
// EN: Base layout container / VI: Container layout cơ bản
|
|
'flex h-screen w-full overflow-hidden bg-bg-primary',
|
|
className
|
|
)}
|
|
onTouchStart={onTouchStart}
|
|
onTouchMove={onTouchMove}
|
|
onTouchEnd={onTouchEnd}
|
|
>
|
|
{/* EN: Mobile/Tablet menu button / VI: Nút menu mobile/tablet */}
|
|
{sidebar && (
|
|
<button
|
|
onClick={() => {
|
|
const newVisible = !isSidebarVisible;
|
|
if (typeof window !== 'undefined') {
|
|
const width = window.innerWidth;
|
|
if (width < 768) {
|
|
setMobileSidebarVisible(newVisible);
|
|
} else if (width >= 768 && width < 1024) {
|
|
setTabletSidebarVisible(newVisible);
|
|
}
|
|
}
|
|
onSidebarToggle?.(newVisible);
|
|
}}
|
|
className={cn(
|
|
'lg:hidden',
|
|
'fixed top-4 left-4 z-50',
|
|
'p-2 rounded-lg',
|
|
'bg-bg-elevated border border-border-primary',
|
|
'text-text-primary hover:bg-bg-tertiary',
|
|
'transition-colors duration-[150ms]',
|
|
'shadow-lg',
|
|
'min-w-[44px] min-h-[44px]'
|
|
)}
|
|
aria-label={isSidebarVisible ? t('chat.closeSidebar', { defaultValue: 'Close sidebar' }) : t('chat.openSidebar', { defaultValue: 'Open sidebar' })}
|
|
>
|
|
{isSidebarVisible ? (
|
|
<X className="h-5 w-5" />
|
|
) : (
|
|
<Menu className="h-5 w-5" />
|
|
)}
|
|
</button>
|
|
)}
|
|
|
|
{/* EN: Left Sidebar / VI: Sidebar trái */}
|
|
{sidebar && (
|
|
<aside
|
|
className={cn(
|
|
// EN: Base sidebar styles / VI: Style sidebar cơ bản
|
|
'flex flex-col bg-bg-secondary border-r border-border-primary transition-all duration-[250ms] ease-out',
|
|
// EN: Desktop: Fixed width 280px / VI: Desktop: Chiều rộng cố định 280px
|
|
'w-sidebar flex-shrink-0',
|
|
// EN: Mobile: Hide by default, show when sidebarVisible / VI: Mobile: Ẩn mặc định, hiện khi sidebarVisible
|
|
'max-md:fixed max-md:inset-y-0 max-md:left-0 max-md:z-40',
|
|
'max-md:transform max-md:transition-transform',
|
|
isSidebarVisible ? 'max-md:translate-x-0' : 'max-md:-translate-x-full',
|
|
// EN: Tablet: Show by default, toggleable / VI: Tablet: Hiện mặc định, có thể toggle
|
|
'md:block lg:block',
|
|
!isSidebarVisible && 'md:hidden'
|
|
)}
|
|
aria-label={t('chat.conversationSidebar', { defaultValue: 'Conversation sidebar' })}
|
|
>
|
|
{sidebar}
|
|
</aside>
|
|
)}
|
|
|
|
{/* EN: Main Chat Area / VI: Khu vực chat chính */}
|
|
<main
|
|
id="main-content"
|
|
className={cn(
|
|
// EN: Base main area styles / VI: Style khu vực chính cơ bản
|
|
'flex flex-col flex-1 overflow-hidden',
|
|
// EN: Center content with max-width constraint on desktop / VI: Căn giữa nội dung với giới hạn chiều rộng tối đa trên desktop
|
|
'md:items-center',
|
|
// EN: Mobile: Full width / VI: Mobile: Full width
|
|
'w-full'
|
|
)}
|
|
role="main"
|
|
aria-label={t('chat.mainChatArea', { defaultValue: 'Main chat area' })}
|
|
>
|
|
<div
|
|
className={cn(
|
|
// EN: Content container with max-width on desktop / VI: Container nội dung với chiều rộng tối đa trên desktop
|
|
'w-full flex flex-col h-full',
|
|
// EN: Mobile: Full width / VI: Mobile: Full width
|
|
'max-md:max-w-none',
|
|
// EN: Tablet: Medium width (60%) / VI: Tablet: Chiều rộng trung bình (60%)
|
|
'md:max-w-[60%] md:mx-auto',
|
|
// EN: Desktop: Max width 768px centered / VI: Desktop: Chiều rộng tối đa 768px căn giữa
|
|
'lg:max-w-chat-max lg:mx-auto'
|
|
)}
|
|
>
|
|
{children}
|
|
</div>
|
|
</main>
|
|
|
|
{/* EN: Right Panel / VI: Panel bên phải */}
|
|
{rightPanel && (
|
|
<aside
|
|
className={cn(
|
|
// EN: Base right panel styles / VI: Style panel bên phải cơ bản
|
|
'flex flex-col bg-bg-secondary border-l border-border-primary transition-all duration-[250ms] ease-out',
|
|
// EN: Desktop: Fixed width 320px, only show on large screens / VI: Desktop: Chiều rộng cố định 320px, chỉ hiện trên màn hình lớn
|
|
'w-80 flex-shrink-0',
|
|
// EN: Hide on small/medium screens / VI: Ẩn trên màn hình nhỏ/trung bình
|
|
'max-lg:hidden',
|
|
// EN: Show/hide based on rightPanelVisible prop / VI: Hiện/ẩn dựa trên prop rightPanelVisible
|
|
rightPanelVisible ? 'lg:flex' : 'lg:hidden'
|
|
)}
|
|
aria-label={t('chat.conversationSettingsPanel', { defaultValue: 'Conversation settings panel' })}
|
|
>
|
|
{rightPanel}
|
|
</aside>
|
|
)}
|
|
|
|
{/* EN: Mobile overlay when sidebar is visible / VI: Overlay mobile khi sidebar hiển thị */}
|
|
{sidebar && isSidebarVisible && (
|
|
<div
|
|
className={cn(
|
|
// EN: Overlay for mobile sidebar / VI: Overlay cho sidebar mobile
|
|
'fixed inset-0 bg-black/50 z-30',
|
|
// EN: Only show on mobile, not tablet / VI: Chỉ hiện trên mobile, không phải tablet
|
|
'md:hidden'
|
|
)}
|
|
onClick={() => {
|
|
if (typeof window !== 'undefined' && window.innerWidth < 768) {
|
|
setMobileSidebarVisible(false);
|
|
onSidebarToggle?.(false);
|
|
}
|
|
}}
|
|
aria-hidden="true"
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|