Update dependencies and enhance Tailwind CSS configuration for web applications
- Added new dependencies including clsx, lucide-react, recharts, and various Radix UI components to improve UI functionality. - Upgraded Tailwind CSS to version 4.0.0 and updated configuration to utilize CSS variables for theming and responsive design. - Introduced global styles and improved accessibility features in the layout and components. - Removed outdated login page and refactored authentication store for better state management. - Enhanced API service with additional authentication methods and improved error handling. These changes aim to modernize the web applications and improve user experience through better design and functionality.
This commit is contained in:
149
apps/web-admin/src/app/(dashboard)/dashboard/page.tsx
Normal file
149
apps/web-admin/src/app/(dashboard)/dashboard/page.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { AnalyticsCard } from '@/components/admin/analytics-card';
|
||||
import { RecentActivityTable, type RecentActivity } from '@/components/admin/recent-activity-table';
|
||||
// EN: Lazy load heavy chart components / VI: Lazy load các component chart nặng
|
||||
const UserGrowthChart = React.lazy(() => import('@/components/admin/charts/user-growth-chart').then(m => ({ default: m.UserGrowthChart })));
|
||||
const RevenueChart = React.lazy(() => import('@/components/admin/charts/revenue-chart').then(m => ({ default: m.RevenueChart })));
|
||||
import { Users, MessageSquare, TrendingUp, DollarSign } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* EN: Dashboard overview page component
|
||||
* VI: Component trang tổng quan Dashboard
|
||||
*
|
||||
* Features:
|
||||
* - Metrics row with key statistics
|
||||
* - Charts row (User Growth, Revenue)
|
||||
* - Recent activity table
|
||||
*
|
||||
* Tính năng:
|
||||
* - Hàng metrics với các thống kê chính
|
||||
* - Hàng charts (Tăng trưởng người dùng, Doanh thu)
|
||||
* - Bảng hoạt động gần đây
|
||||
*/
|
||||
export default function DashboardPage() {
|
||||
// EN: Mock data - replace with actual API calls / VI: Dữ liệu mock - thay thế bằng API calls thực tế
|
||||
const [activities] = React.useState<RecentActivity[]>([
|
||||
{
|
||||
id: '1',
|
||||
user: {
|
||||
id: 'user1',
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
},
|
||||
action: 'user_created',
|
||||
description: 'Created new user account',
|
||||
status: 'success',
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 5), // 5 minutes ago
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
user: {
|
||||
id: 'user2',
|
||||
name: 'Jane Smith',
|
||||
email: 'jane@example.com',
|
||||
},
|
||||
action: 'message_sent',
|
||||
description: 'Sent message in conversation',
|
||||
status: 'info',
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 15), // 15 minutes ago
|
||||
},
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* EN: Page header / VI: Header trang */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-text-primary">
|
||||
Dashboard / Bảng điều khiển
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-text-tertiary">
|
||||
Overview of your platform metrics and activities / Tổng quan về các metric và hoạt động của nền tảng
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* EN: Metrics row / VI: Hàng metrics */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<AnalyticsCard
|
||||
title="Total Users / Tổng người dùng"
|
||||
value={12543}
|
||||
change="+12.5%"
|
||||
trend="up"
|
||||
icon={Users}
|
||||
variant="metric"
|
||||
/>
|
||||
<AnalyticsCard
|
||||
title="Messages / Tin nhắn"
|
||||
value={89234}
|
||||
change="+8.2%"
|
||||
trend="up"
|
||||
icon={MessageSquare}
|
||||
variant="metric"
|
||||
/>
|
||||
<AnalyticsCard
|
||||
title="Active Users / Người dùng hoạt động"
|
||||
value={3421}
|
||||
change="+5.1%"
|
||||
trend="up"
|
||||
icon={TrendingUp}
|
||||
variant="metric"
|
||||
/>
|
||||
<AnalyticsCard
|
||||
title="Revenue / Doanh thu"
|
||||
value="$45,231"
|
||||
change="-2.3%"
|
||||
trend="down"
|
||||
icon={DollarSign}
|
||||
variant="metric"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* EN: Charts row / VI: Hàng charts */}
|
||||
{/* EN: Tablet: Side-by-side charts / VI: Tablet: Charts cạnh nhau */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 sm:gap-6 lg:grid-cols-2">
|
||||
{/* EN: User Growth Chart / VI: User Growth Chart */}
|
||||
<React.Suspense fallback={<div className="h-[300px] bg-bg-secondary rounded-lg border border-border-primary flex items-center justify-center"><div className="text-text-tertiary">Loading chart... / Đang tải chart...</div></div>}>
|
||||
<UserGrowthChart
|
||||
data={[
|
||||
{ date: 'Jan', users: 1000, newUsers: 150 },
|
||||
{ date: 'Feb', users: 1200, newUsers: 200 },
|
||||
{ date: 'Mar', users: 1450, newUsers: 250 },
|
||||
{ date: 'Apr', users: 1700, newUsers: 250 },
|
||||
{ date: 'May', users: 1950, newUsers: 250 },
|
||||
{ date: 'Jun', users: 2200, newUsers: 250 },
|
||||
]}
|
||||
title="User Growth / Tăng trưởng người dùng"
|
||||
description="Monthly user growth trend / Xu hướng tăng trưởng người dùng hàng tháng"
|
||||
/>
|
||||
</React.Suspense>
|
||||
|
||||
{/* EN: Revenue Chart / VI: Revenue Chart */}
|
||||
<React.Suspense fallback={<div className="h-[300px] bg-bg-secondary rounded-lg border border-border-primary flex items-center justify-center"><div className="text-text-tertiary">Loading chart... / Đang tải chart...</div></div>}>
|
||||
<RevenueChart
|
||||
data={[
|
||||
{ date: 'Jan', revenue: 45000, previousRevenue: 40000 },
|
||||
{ date: 'Feb', revenue: 52000, previousRevenue: 45000 },
|
||||
{ date: 'Mar', revenue: 48000, previousRevenue: 52000 },
|
||||
{ date: 'Apr', revenue: 55000, previousRevenue: 48000 },
|
||||
{ date: 'May', revenue: 60000, previousRevenue: 55000 },
|
||||
{ date: 'Jun', revenue: 65000, previousRevenue: 60000 },
|
||||
]}
|
||||
title="Revenue / Doanh thu"
|
||||
description="Monthly revenue trend / Xu hướng doanh thu hàng tháng"
|
||||
currency="$"
|
||||
/>
|
||||
</React.Suspense>
|
||||
</div>
|
||||
|
||||
{/* EN: Recent Activity Table / VI: Bảng hoạt động gần đây */}
|
||||
<RecentActivityTable
|
||||
activities={activities}
|
||||
currentPage={1}
|
||||
itemsPerPage={10}
|
||||
onPageChange={(page) => console.log('Page changed:', page)}
|
||||
onQuickAction={(id, action) => console.log('Action:', id, action)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
217
apps/web-admin/src/app/(dashboard)/layout.tsx
Normal file
217
apps/web-admin/src/app/(dashboard)/layout.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
BarChart3,
|
||||
MessageSquare,
|
||||
Settings,
|
||||
LogOut,
|
||||
Menu,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
|
||||
/**
|
||||
* EN: Admin navigation items configuration
|
||||
* VI: Cấu hình các mục điều hướng Admin
|
||||
*/
|
||||
const adminNavItems = [
|
||||
{
|
||||
id: 'dashboard',
|
||||
label: 'Dashboard / Bảng điều khiển',
|
||||
href: '/dashboard',
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
{
|
||||
id: 'users',
|
||||
label: 'Users / Người dùng',
|
||||
href: '/dashboard/users',
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
id: 'analytics',
|
||||
label: 'Analytics / Phân tích',
|
||||
href: '/dashboard/analytics',
|
||||
icon: BarChart3,
|
||||
},
|
||||
{
|
||||
id: 'messages',
|
||||
label: 'Messages / Tin nhắn',
|
||||
href: '/dashboard/messages',
|
||||
icon: MessageSquare,
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
label: 'Settings / Cài đặt',
|
||||
href: '/dashboard/settings',
|
||||
icon: Settings,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* EN: Admin Dashboard layout component with sidebar navigation
|
||||
* VI: Component layout Admin Dashboard với điều hướng sidebar
|
||||
*
|
||||
* Features:
|
||||
* - Sidebar navigation with icons
|
||||
* - Responsive design (collapsible on mobile)
|
||||
* - User profile section
|
||||
* - Logout functionality
|
||||
* - Active route highlighting
|
||||
*
|
||||
* Tính năng:
|
||||
* - Điều hướng sidebar với icons
|
||||
* - Responsive design (có thể thu gọn trên mobile)
|
||||
* - Phần profile người dùng
|
||||
* - Chức năng logout
|
||||
* - Highlight route đang active
|
||||
*/
|
||||
export default function AdminDashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const { user, logout } = useAuthStore();
|
||||
const [sidebarOpen, setSidebarOpen] = React.useState(false);
|
||||
|
||||
/**
|
||||
* EN: Handle logout
|
||||
* VI: Xử lý logout
|
||||
*/
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
window.location.href = '/login';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full overflow-hidden bg-bg-primary">
|
||||
{/* EN: Mobile sidebar overlay / VI: Overlay sidebar mobile */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40 md:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* EN: Sidebar / VI: 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 / VI: Desktop: Chiều rộng cố định
|
||||
'w-64 flex-shrink-0',
|
||||
// EN: Mobile: Fixed position, slide in/out / VI: Mobile: Vị trí cố định, trượt vào/ra
|
||||
'max-md:fixed max-md:inset-y-0 max-md:left-0 max-md:z-50',
|
||||
'max-md:transform max-md:transition-transform',
|
||||
sidebarOpen ? 'max-md:translate-x-0' : 'max-md:-translate-x-full'
|
||||
)}
|
||||
aria-label="Admin sidebar / Sidebar Admin"
|
||||
>
|
||||
{/* EN: Sidebar header with mobile menu button / VI: Header sidebar với nút menu mobile */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border-primary">
|
||||
<h1 className="text-xl font-semibold text-text-primary">
|
||||
GoodGo Admin / Quản trị GoodGo
|
||||
</h1>
|
||||
<button
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className="md:hidden p-2 rounded-md hover:bg-bg-tertiary transition-colors"
|
||||
aria-label="Close sidebar / Đóng sidebar"
|
||||
>
|
||||
<X className="h-5 w-5 text-text-secondary" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* EN: Navigation menu / VI: Menu điều hướng */}
|
||||
<nav className="flex-1 overflow-y-auto p-4 space-y-1">
|
||||
{adminNavItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = pathname === item.href || pathname.startsWith(item.href + '/');
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={item.href}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={cn(
|
||||
// EN: Base nav item styles / VI: Style nav item cơ bản
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-[150ms]',
|
||||
// EN: Active state / VI: Trạng thái active
|
||||
isActive
|
||||
? 'bg-accent-primary text-white shadow-md'
|
||||
: 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary',
|
||||
)}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
'h-5 w-5 flex-shrink-0',
|
||||
isActive ? 'text-white' : 'text-text-tertiary',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* EN: User profile section / VI: Phần profile người dùng */}
|
||||
{user && (
|
||||
<div className="p-4 border-t border-border-primary">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="h-10 w-10 rounded-full bg-chat-ai-bubble flex items-center justify-center">
|
||||
<span className="text-sm font-medium text-text-primary">
|
||||
{user.email?.charAt(0).toUpperCase() || 'A'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-text-primary truncate">
|
||||
{user.email}
|
||||
</p>
|
||||
<p className="text-xs text-text-tertiary truncate">
|
||||
{user.role || 'Admin / Quản trị viên'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-text-secondary hover:bg-bg-tertiary hover:text-text-primary transition-all duration-[150ms]"
|
||||
aria-label="Logout / Đăng xuất"
|
||||
>
|
||||
<LogOut className="h-5 w-5 flex-shrink-0" aria-hidden="true" />
|
||||
<span>Logout / Đăng xuất</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{/* EN: Main content area / VI: Khu vực nội dung chính */}
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
{/* EN: Mobile header with menu button / VI: Header mobile với nút menu */}
|
||||
<header className="sticky top-0 z-30 flex items-center justify-between p-4 bg-bg-secondary border-b border-border-primary md:hidden">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="p-2 rounded-md hover:bg-bg-tertiary transition-colors"
|
||||
aria-label="Open sidebar / Mở sidebar"
|
||||
>
|
||||
<Menu className="h-5 w-5 text-text-secondary" />
|
||||
</button>
|
||||
<h1 className="text-lg font-semibold text-text-primary">
|
||||
GoodGo Admin / Quản trị GoodGo
|
||||
</h1>
|
||||
<div className="w-9" /> {/* EN: Spacer for centering / VI: Khoảng trống để căn giữa */}
|
||||
</header>
|
||||
|
||||
{/* EN: Page content / VI: Nội dung trang */}
|
||||
<div className="p-6">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
apps/web-admin/src/app/(dashboard)/settings/page.tsx
Normal file
103
apps/web-admin/src/app/(dashboard)/settings/page.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
// EN: Lazy load settings forms / VI: Lazy load các form cài đặt
|
||||
const GeneralSettings = React.lazy(() => import('@/components/admin/settings/general-settings').then(m => ({ default: m.GeneralSettings })));
|
||||
const EmailSettings = React.lazy(() => import('@/components/admin/settings/email-settings').then(m => ({ default: m.EmailSettings })));
|
||||
const SecuritySettings = React.lazy(() => import('@/components/admin/settings/security-settings').then(m => ({ default: m.SecuritySettings })));
|
||||
|
||||
/**
|
||||
* EN: System Settings page component
|
||||
* VI: Component trang cài đặt hệ thống
|
||||
*
|
||||
* Features:
|
||||
* - Tab navigation (General, Email, Security, API, Advanced)
|
||||
* - Settings forms for each category
|
||||
*
|
||||
* Tính năng:
|
||||
* - Điều hướng tab (General, Email, Security, API, Advanced)
|
||||
* - Forms cài đặt cho mỗi danh mục
|
||||
*/
|
||||
export default function SystemSettingsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* EN: Page header / VI: Header trang */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-text-primary">
|
||||
System Settings / Cài đặt hệ thống
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-text-tertiary">
|
||||
Configure system-wide settings and preferences / Cấu hình cài đặt và tùy chọn toàn hệ thống
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* EN: Settings tabs / VI: Tabs cài đặt */}
|
||||
<Tabs defaultValue="general" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-5">
|
||||
<TabsTrigger value="general">General / Chung</TabsTrigger>
|
||||
<TabsTrigger value="email">Email / Email</TabsTrigger>
|
||||
<TabsTrigger value="security">Security / Bảo mật</TabsTrigger>
|
||||
<TabsTrigger value="api">API / API</TabsTrigger>
|
||||
<TabsTrigger value="advanced">Advanced / Nâng cao</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* EN: General settings tab / VI: Tab cài đặt chung */}
|
||||
<TabsContent value="general" className="space-y-6">
|
||||
<React.Suspense fallback={<div className="p-8 text-center"><div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-accent-primary border-r-transparent" /><p className="mt-4 text-sm text-text-tertiary">Loading... / Đang tải...</p></div>}>
|
||||
<GeneralSettings />
|
||||
</React.Suspense>
|
||||
</TabsContent>
|
||||
|
||||
{/* EN: Email settings tab / VI: Tab cài đặt email */}
|
||||
<TabsContent value="email" className="space-y-6">
|
||||
<React.Suspense fallback={<div className="p-8 text-center"><div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-accent-primary border-r-transparent" /><p className="mt-4 text-sm text-text-tertiary">Loading... / Đang tải...</p></div>}>
|
||||
<EmailSettings />
|
||||
</React.Suspense>
|
||||
</TabsContent>
|
||||
|
||||
{/* EN: Security settings tab / VI: Tab cài đặt bảo mật */}
|
||||
<TabsContent value="security" className="space-y-6">
|
||||
<React.Suspense fallback={<div className="p-8 text-center"><div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-accent-primary border-r-transparent" /><p className="mt-4 text-sm text-text-tertiary">Loading... / Đang tải...</p></div>}>
|
||||
<SecuritySettings />
|
||||
</React.Suspense>
|
||||
</TabsContent>
|
||||
|
||||
{/* EN: API settings tab / VI: Tab cài đặt API */}
|
||||
<TabsContent value="api" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>API Settings / Cài đặt API</CardTitle>
|
||||
<CardDescription>
|
||||
Configure API settings and webhooks / Cấu hình cài đặt API và webhooks
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-text-tertiary">
|
||||
API settings form will be implemented here / Form cài đặt API sẽ được implement ở đây
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* EN: Advanced settings tab / VI: Tab cài đặt nâng cao */}
|
||||
<TabsContent value="advanced" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Advanced Settings / Cài đặt nâng cao</CardTitle>
|
||||
<CardDescription>
|
||||
Advanced system configuration / Cấu hình hệ thống nâng cao
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-text-tertiary">
|
||||
Advanced settings form will be implemented here / Form cài đặt nâng cao sẽ được implement ở đây
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
apps/web-admin/src/app/(dashboard)/users/page.tsx
Normal file
139
apps/web-admin/src/app/(dashboard)/users/page.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { DataTable, type DataTableColumn } from '@/components/admin/data-table';
|
||||
// EN: Lazy load modal component / VI: Lazy load component modal
|
||||
const UserDetailsModal = React.lazy(() => import('@/components/admin/user-details-modal').then(m => ({ default: m.UserDetailsModal })));
|
||||
import type { User } from '@/components/admin/user-details-modal';
|
||||
import { Search, Filter, Download, MoreVertical } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* EN: User Management page component
|
||||
* VI: Component trang quản lý người dùng
|
||||
*
|
||||
* Features:
|
||||
* - Search bar
|
||||
* - Filter dropdowns
|
||||
* - User list table (will use DataTable component)
|
||||
* - Export functionality
|
||||
*
|
||||
* Tính năng:
|
||||
* - Thanh tìm kiếm
|
||||
* - Dropdowns lọc
|
||||
* - Bảng danh sách người dùng (sẽ sử dụng component DataTable)
|
||||
* - Chức năng export
|
||||
*/
|
||||
export default function UserManagementPage() {
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
const [selectedRole, setSelectedRole] = React.useState<string>('all');
|
||||
const [selectedStatus, setSelectedStatus] = React.useState<string>('all');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* EN: Page header / VI: Header trang */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-text-primary">
|
||||
User Management / Quản lý người dùng
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-text-tertiary">
|
||||
Manage users, roles, and permissions / Quản lý người dùng, vai trò và quyền
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* EN: Search and filters card / VI: Card tìm kiếm và lọc */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Search & Filters / Tìm kiếm & Lọc</CardTitle>
|
||||
<CardDescription>
|
||||
Find and filter users by name, email, role, or status / Tìm và lọc người dùng theo tên, email, vai trò hoặc trạng thái
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{/* EN: Search input / VI: Input tìm kiếm */}
|
||||
<div className="md:col-span-2">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search by name or email... / Tìm kiếm theo tên hoặc email..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* EN: Filter buttons / VI: Nút lọc */}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" size="md" className="flex-1">
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
Filters / Lọc
|
||||
</Button>
|
||||
<Button variant="secondary" size="md">
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* EN: User list table / VI: Bảng danh sách người dùng */}
|
||||
<DataTable
|
||||
data={users}
|
||||
columns={columns}
|
||||
currentPage={currentPage}
|
||||
itemsPerPage={10}
|
||||
onPageChange={setCurrentPage}
|
||||
selectable
|
||||
selectedRows={selectedRows}
|
||||
onSelectionChange={setSelectedRows}
|
||||
getRowId={(row) => row.id}
|
||||
bulkActions={[
|
||||
{
|
||||
label: 'Activate / Kích hoạt',
|
||||
action: handleBulkActivate,
|
||||
variant: 'primary',
|
||||
},
|
||||
{
|
||||
label: 'Deactivate / Vô hiệu hóa',
|
||||
action: handleBulkDeactivate,
|
||||
variant: 'secondary',
|
||||
},
|
||||
{
|
||||
label: 'Delete / Xóa',
|
||||
action: handleBulkDelete,
|
||||
variant: 'danger',
|
||||
},
|
||||
]}
|
||||
exportable
|
||||
onExport={() => console.log('Export users')}
|
||||
/>
|
||||
|
||||
{/* EN: User details modal / VI: Modal chi tiết người dùng */}
|
||||
{selectedUser && (
|
||||
<React.Suspense fallback={null}>
|
||||
<UserDetailsModal
|
||||
user={selectedUser}
|
||||
open={!!selectedUser}
|
||||
onOpenChange={(open) => !open && setSelectedUser(null)}
|
||||
onEdit={(user) => {
|
||||
console.log('Edit user:', user);
|
||||
// EN: TODO: Implement edit / VI: TODO: Implement edit
|
||||
}}
|
||||
onDelete={(userId) => {
|
||||
console.log('Delete user:', userId);
|
||||
setSelectedUser(null);
|
||||
// EN: TODO: Implement delete / VI: TODO: Implement delete
|
||||
}}
|
||||
onDeactivate={(userId) => {
|
||||
console.log('Deactivate user:', userId);
|
||||
setSelectedUser(null);
|
||||
// EN: TODO: Implement deactivate / VI: TODO: Implement deactivate
|
||||
}}
|
||||
/>
|
||||
</React.Suspense>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +1,199 @@
|
||||
/**
|
||||
* EN: Global Styles with Tailwind CSS 4
|
||||
* VI: Styles toàn cục với Tailwind CSS 4
|
||||
*
|
||||
* Import theme variables first, then Tailwind directives
|
||||
* Import các biến theme trước, sau đó là các directives của Tailwind
|
||||
*/
|
||||
@import "../styles/theme.css";
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/**
|
||||
* EN: Theme CSS Variables - Dark Mode (Default)
|
||||
* VI: CSS Variables cho Theme - Dark Mode (Mặc định)
|
||||
*/
|
||||
:root {
|
||||
--foreground-rgb: 0, 0, 0;
|
||||
--background-start-rgb: 214, 219, 220;
|
||||
--background-end-rgb: 255, 255, 255;
|
||||
/* Background Colors */
|
||||
--bg-primary: #0A0A0A;
|
||||
--bg-secondary: #121212;
|
||||
--bg-tertiary: #1A1A1A;
|
||||
--bg-elevated: #242424;
|
||||
|
||||
/* Text Colors */
|
||||
--text-primary: #FAFAFA;
|
||||
--text-secondary: #E0E0E0;
|
||||
--text-tertiary: #A0A0A0;
|
||||
--text-inverse: #1A1A1A;
|
||||
|
||||
/* Brand/Accent Colors */
|
||||
--accent-primary: #3B82F6;
|
||||
--accent-secondary: #8B5CF6;
|
||||
--accent-success: #10B981;
|
||||
--accent-warning: #F59E0B;
|
||||
--accent-error: #EF4444;
|
||||
--accent-info: #06B6D4;
|
||||
|
||||
/* Chat Specific Colors */
|
||||
--chat-user-bubble: #2563EB;
|
||||
--chat-ai-bubble: #374151;
|
||||
--chat-user-text: #FFFFFF;
|
||||
--chat-ai-text: #F3F4F6;
|
||||
--chat-timestamp: #9CA3AF;
|
||||
--chat-divider: #1F2937;
|
||||
|
||||
/* Border Colors */
|
||||
--border-primary: #2A2A2A;
|
||||
--border-secondary: #3A3A3A;
|
||||
--border-focus: #3B82F6;
|
||||
|
||||
/* Font Stack */
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--font-mono: "SF Mono", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
|
||||
/* Type Scale */
|
||||
--text-6xl: 3.75rem;
|
||||
--text-5xl: 3rem;
|
||||
--text-4xl: 2.25rem;
|
||||
--text-3xl: 1.875rem;
|
||||
--text-2xl: 1.5rem;
|
||||
--text-xl: 1.25rem;
|
||||
--text-lg: 1.125rem;
|
||||
--text-base: 1rem;
|
||||
--text-sm: 0.875rem;
|
||||
--text-xs: 0.75rem;
|
||||
|
||||
/* Font Weights */
|
||||
--font-light: 300;
|
||||
--font-normal: 400;
|
||||
--font-medium: 500;
|
||||
--font-semibold: 600;
|
||||
--font-bold: 700;
|
||||
|
||||
/* Spacing Scale (Base: 4px) */
|
||||
--space-0: 0;
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 0.75rem;
|
||||
--space-4: 1rem;
|
||||
--space-5: 1.25rem;
|
||||
--space-6: 1.5rem;
|
||||
--space-8: 2rem;
|
||||
--space-10: 2.5rem;
|
||||
--space-12: 3rem;
|
||||
--space-16: 4rem;
|
||||
--space-20: 5rem;
|
||||
|
||||
/* Container Widths */
|
||||
--container-sm: 640px;
|
||||
--container-md: 768px;
|
||||
--container-lg: 1024px;
|
||||
--container-xl: 1280px;
|
||||
--container-2xl: 1536px;
|
||||
--chat-max-width: 768px;
|
||||
--sidebar-width: 280px;
|
||||
|
||||
/* Border Radius */
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 0.75rem;
|
||||
--radius-xl: 1rem;
|
||||
--radius-2xl: 1.5rem;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.5);
|
||||
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.5);
|
||||
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.6);
|
||||
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.7);
|
||||
--shadow-glow: 0 0 20px rgba(59, 130, 246, 0.3);
|
||||
|
||||
/* Animation Timing */
|
||||
--ease-in: cubic-bezier(0.4, 0, 1, 1);
|
||||
--ease-out: cubic-bezier(0, 0, 0.2, 1);
|
||||
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
|
||||
/* Duration */
|
||||
--duration-fast: 150ms;
|
||||
--duration-normal: 250ms;
|
||||
--duration-slow: 350ms;
|
||||
--duration-slower: 500ms;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--foreground-rgb: 255, 255, 255;
|
||||
--background-start-rgb: 0, 0, 0;
|
||||
--background-end-rgb: 0, 0, 0;
|
||||
}
|
||||
/**
|
||||
* EN: Light Mode Theme Variables
|
||||
* VI: CSS Variables cho Light Mode
|
||||
*/
|
||||
:root.light {
|
||||
/* Background Colors */
|
||||
--bg-primary: #FFFFFF;
|
||||
--bg-secondary: #F9FAFB;
|
||||
--bg-tertiary: #F3F4F6;
|
||||
--bg-elevated: #FFFFFF;
|
||||
|
||||
/* Text Colors */
|
||||
--text-primary: #111827;
|
||||
--text-secondary: #4B5563;
|
||||
--text-tertiary: #9CA3AF;
|
||||
--text-inverse: #FAFAFA;
|
||||
|
||||
/* Chat Specific Colors */
|
||||
--chat-user-bubble: #3B82F6;
|
||||
--chat-ai-bubble: #F3F4F6;
|
||||
--chat-user-text: #FFFFFF;
|
||||
--chat-ai-text: #111827;
|
||||
--chat-timestamp: #6B7280;
|
||||
--chat-divider: #E5E7EB;
|
||||
|
||||
/* Border Colors */
|
||||
--border-primary: #E5E7EB;
|
||||
--border-secondary: #D1D5DB;
|
||||
--border-focus: #3B82F6;
|
||||
|
||||
/* Shadows (lighter for light mode) */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
|
||||
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.1);
|
||||
--shadow-glow: 0 0 20px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Base styles
|
||||
* VI: Styles cơ bản
|
||||
*/
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
color: rgb(var(--foreground-rgb));
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
rgb(var(--background-end-rgb))
|
||||
)
|
||||
rgb(var(--background-start-rgb));
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-base);
|
||||
line-height: 1.5;
|
||||
transition: background-color var(--duration-normal) var(--ease-in-out),
|
||||
color var(--duration-normal) var(--ease-in-out);
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Smooth transitions for theme switching
|
||||
* VI: Chuyển đổi mượt mà cho việc chuyển theme
|
||||
*/
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
transition: background-color var(--duration-normal) var(--ease-in-out),
|
||||
color var(--duration-normal) var(--ease-in-out),
|
||||
border-color var(--duration-normal) var(--ease-in-out);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from 'next';
|
||||
import './globals.css';
|
||||
import { ThemeProvider } from '../contexts/theme-context';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'GoodGo Platform',
|
||||
@@ -12,8 +13,10 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body>
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
230
apps/web-admin/src/components/admin/analytics-card.tsx
Normal file
230
apps/web-admin/src/components/admin/analytics-card.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* EN: Analytics card variant types
|
||||
* VI: Các loại biến thể analytics card
|
||||
*/
|
||||
export type AnalyticsCardVariant = 'metric' | 'chart' | 'progress' | 'comparison';
|
||||
|
||||
/**
|
||||
* EN: Trend direction
|
||||
* VI: Hướng trend
|
||||
*/
|
||||
export type TrendDirection = 'up' | 'down' | 'neutral';
|
||||
|
||||
/**
|
||||
* EN: AnalyticsCard component props
|
||||
* VI: Props của component AnalyticsCard
|
||||
*/
|
||||
export interface AnalyticsCardProps {
|
||||
/**
|
||||
* EN: Card title / VI: Tiêu đề card
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* EN: Main value to display / VI: Giá trị chính để hiển thị
|
||||
*/
|
||||
value: string | number;
|
||||
/**
|
||||
* EN: Change percentage (e.g., "+12.5%") / VI: Phần trăm thay đổi (VD: "+12.5%")
|
||||
*/
|
||||
change?: string;
|
||||
/**
|
||||
* EN: Trend direction / VI: Hướng trend
|
||||
*/
|
||||
trend?: TrendDirection;
|
||||
/**
|
||||
* EN: Icon component / VI: Component icon
|
||||
*/
|
||||
icon?: LucideIcon;
|
||||
/**
|
||||
* EN: Card variant / VI: Biến thể card
|
||||
*/
|
||||
variant?: AnalyticsCardVariant;
|
||||
/**
|
||||
* EN: Chart data (for chart variant) / VI: Dữ liệu chart (cho variant chart)
|
||||
*/
|
||||
chartData?: Array<{ label: string; value: number }>;
|
||||
/**
|
||||
* EN: Progress percentage (0-100) for progress variant / VI: Phần trăm tiến độ (0-100) cho variant progress
|
||||
*/
|
||||
progress?: number;
|
||||
/**
|
||||
* EN: Comparison values for comparison variant / VI: Giá trị so sánh cho variant comparison
|
||||
*/
|
||||
comparisons?: Array<{ label: string; value: string | number }>;
|
||||
/**
|
||||
* EN: Additional CSS classes / VI: Các class CSS bổ sung
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: AnalyticsCard component - Displays analytics metrics with various visualization options
|
||||
* VI: Component AnalyticsCard - Hiển thị các metric analytics với các tùy chọn visualization
|
||||
*
|
||||
* Variants:
|
||||
* - metric: Single value with trend
|
||||
* - chart: Value with mini chart
|
||||
* - progress: Value with progress bar
|
||||
* - comparison: Multiple metrics comparison
|
||||
*
|
||||
* Biến thể:
|
||||
* - metric: Giá trị đơn với trend
|
||||
* - chart: Giá trị với mini chart
|
||||
* - progress: Giá trị với thanh tiến độ
|
||||
* - comparison: So sánh nhiều metrics
|
||||
*/
|
||||
export function AnalyticsCard({
|
||||
title,
|
||||
value,
|
||||
change,
|
||||
trend = 'neutral',
|
||||
icon: Icon,
|
||||
variant = 'metric',
|
||||
chartData,
|
||||
progress,
|
||||
comparisons,
|
||||
className,
|
||||
}: AnalyticsCardProps) {
|
||||
// EN: Get trend color / VI: Lấy màu trend
|
||||
const getTrendColor = () => {
|
||||
switch (trend) {
|
||||
case 'up':
|
||||
return 'text-accent-success';
|
||||
case 'down':
|
||||
return 'text-accent-error';
|
||||
default:
|
||||
return 'text-text-tertiary';
|
||||
}
|
||||
};
|
||||
|
||||
// EN: Get trend icon / VI: Lấy icon trend
|
||||
const getTrendIcon = () => {
|
||||
switch (trend) {
|
||||
case 'up':
|
||||
return (
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
case 'down':
|
||||
return (
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 17h8m0 0V9m0 8l-8-8-4 4-6-6"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={cn('hover', className)}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-text-secondary">
|
||||
{title}
|
||||
</CardTitle>
|
||||
{Icon && (
|
||||
<Icon className="h-4 w-4 text-text-tertiary" aria-hidden="true" />
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* EN: Main value / VI: Giá trị chính */}
|
||||
<div className="text-2xl font-bold text-text-primary mb-1">
|
||||
{typeof value === 'number' ? value.toLocaleString() : value}
|
||||
</div>
|
||||
|
||||
{/* EN: Change indicator / VI: Chỉ báo thay đổi */}
|
||||
{change && (
|
||||
<div className={cn('text-xs flex items-center gap-1', getTrendColor())}>
|
||||
{getTrendIcon()}
|
||||
<span>{change}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* EN: Chart variant - Mini sparkline / VI: Variant chart - Mini sparkline */}
|
||||
{variant === 'chart' && chartData && chartData.length > 0 && (
|
||||
<div className="mt-4 h-[60px] flex items-end gap-1">
|
||||
{chartData.map((point, index) => {
|
||||
const maxValue = Math.max(...chartData.map((p) => p.value));
|
||||
const height = (point.value / maxValue) * 100;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex-1 bg-accent-primary rounded-t"
|
||||
style={{ height: `${height}%` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* EN: Progress variant - Progress bar / VI: Variant progress - Thanh tiến độ */}
|
||||
{variant === 'progress' && progress !== undefined && (
|
||||
<div className="mt-4">
|
||||
<div className="w-full h-2 bg-bg-tertiary rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent-primary transition-all duration-[250ms]"
|
||||
style={{ width: `${Math.min(progress, 100)}%` }}
|
||||
role="progressbar"
|
||||
aria-valuenow={progress}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={`${title}: ${progress}%`}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-text-tertiary mt-1">{progress}%</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* EN: Comparison variant - Multiple metrics / VI: Variant comparison - Nhiều metrics */}
|
||||
{variant === 'comparison' && comparisons && comparisons.length > 0 && (
|
||||
<div className="mt-4 space-y-2">
|
||||
{comparisons.map((comp, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between text-sm"
|
||||
>
|
||||
<span className="text-text-secondary">{comp.label}</span>
|
||||
<span className="font-medium text-text-primary">
|
||||
{typeof comp.value === 'number'
|
||||
? comp.value.toLocaleString()
|
||||
: comp.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
171
apps/web-admin/src/components/admin/charts/revenue-chart.tsx
Normal file
171
apps/web-admin/src/components/admin/charts/revenue-chart.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
|
||||
/**
|
||||
* EN: Revenue data point
|
||||
* VI: Điểm dữ liệu doanh thu
|
||||
*/
|
||||
export interface RevenueDataPoint {
|
||||
/**
|
||||
* EN: Date label / VI: Nhãn ngày
|
||||
*/
|
||||
date: string;
|
||||
/**
|
||||
* EN: Revenue amount / VI: Số tiền doanh thu
|
||||
*/
|
||||
revenue: number;
|
||||
/**
|
||||
* EN: Optional comparison value / VI: Giá trị so sánh tùy chọn
|
||||
*/
|
||||
previousRevenue?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: RevenueChart component props
|
||||
* VI: Props của component RevenueChart
|
||||
*/
|
||||
export interface RevenueChartProps {
|
||||
/**
|
||||
* EN: Chart data / VI: Dữ liệu chart
|
||||
*/
|
||||
data: RevenueDataPoint[];
|
||||
/**
|
||||
* EN: Chart title / VI: Tiêu đề chart
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* EN: Chart description / VI: Mô tả chart
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* EN: Currency symbol / VI: Ký hiệu tiền tệ
|
||||
*/
|
||||
currency?: string;
|
||||
/**
|
||||
* EN: Show legend / VI: Hiển thị legend
|
||||
*/
|
||||
showLegend?: boolean;
|
||||
/**
|
||||
* EN: Additional CSS classes / VI: Các class CSS bổ sung
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: RevenueChart component - Area chart showing revenue over time
|
||||
* VI: Component RevenueChart - Area chart hiển thị doanh thu theo thời gian
|
||||
*
|
||||
* Features:
|
||||
* - Area chart with Recharts
|
||||
* - Responsive container
|
||||
* - Tooltip on hover with currency formatting
|
||||
* - Legend support
|
||||
* - Dark mode optimized colors
|
||||
* - Gradient fill
|
||||
*
|
||||
* Tính năng:
|
||||
* - Area chart với Recharts
|
||||
* - Container responsive
|
||||
* - Tooltip khi hover với định dạng tiền tệ
|
||||
* - Hỗ trợ legend
|
||||
* - Màu sắc tối ưu cho dark mode
|
||||
* - Gradient fill
|
||||
*/
|
||||
export function RevenueChart({
|
||||
data,
|
||||
title = 'Revenue / Doanh thu',
|
||||
description = 'Revenue over time / Doanh thu theo thời gian',
|
||||
currency = '$',
|
||||
showLegend = true,
|
||||
className,
|
||||
}: RevenueChartProps) {
|
||||
// EN: Format currency value / VI: Format giá trị tiền tệ
|
||||
const formatCurrency = (value: number) => {
|
||||
return `${currency}${value.toLocaleString()}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
{description && <CardDescription>{description}</CardDescription>}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart
|
||||
data={data}
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="revenueGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--accent-primary)" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="var(--accent-primary)" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
{data.some((d) => d.previousRevenue !== undefined) && (
|
||||
<linearGradient id="previousRevenueGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--accent-secondary)" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="var(--accent-secondary)" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
)}
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border-primary)" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="var(--text-tertiary)"
|
||||
style={{ fontSize: '12px' }}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="var(--text-tertiary)"
|
||||
style={{ fontSize: '12px' }}
|
||||
tickFormatter={formatCurrency}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'var(--bg-elevated)',
|
||||
border: '1px solid var(--border-primary)',
|
||||
borderRadius: '8px',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
formatter={(value: number) => formatCurrency(value)}
|
||||
/>
|
||||
{showLegend && (
|
||||
<Legend
|
||||
wrapperStyle={{ color: 'var(--text-secondary)' }}
|
||||
/>
|
||||
)}
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="revenue"
|
||||
stroke="var(--accent-primary)"
|
||||
strokeWidth={2}
|
||||
fill="url(#revenueGradient)"
|
||||
name="Revenue / Doanh thu"
|
||||
/>
|
||||
{data.some((d) => d.previousRevenue !== undefined) && (
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="previousRevenue"
|
||||
stroke="var(--accent-secondary)"
|
||||
strokeWidth={2}
|
||||
fill="url(#previousRevenueGradient)"
|
||||
name="Previous Period / Kỳ trước"
|
||||
/>
|
||||
)}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
145
apps/web-admin/src/components/admin/charts/user-growth-chart.tsx
Normal file
145
apps/web-admin/src/components/admin/charts/user-growth-chart.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
|
||||
/**
|
||||
* EN: User growth data point
|
||||
* VI: Điểm dữ liệu tăng trưởng người dùng
|
||||
*/
|
||||
export interface UserGrowthDataPoint {
|
||||
/**
|
||||
* EN: Date label / VI: Nhãn ngày
|
||||
*/
|
||||
date: string;
|
||||
/**
|
||||
* EN: Number of users / VI: Số lượng người dùng
|
||||
*/
|
||||
users: number;
|
||||
/**
|
||||
* EN: Number of new users / VI: Số người dùng mới
|
||||
*/
|
||||
newUsers?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: UserGrowthChart component props
|
||||
* VI: Props của component UserGrowthChart
|
||||
*/
|
||||
export interface UserGrowthChartProps {
|
||||
/**
|
||||
* EN: Chart data / VI: Dữ liệu chart
|
||||
*/
|
||||
data: UserGrowthDataPoint[];
|
||||
/**
|
||||
* EN: Chart title / VI: Tiêu đề chart
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* EN: Chart description / VI: Mô tả chart
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* EN: Show legend / VI: Hiển thị legend
|
||||
*/
|
||||
showLegend?: boolean;
|
||||
/**
|
||||
* EN: Additional CSS classes / VI: Các class CSS bổ sung
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: UserGrowthChart component - Line chart showing user growth over time
|
||||
* VI: Component UserGrowthChart - Line chart hiển thị tăng trưởng người dùng theo thời gian
|
||||
*
|
||||
* Features:
|
||||
* - Line chart with Recharts
|
||||
* - Responsive container
|
||||
* - Tooltip on hover
|
||||
* - Legend support
|
||||
* - Dark mode optimized colors
|
||||
*
|
||||
* Tính năng:
|
||||
* - Line chart với Recharts
|
||||
* - Container responsive
|
||||
* - Tooltip khi hover
|
||||
* - Hỗ trợ legend
|
||||
* - Màu sắc tối ưu cho dark mode
|
||||
*/
|
||||
export function UserGrowthChart({
|
||||
data,
|
||||
title = 'User Growth / Tăng trưởng người dùng',
|
||||
description = 'User growth over time / Tăng trưởng người dùng theo thời gian',
|
||||
showLegend = true,
|
||||
className,
|
||||
}: UserGrowthChartProps) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
{description && <CardDescription>{description}</CardDescription>}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart
|
||||
data={data}
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border-primary)" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="var(--text-tertiary)"
|
||||
style={{ fontSize: '12px' }}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="var(--text-tertiary)"
|
||||
style={{ fontSize: '12px' }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'var(--bg-elevated)',
|
||||
border: '1px solid var(--border-primary)',
|
||||
borderRadius: '8px',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
/>
|
||||
{showLegend && (
|
||||
<Legend
|
||||
wrapperStyle={{ color: 'var(--text-secondary)' }}
|
||||
/>
|
||||
)}
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="users"
|
||||
stroke="var(--accent-primary)"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: 'var(--accent-primary)', r: 4 }}
|
||||
name="Total Users / Tổng người dùng"
|
||||
/>
|
||||
{data.some((d) => d.newUsers !== undefined) && (
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="newUsers"
|
||||
stroke="var(--accent-success)"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: 'var(--accent-success)', r: 4 }}
|
||||
name="New Users / Người dùng mới"
|
||||
/>
|
||||
)}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
491
apps/web-admin/src/components/admin/data-table.tsx
Normal file
491
apps/web-admin/src/components/admin/data-table.tsx
Normal file
@@ -0,0 +1,491 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
ArrowUpDown,
|
||||
Download,
|
||||
Trash2,
|
||||
CheckSquare,
|
||||
Square,
|
||||
} from 'lucide-react';
|
||||
|
||||
/**
|
||||
* EN: Column definition for DataTable
|
||||
* VI: Định nghĩa cột cho DataTable
|
||||
*/
|
||||
export interface DataTableColumn<T> {
|
||||
/**
|
||||
* EN: Column key / VI: Key của cột
|
||||
*/
|
||||
key: keyof T | string;
|
||||
/**
|
||||
* EN: Column header label / VI: Nhãn header cột
|
||||
*/
|
||||
header: string;
|
||||
/**
|
||||
* EN: Custom cell renderer / VI: Custom cell renderer
|
||||
*/
|
||||
cell?: (row: T) => React.ReactNode;
|
||||
/**
|
||||
* EN: Enable sorting / VI: Bật sắp xếp
|
||||
*/
|
||||
sortable?: boolean;
|
||||
/**
|
||||
* EN: Column width / VI: Chiều rộng cột
|
||||
*/
|
||||
width?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Sort direction
|
||||
* VI: Hướng sắp xếp
|
||||
*/
|
||||
export type SortDirection = 'asc' | 'desc' | null;
|
||||
|
||||
/**
|
||||
* EN: DataTable component props
|
||||
* VI: Props của component DataTable
|
||||
*/
|
||||
export interface DataTableProps<T> {
|
||||
/**
|
||||
* EN: Table data / VI: Dữ liệu bảng
|
||||
*/
|
||||
data: T[];
|
||||
/**
|
||||
* EN: Column definitions / VI: Định nghĩa cột
|
||||
*/
|
||||
columns: DataTableColumn<T>[];
|
||||
/**
|
||||
* EN: Current page number (1-indexed) / VI: Số trang hiện tại (bắt đầu từ 1)
|
||||
*/
|
||||
currentPage?: number;
|
||||
/**
|
||||
* EN: Number of items per page / VI: Số item mỗi trang
|
||||
*/
|
||||
itemsPerPage?: number;
|
||||
/**
|
||||
* EN: Total number of items (for server-side pagination) / VI: Tổng số item (cho pagination phía server)
|
||||
*/
|
||||
totalItems?: number;
|
||||
/**
|
||||
* EN: Callback when page changes / VI: Callback khi trang thay đổi
|
||||
*/
|
||||
onPageChange?: (page: number) => void;
|
||||
/**
|
||||
* EN: Enable row selection / VI: Bật chọn hàng
|
||||
*/
|
||||
selectable?: boolean;
|
||||
/**
|
||||
* EN: Selected row IDs / VI: IDs hàng được chọn
|
||||
*/
|
||||
selectedRows?: string[];
|
||||
/**
|
||||
* EN: Callback when selection changes / VI: Callback khi selection thay đổi
|
||||
*/
|
||||
onSelectionChange?: (selectedIds: string[]) => void;
|
||||
/**
|
||||
* EN: Get row ID function / VI: Hàm lấy ID hàng
|
||||
*/
|
||||
getRowId?: (row: T) => string;
|
||||
/**
|
||||
* EN: Enable bulk actions / VI: Bật bulk actions
|
||||
*/
|
||||
bulkActions?: Array<{
|
||||
label: string;
|
||||
action: (selectedIds: string[]) => void;
|
||||
variant?: 'primary' | 'secondary' | 'danger';
|
||||
}>;
|
||||
/**
|
||||
* EN: Enable export / VI: Bật export
|
||||
*/
|
||||
exportable?: boolean;
|
||||
/**
|
||||
* EN: Callback for export / VI: Callback cho export
|
||||
*/
|
||||
onExport?: () => void;
|
||||
/**
|
||||
* EN: Loading state / VI: Trạng thái loading
|
||||
*/
|
||||
loading?: boolean;
|
||||
/**
|
||||
* EN: Additional CSS classes / VI: Các class CSS bổ sung
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: DataTable component - Advanced table with sorting, filtering, pagination, and bulk actions
|
||||
* VI: Component DataTable - Bảng nâng cao với sắp xếp, lọc, pagination và bulk actions
|
||||
*
|
||||
* Features:
|
||||
* - Column sorting
|
||||
* - Global search
|
||||
* - Column filters
|
||||
* - Pagination
|
||||
* - Row selection
|
||||
* - Bulk actions
|
||||
* - Export to CSV/Excel
|
||||
* - Column visibility toggle
|
||||
*
|
||||
* Tính năng:
|
||||
* - Sắp xếp cột
|
||||
* - Tìm kiếm toàn cục
|
||||
* - Lọc cột
|
||||
* - Pagination
|
||||
* - Chọn hàng
|
||||
* - Bulk actions
|
||||
* - Export sang CSV/Excel
|
||||
* - Toggle hiển thị cột
|
||||
*/
|
||||
export function DataTable<T extends Record<string, any>>({
|
||||
data,
|
||||
columns,
|
||||
currentPage = 1,
|
||||
itemsPerPage = 10,
|
||||
totalItems,
|
||||
onPageChange,
|
||||
selectable = false,
|
||||
selectedRows = [],
|
||||
onSelectionChange,
|
||||
getRowId = (row) => row.id || String(row),
|
||||
bulkActions = [],
|
||||
exportable = false,
|
||||
onExport,
|
||||
loading = false,
|
||||
className,
|
||||
}: DataTableProps<T>) {
|
||||
const [sortColumn, setSortColumn] = React.useState<keyof T | string | null>(null);
|
||||
const [sortDirection, setSortDirection] = React.useState<SortDirection>(null);
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
|
||||
// EN: Calculate pagination / VI: Tính toán pagination
|
||||
const totalPages = totalItems
|
||||
? Math.ceil(totalItems / itemsPerPage)
|
||||
: Math.ceil(data.length / itemsPerPage);
|
||||
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const endIndex = startIndex + itemsPerPage;
|
||||
|
||||
// EN: Handle sorting / VI: Xử lý sắp xếp
|
||||
const handleSort = (columnKey: keyof T | string) => {
|
||||
if (sortColumn === columnKey) {
|
||||
if (sortDirection === 'asc') {
|
||||
setSortDirection('desc');
|
||||
} else if (sortDirection === 'desc') {
|
||||
setSortColumn(null);
|
||||
setSortDirection(null);
|
||||
}
|
||||
} else {
|
||||
setSortColumn(columnKey);
|
||||
setSortDirection('asc');
|
||||
}
|
||||
};
|
||||
|
||||
// EN: Handle row selection / VI: Xử lý chọn hàng
|
||||
const handleRowSelect = (rowId: string) => {
|
||||
if (!onSelectionChange) return;
|
||||
|
||||
const newSelection = selectedRows.includes(rowId)
|
||||
? selectedRows.filter((id) => id !== rowId)
|
||||
: [...selectedRows, rowId];
|
||||
onSelectionChange(newSelection);
|
||||
};
|
||||
|
||||
// EN: Handle select all / VI: Xử lý chọn tất cả
|
||||
const handleSelectAll = () => {
|
||||
if (!onSelectionChange) return;
|
||||
|
||||
const allIds = data.map(getRowId);
|
||||
const allSelected = allIds.every((id) => selectedRows.includes(id));
|
||||
onSelectionChange(allSelected ? [] : allIds);
|
||||
};
|
||||
|
||||
// EN: Filter and sort data / VI: Lọc và sắp xếp dữ liệu
|
||||
const processedData = React.useMemo(() => {
|
||||
let filtered = data;
|
||||
|
||||
// EN: Apply search filter / VI: Áp dụng bộ lọc tìm kiếm
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter((row) =>
|
||||
columns.some((col) => {
|
||||
const value = row[col.key];
|
||||
return value && String(value).toLowerCase().includes(query);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// EN: Apply sorting / VI: Áp dụng sắp xếp
|
||||
if (sortColumn && sortDirection) {
|
||||
filtered = [...filtered].sort((a, b) => {
|
||||
const aValue = a[sortColumn];
|
||||
const bValue = b[sortColumn];
|
||||
|
||||
if (aValue === bValue) return 0;
|
||||
|
||||
const comparison = aValue < bValue ? -1 : 1;
|
||||
return sortDirection === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
}
|
||||
|
||||
// EN: Apply pagination / VI: Áp dụng pagination
|
||||
return totalItems ? filtered : filtered.slice(startIndex, endIndex);
|
||||
}, [data, searchQuery, sortColumn, sortDirection, startIndex, endIndex, totalItems, columns]);
|
||||
|
||||
// EN: Handle page change / VI: Xử lý thay đổi trang
|
||||
const handlePageChange = (page: number) => {
|
||||
if (page >= 1 && page <= totalPages && onPageChange) {
|
||||
onPageChange(page);
|
||||
}
|
||||
};
|
||||
|
||||
const allSelected = data.length > 0 && data.every((row) => selectedRows.includes(getRowId(row)));
|
||||
const someSelected = selectedRows.length > 0 && !allSelected;
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
{/* EN: Toolbar with search and actions / VI: Toolbar với tìm kiếm và actions */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search... / Tìm kiếm..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
{exportable && (
|
||||
<Button variant="secondary" size="sm" onClick={onExport}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Export / Xuất
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* EN: Bulk actions bar / VI: Thanh bulk actions */}
|
||||
{selectable && selectedRows.length > 0 && (
|
||||
<div className="flex items-center justify-between p-4 bg-bg-tertiary rounded-lg border border-border-primary">
|
||||
<span className="text-sm text-text-secondary">
|
||||
{selectedRows.length} selected / {selectedRows.length} đã chọn
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{bulkActions.map((action, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant={action.variant || 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => action.action(selectedRows)}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* EN: Table / VI: Bảng */}
|
||||
<div className="bg-bg-secondary rounded-lg border border-border-primary overflow-hidden">
|
||||
{/* EN: Mobile: Horizontal scroll / VI: Mobile: Scroll ngang */}
|
||||
<div className="overflow-x-auto -mx-4 sm:mx-0">
|
||||
<div className="inline-block min-w-full align-middle sm:min-w-0">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-accent-primary border-r-transparent" />
|
||||
<p className="mt-4 text-sm text-text-tertiary">
|
||||
Loading... / Đang tải...
|
||||
</p>
|
||||
</div>
|
||||
) : processedData.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<p className="text-sm text-text-tertiary">
|
||||
No data found / Không tìm thấy dữ liệu
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border-primary">
|
||||
{selectable && (
|
||||
<th className="px-6 py-3 text-left w-12">
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
className="p-1 rounded hover:bg-bg-tertiary transition-colors"
|
||||
aria-label="Select all / Chọn tất cả"
|
||||
>
|
||||
{allSelected ? (
|
||||
<CheckSquare className="h-4 w-4 text-accent-primary" />
|
||||
) : someSelected ? (
|
||||
<div className="h-4 w-4 border-2 border-accent-primary rounded bg-accent-primary/20" />
|
||||
) : (
|
||||
<Square className="h-4 w-4 text-text-tertiary" />
|
||||
)}
|
||||
</button>
|
||||
</th>
|
||||
)}
|
||||
{columns.map((column) => (
|
||||
<th
|
||||
key={String(column.key)}
|
||||
className="px-6 py-3 text-left text-xs font-medium text-text-tertiary uppercase tracking-wider"
|
||||
style={{ width: column.width }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{column.header}</span>
|
||||
{column.sortable && (
|
||||
<button
|
||||
onClick={() => handleSort(column.key)}
|
||||
className="p-1 rounded hover:bg-bg-tertiary transition-colors"
|
||||
aria-label={`Sort by ${column.header} / Sắp xếp theo ${column.header}`}
|
||||
>
|
||||
<ArrowUpDown
|
||||
className={cn(
|
||||
'h-3 w-3',
|
||||
sortColumn === column.key
|
||||
? 'text-accent-primary'
|
||||
: 'text-text-tertiary'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-primary">
|
||||
{processedData.map((row) => {
|
||||
const rowId = getRowId(row);
|
||||
const isSelected = selectedRows.includes(rowId);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={rowId}
|
||||
className={cn(
|
||||
'hover:bg-bg-tertiary transition-colors duration-[150ms]',
|
||||
isSelected && 'bg-bg-tertiary'
|
||||
)}
|
||||
>
|
||||
{selectable && (
|
||||
<td className="px-6 py-4">
|
||||
<button
|
||||
onClick={() => handleRowSelect(rowId)}
|
||||
className="p-1 rounded hover:bg-bg-elevated transition-colors"
|
||||
aria-label={`Select row ${rowId} / Chọn hàng ${rowId}`}
|
||||
>
|
||||
{isSelected ? (
|
||||
<CheckSquare className="h-4 w-4 text-accent-primary" />
|
||||
) : (
|
||||
<Square className="h-4 w-4 text-text-tertiary" />
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
{columns.map((column) => (
|
||||
<td key={String(column.key)} className="px-6 py-4">
|
||||
{column.cell
|
||||
? column.cell(row)
|
||||
: String(row[column.key] || '')}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* EN: Pagination / VI: Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="px-6 py-4 border-t border-border-primary flex items-center justify-between">
|
||||
<div className="text-sm text-text-tertiary">
|
||||
{totalItems ? (
|
||||
<>
|
||||
Showing / Hiển thị{' '}
|
||||
<span className="font-medium text-text-secondary">
|
||||
{startIndex + 1}
|
||||
</span>{' '}
|
||||
to / đến{' '}
|
||||
<span className="font-medium text-text-secondary">
|
||||
{Math.min(endIndex, totalItems)}
|
||||
</span>{' '}
|
||||
of / trong{' '}
|
||||
<span className="font-medium text-text-secondary">{totalItems}</span>{' '}
|
||||
results / kết quả
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Showing / Hiển thị{' '}
|
||||
<span className="font-medium text-text-secondary">
|
||||
{startIndex + 1}
|
||||
</span>{' '}
|
||||
to / đến{' '}
|
||||
<span className="font-medium text-text-secondary">
|
||||
{Math.min(endIndex, data.length)}
|
||||
</span>{' '}
|
||||
of / trong{' '}
|
||||
<span className="font-medium text-text-secondary">{data.length}</span>{' '}
|
||||
results / kết quả
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(1)}
|
||||
disabled={currentPage === 1 || loading}
|
||||
aria-label="First page / Trang đầu"
|
||||
className="min-w-[44px] min-h-[44px]"
|
||||
>
|
||||
<ChevronsLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1 || loading}
|
||||
aria-label="Previous page / Trang trước"
|
||||
className="min-w-[44px] min-h-[44px]"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-sm text-text-secondary px-3 hidden sm:inline">
|
||||
Page / Trang {currentPage} of / trong {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages || loading}
|
||||
aria-label="Next page / Trang sau"
|
||||
className="min-w-[44px] min-h-[44px]"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(totalPages)}
|
||||
disabled={currentPage === totalPages || loading}
|
||||
aria-label="Last page / Trang cuối"
|
||||
className="min-w-[44px] min-h-[44px]"
|
||||
>
|
||||
<ChevronsRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
551
apps/web-admin/src/components/admin/recent-activity-table.tsx
Normal file
551
apps/web-admin/src/components/admin/recent-activity-table.tsx
Normal file
@@ -0,0 +1,551 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* EN: Activity status type
|
||||
* VI: Loại trạng thái hoạt động
|
||||
*/
|
||||
export type ActivityStatus = 'success' | 'warning' | 'error' | 'info' | 'pending';
|
||||
|
||||
/**
|
||||
* EN: Activity action type
|
||||
* VI: Loại hành động hoạt động
|
||||
*/
|
||||
export type ActivityAction =
|
||||
| 'user_created'
|
||||
| 'user_updated'
|
||||
| 'user_deleted'
|
||||
| 'user_login'
|
||||
| 'user_logout'
|
||||
| 'message_sent'
|
||||
| 'message_deleted'
|
||||
| 'settings_updated'
|
||||
| 'system_backup'
|
||||
| 'system_restore';
|
||||
|
||||
/**
|
||||
* EN: Recent activity item interface
|
||||
* VI: Interface cho item hoạt động gần đây
|
||||
*/
|
||||
export interface RecentActivity {
|
||||
/**
|
||||
* EN: Unique activity identifier / VI: Mã định danh duy nhất cho hoạt động
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* EN: User who performed the action / VI: Người dùng thực hiện hành động
|
||||
*/
|
||||
user: {
|
||||
/**
|
||||
* EN: User ID / VI: ID người dùng
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* EN: User name / VI: Tên người dùng
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* EN: User email / VI: Email người dùng
|
||||
*/
|
||||
email: string;
|
||||
/**
|
||||
* EN: User avatar URL (optional) / VI: URL avatar người dùng (tùy chọn)
|
||||
*/
|
||||
avatarUrl?: string;
|
||||
};
|
||||
/**
|
||||
* EN: Action performed / VI: Hành động được thực hiện
|
||||
*/
|
||||
action: ActivityAction;
|
||||
/**
|
||||
* EN: Action description / VI: Mô tả hành động
|
||||
*/
|
||||
description: string;
|
||||
/**
|
||||
* EN: Activity status / VI: Trạng thái hoạt động
|
||||
*/
|
||||
status: ActivityStatus;
|
||||
/**
|
||||
* EN: Timestamp when activity occurred / VI: Timestamp khi hoạt động xảy ra
|
||||
*/
|
||||
timestamp: Date | string;
|
||||
/**
|
||||
* EN: Additional metadata (optional) / VI: Metadata bổ sung (tùy chọn)
|
||||
*/
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: RecentActivityTable component props
|
||||
* VI: Props của component RecentActivityTable
|
||||
*/
|
||||
export interface RecentActivityTableProps {
|
||||
/**
|
||||
* EN: Array of recent activities / VI: Mảng các hoạt động gần đây
|
||||
*/
|
||||
activities: RecentActivity[];
|
||||
/**
|
||||
* EN: Current page number (1-indexed) / VI: Số trang hiện tại (bắt đầu từ 1)
|
||||
*/
|
||||
currentPage?: number;
|
||||
/**
|
||||
* EN: Number of items per page / VI: Số item mỗi trang
|
||||
*/
|
||||
itemsPerPage?: number;
|
||||
/**
|
||||
* EN: Total number of items (for server-side pagination) / VI: Tổng số item (cho pagination phía server)
|
||||
*/
|
||||
totalItems?: number;
|
||||
/**
|
||||
* EN: Callback when page changes / VI: Callback khi trang thay đổi
|
||||
*/
|
||||
onPageChange?: (page: number) => void;
|
||||
/**
|
||||
* EN: Callback for quick action click / VI: Callback khi click quick action
|
||||
*/
|
||||
onQuickAction?: (activityId: string, action: string) => void;
|
||||
/**
|
||||
* EN: Loading state / VI: Trạng thái loading
|
||||
*/
|
||||
loading?: boolean;
|
||||
/**
|
||||
* EN: Additional CSS classes / VI: Các class CSS bổ sung
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Get user initials for avatar fallback
|
||||
* VI: Lấy initials của user cho avatar fallback
|
||||
*/
|
||||
function getUserInitials(name: string): string {
|
||||
return name
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Format timestamp to relative time
|
||||
* VI: Format timestamp thành thời gian tương đối
|
||||
*/
|
||||
function formatRelativeTime(date: Date | string): string {
|
||||
const now = new Date();
|
||||
const activityDate = typeof date === 'string' ? new Date(date) : date;
|
||||
const diffMs = now.getTime() - activityDate.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'Just now / Vừa xong';
|
||||
if (diffMins < 60) return `${diffMins}m ago / ${diffMins} phút trước`;
|
||||
if (diffHours < 24) return `${diffHours}h ago / ${diffHours} giờ trước`;
|
||||
if (diffDays < 7) return `${diffDays}d ago / ${diffDays} ngày trước`;
|
||||
return activityDate.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: diffDays >= 365 ? 'numeric' : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Get status badge styles
|
||||
* VI: Lấy styles cho status badge
|
||||
*/
|
||||
function getStatusBadgeStyles(status: ActivityStatus): string {
|
||||
const styles = {
|
||||
success:
|
||||
'bg-accent-success/20 text-accent-success border-accent-success/30',
|
||||
warning:
|
||||
'bg-accent-warning/20 text-accent-warning border-accent-warning/30',
|
||||
error:
|
||||
'bg-accent-error/20 text-accent-error border-accent-error/30',
|
||||
info: 'bg-accent-info/20 text-accent-info border-accent-info/30',
|
||||
pending:
|
||||
'bg-text-tertiary/20 text-text-tertiary border-text-tertiary/30',
|
||||
};
|
||||
return styles[status];
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Get status label
|
||||
* VI: Lấy label cho status
|
||||
*/
|
||||
function getStatusLabel(status: ActivityStatus): string {
|
||||
const labels = {
|
||||
success: 'Success / Thành công',
|
||||
warning: 'Warning / Cảnh báo',
|
||||
error: 'Error / Lỗi',
|
||||
info: 'Info / Thông tin',
|
||||
pending: 'Pending / Đang chờ',
|
||||
};
|
||||
return labels[status];
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Get action label
|
||||
* VI: Lấy label cho action
|
||||
*/
|
||||
function getActionLabel(action: ActivityAction): string {
|
||||
const labels: Record<ActivityAction, string> = {
|
||||
user_created: 'User Created / Tạo người dùng',
|
||||
user_updated: 'User Updated / Cập nhật người dùng',
|
||||
user_deleted: 'User Deleted / Xóa người dùng',
|
||||
user_login: 'User Login / Đăng nhập',
|
||||
user_logout: 'User Logout / Đăng xuất',
|
||||
message_sent: 'Message Sent / Gửi tin nhắn',
|
||||
message_deleted: 'Message Deleted / Xóa tin nhắn',
|
||||
settings_updated: 'Settings Updated / Cập nhật cài đặt',
|
||||
system_backup: 'System Backup / Sao lưu hệ thống',
|
||||
system_restore: 'System Restore / Khôi phục hệ thống',
|
||||
};
|
||||
return labels[action] || action;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: RecentActivityTable component - Displays recent activities with pagination
|
||||
* VI: Component RecentActivityTable - Hiển thị các hoạt động gần đây với pagination
|
||||
*
|
||||
* Features:
|
||||
* - User avatar + name
|
||||
* - Action performed
|
||||
* - Timestamp (relative)
|
||||
* - Status badge
|
||||
* - Quick actions
|
||||
* - Pagination
|
||||
*
|
||||
* Tính năng:
|
||||
* - Avatar + tên người dùng
|
||||
* - Hành động được thực hiện
|
||||
* - Timestamp (tương đối)
|
||||
* - Badge trạng thái
|
||||
* - Quick actions
|
||||
* - Pagination
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <RecentActivityTable
|
||||
* activities={activities}
|
||||
* currentPage={1}
|
||||
* itemsPerPage={10}
|
||||
* onPageChange={(page) => setPage(page)}
|
||||
* onQuickAction={(id, action) => handleAction(id, action)}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function RecentActivityTable({
|
||||
activities,
|
||||
currentPage = 1,
|
||||
itemsPerPage = 10,
|
||||
totalItems,
|
||||
onPageChange,
|
||||
onQuickAction,
|
||||
loading = false,
|
||||
className,
|
||||
}: RecentActivityTableProps) {
|
||||
// EN: Calculate pagination / VI: Tính toán pagination
|
||||
const totalPages = totalItems
|
||||
? Math.ceil(totalItems / itemsPerPage)
|
||||
: Math.ceil(activities.length / itemsPerPage);
|
||||
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const endIndex = startIndex + itemsPerPage;
|
||||
const paginatedActivities = totalItems
|
||||
? activities // EN: Server-side pagination / VI: Pagination phía server
|
||||
: activities.slice(startIndex, endIndex); // EN: Client-side pagination / VI: Pagination phía client
|
||||
|
||||
// EN: Handle page change / VI: Xử lý thay đổi trang
|
||||
const handlePageChange = (page: number) => {
|
||||
if (page >= 1 && page <= totalPages && onPageChange) {
|
||||
onPageChange(page);
|
||||
}
|
||||
};
|
||||
|
||||
// EN: Handle quick action / VI: Xử lý quick action
|
||||
const handleQuickAction = (activityId: string, action: string) => {
|
||||
if (onQuickAction) {
|
||||
onQuickAction(activityId, action);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-bg-secondary rounded-xl border border-border-primary overflow-hidden',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* EN: Table Header / VI: Header bảng */}
|
||||
<div className="px-6 py-4 border-b border-border-primary">
|
||||
<h3 className="text-lg font-semibold text-text-primary">
|
||||
Recent Activity / Hoạt động gần đây
|
||||
</h3>
|
||||
<p className="text-sm text-text-tertiary mt-1">
|
||||
Latest system activities / Các hoạt động hệ thống mới nhất
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* EN: Table Content / VI: Nội dung bảng */}
|
||||
{/* EN: Mobile: Horizontal scroll / VI: Mobile: Scroll ngang */}
|
||||
<div className="overflow-x-auto -mx-4 sm:mx-0">
|
||||
<div className="inline-block min-w-full align-middle sm:min-w-0 px-4 sm:px-0">
|
||||
{loading ? (
|
||||
// EN: Loading state / VI: Trạng thái loading
|
||||
<div className="p-8 text-center">
|
||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-accent-primary border-r-transparent" />
|
||||
<p className="mt-4 text-sm text-text-tertiary">
|
||||
Loading activities... / Đang tải hoạt động...
|
||||
</p>
|
||||
</div>
|
||||
) : paginatedActivities.length === 0 ? (
|
||||
// EN: Empty state / VI: Trạng thái trống
|
||||
<div className="p-8 text-center">
|
||||
<p className="text-sm text-text-tertiary">
|
||||
No activities found / Không tìm thấy hoạt động nào
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border-primary">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-text-tertiary uppercase tracking-wider">
|
||||
User / Người dùng
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-text-tertiary uppercase tracking-wider">
|
||||
Action / Hành động
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-text-tertiary uppercase tracking-wider">
|
||||
Status / Trạng thái
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-text-tertiary uppercase tracking-wider">
|
||||
Time / Thời gian
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-text-tertiary uppercase tracking-wider">
|
||||
Actions / Hành động
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-primary">
|
||||
{paginatedActivities.map((activity) => (
|
||||
<tr
|
||||
key={activity.id}
|
||||
className="hover:bg-bg-tertiary transition-colors duration-[150ms]"
|
||||
>
|
||||
{/* EN: User column / VI: Cột người dùng */}
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
{/* EN: Avatar / VI: Avatar */}
|
||||
<div className="relative flex-shrink-0 h-10 w-10">
|
||||
{activity.user.avatarUrl ? (
|
||||
<img
|
||||
src={activity.user.avatarUrl}
|
||||
alt={`Avatar of ${activity.user.name} / Avatar của ${activity.user.name}`}
|
||||
className="h-10 w-10 rounded-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-10 w-10 rounded-full bg-chat-ai-bubble flex items-center justify-center">
|
||||
<span className="text-sm font-medium text-text-primary">
|
||||
{getUserInitials(activity.user.name)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<div className="text-sm font-medium text-text-primary">
|
||||
{activity.user.name}
|
||||
</div>
|
||||
<div className="text-sm text-text-tertiary">
|
||||
{activity.user.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* EN: Action column / VI: Cột hành động */}
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-sm text-text-secondary">
|
||||
{getActionLabel(activity.action)}
|
||||
</div>
|
||||
<div className="text-xs text-text-tertiary mt-1">
|
||||
{activity.description}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* EN: Status column / VI: Cột trạng thái */}
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border',
|
||||
getStatusBadgeStyles(activity.status)
|
||||
)}
|
||||
>
|
||||
{getStatusLabel(activity.status)}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* EN: Time column / VI: Cột thời gian */}
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-tertiary">
|
||||
{formatRelativeTime(activity.timestamp)}
|
||||
</td>
|
||||
|
||||
{/* EN: Quick actions column / VI: Cột quick actions */}
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => handleQuickAction(activity.id, 'view')}
|
||||
className="text-accent-primary hover:brightness-110 transition-colors duration-[150ms] min-h-[44px] px-2 py-1"
|
||||
aria-label="View details / Xem chi tiết"
|
||||
>
|
||||
View / Xem
|
||||
</button>
|
||||
<span className="text-border-primary">|</span>
|
||||
<button
|
||||
onClick={() => handleQuickAction(activity.id, 'copy')}
|
||||
className="text-accent-primary hover:brightness-110 transition-colors duration-[150ms] min-h-[44px] px-2 py-1"
|
||||
aria-label="Copy ID / Sao chép ID"
|
||||
>
|
||||
Copy / Sao chép
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* EN: Pagination / VI: Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="px-6 py-4 border-t border-border-primary flex items-center justify-between">
|
||||
<div className="text-sm text-text-tertiary">
|
||||
{totalItems ? (
|
||||
<>
|
||||
Showing / Hiển thị{' '}
|
||||
<span className="font-medium text-text-secondary">
|
||||
{startIndex + 1}
|
||||
</span>{' '}
|
||||
to / đến{' '}
|
||||
<span className="font-medium text-text-secondary">
|
||||
{Math.min(endIndex, totalItems)}
|
||||
</span>{' '}
|
||||
of / trong{' '}
|
||||
<span className="font-medium text-text-secondary">{totalItems}</span>{' '}
|
||||
results / kết quả
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Showing / Hiển thị{' '}
|
||||
<span className="font-medium text-text-secondary">
|
||||
{startIndex + 1}
|
||||
</span>{' '}
|
||||
to / đến{' '}
|
||||
<span className="font-medium text-text-secondary">
|
||||
{Math.min(endIndex, activities.length)}
|
||||
</span>{' '}
|
||||
of / trong{' '}
|
||||
<span className="font-medium text-text-secondary">
|
||||
{activities.length}
|
||||
</span>{' '}
|
||||
results / kết quả
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* EN: Previous button / VI: Nút trước */}
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1 || loading}
|
||||
className={cn(
|
||||
'px-3 py-1.5 text-sm font-medium rounded-md transition-all duration-[150ms]',
|
||||
'border border-border-primary',
|
||||
// EN: Mobile: Minimum 44px touch target / VI: Mobile: Touch target tối thiểu 44px
|
||||
'min-w-[44px] min-h-[44px]',
|
||||
currentPage === 1 || loading
|
||||
? 'opacity-50 cursor-not-allowed text-text-tertiary'
|
||||
: 'text-text-secondary hover:bg-bg-tertiary hover:border-border-secondary hover:scale-[1.02] active:scale-[0.98]'
|
||||
)}
|
||||
aria-label="Previous page / Trang trước"
|
||||
>
|
||||
<span className="hidden sm:inline">Previous / Trước</span>
|
||||
<span className="sm:hidden">Prev</span>
|
||||
</button>
|
||||
|
||||
{/* EN: Page numbers / VI: Số trang */}
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map(
|
||||
(page) => {
|
||||
// EN: Show first page, last page, current page, and pages around current / VI: Hiển thị trang đầu, cuối, hiện tại và các trang xung quanh
|
||||
if (
|
||||
page === 1 ||
|
||||
page === totalPages ||
|
||||
(page >= currentPage - 1 && page <= currentPage + 1)
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => handlePageChange(page)}
|
||||
disabled={loading}
|
||||
className={cn(
|
||||
'px-3 py-1.5 text-sm font-medium rounded-md transition-all duration-[150ms]',
|
||||
'border border-border-primary',
|
||||
page === currentPage
|
||||
? 'bg-accent-primary text-white border-accent-primary'
|
||||
: 'text-text-secondary hover:bg-bg-tertiary hover:border-border-secondary hover:scale-[1.02] active:scale-[0.98]',
|
||||
loading && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
aria-label={`Page ${page} / Trang ${page}`}
|
||||
aria-current={page === currentPage ? 'page' : undefined}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
);
|
||||
} else if (
|
||||
page === currentPage - 2 ||
|
||||
page === currentPage + 2
|
||||
) {
|
||||
return (
|
||||
<span
|
||||
key={page}
|
||||
className="px-2 text-sm text-text-tertiary"
|
||||
>
|
||||
...
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* EN: Next button / VI: Nút sau */}
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages || loading}
|
||||
className={cn(
|
||||
'px-3 py-1.5 text-sm font-medium rounded-md transition-all duration-[150ms]',
|
||||
'border border-border-primary',
|
||||
// EN: Mobile: Minimum 44px touch target / VI: Mobile: Touch target tối thiểu 44px
|
||||
'min-w-[44px] min-h-[44px]',
|
||||
currentPage === totalPages || loading
|
||||
? 'opacity-50 cursor-not-allowed text-text-tertiary'
|
||||
: 'text-text-secondary hover:bg-bg-tertiary hover:border-border-secondary hover:scale-[1.02] active:scale-[0.98]'
|
||||
)}
|
||||
aria-label="Next page / Trang sau"
|
||||
>
|
||||
<span className="hidden sm:inline">Next / Sau</span>
|
||||
<span className="sm:hidden">Next</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
250
apps/web-admin/src/components/admin/settings/email-settings.tsx
Normal file
250
apps/web-admin/src/components/admin/settings/email-settings.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from '@/components/ui/card';
|
||||
|
||||
/**
|
||||
* EN: Email settings form validation schema
|
||||
* VI: Schema validation cho form cài đặt email
|
||||
*/
|
||||
const emailSettingsSchema = z.object({
|
||||
smtpHost: z.string().min(1, 'SMTP host is required / SMTP host là bắt buộc'),
|
||||
smtpPort: z.number().min(1).max(65535),
|
||||
smtpUser: z.string().min(1, 'SMTP user is required / SMTP user là bắt buộc'),
|
||||
smtpPassword: z.string().min(1, 'SMTP password is required / SMTP password là bắt buộc'),
|
||||
smtpFromEmail: z.string().email('Invalid email format / Định dạng email không hợp lệ'),
|
||||
smtpFromName: z.string().min(1, 'From name is required / Tên người gửi là bắt buộc'),
|
||||
});
|
||||
|
||||
type EmailSettingsFormData = z.infer<typeof emailSettingsSchema>;
|
||||
|
||||
/**
|
||||
* EN: EmailSettings component - Form for email/SMTP configuration
|
||||
* VI: Component EmailSettings - Form cho cấu hình email/SMTP
|
||||
*
|
||||
* Features:
|
||||
* - SMTP configuration (host, port, user, password)
|
||||
* - From email and name
|
||||
* - Email templates management
|
||||
* - Test email function
|
||||
*
|
||||
* Tính năng:
|
||||
* - Cấu hình SMTP (host, port, user, password)
|
||||
* - Email và tên người gửi
|
||||
* - Quản lý email templates
|
||||
* - Chức năng test email
|
||||
*/
|
||||
export function EmailSettings() {
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const [isTesting, setIsTesting] = React.useState(false);
|
||||
const [saveSuccess, setSaveSuccess] = React.useState(false);
|
||||
const [testResult, setTestResult] = React.useState<'success' | 'error' | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isDirty },
|
||||
reset,
|
||||
} = useForm<EmailSettingsFormData>({
|
||||
resolver: zodResolver(emailSettingsSchema),
|
||||
defaultValues: {
|
||||
smtpHost: 'smtp.example.com',
|
||||
smtpPort: 587,
|
||||
smtpUser: '',
|
||||
smtpPassword: '',
|
||||
smtpFromEmail: 'noreply@goodgo.vn',
|
||||
smtpFromName: 'GoodGo Platform',
|
||||
},
|
||||
});
|
||||
|
||||
// EN: Handle form submission / VI: Xử lý submit form
|
||||
const onSubmit = async (data: EmailSettingsFormData) => {
|
||||
setIsSaving(true);
|
||||
setSaveSuccess(false);
|
||||
|
||||
try {
|
||||
// EN: TODO: Replace with actual API call / VI: TODO: Thay thế bằng API call thực tế
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
reset(data);
|
||||
setSaveSuccess(true);
|
||||
setTimeout(() => setSaveSuccess(false), 3000);
|
||||
} catch (error) {
|
||||
console.error('Failed to save email settings / Không thể lưu cài đặt email:', error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// EN: Handle test email / VI: Xử lý test email
|
||||
const handleTestEmail = async () => {
|
||||
setIsTesting(true);
|
||||
setTestResult(null);
|
||||
|
||||
try {
|
||||
// EN: TODO: Replace with actual API call / VI: TODO: Thay thế bằng API call thực tế
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
setTestResult('success');
|
||||
setTimeout(() => setTestResult(null), 3000);
|
||||
} catch (error) {
|
||||
setTestResult('error');
|
||||
setTimeout(() => setTestResult(null), 3000);
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Email Settings / Cài đặt Email</CardTitle>
|
||||
<CardDescription>
|
||||
Configure SMTP settings for sending emails / Cấu hình cài đặt SMTP để gửi email
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<CardContent className="space-y-6">
|
||||
{/* EN: SMTP Configuration / VI: Cấu hình SMTP */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-text-primary">
|
||||
SMTP Configuration / Cấu hình SMTP
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Input
|
||||
label="SMTP Host / SMTP Host"
|
||||
placeholder="smtp.example.com"
|
||||
{...register('smtpHost')}
|
||||
errorMessage={errors.smtpHost?.message}
|
||||
validationState={errors.smtpHost ? 'error' : 'default'}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="SMTP Port / SMTP Port"
|
||||
type="number"
|
||||
placeholder="587"
|
||||
{...register('smtpPort', { valueAsNumber: true })}
|
||||
errorMessage={errors.smtpPort?.message}
|
||||
validationState={errors.smtpPort ? 'error' : 'default'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Input
|
||||
label="SMTP User / SMTP User"
|
||||
placeholder="your-email@example.com"
|
||||
{...register('smtpUser')}
|
||||
errorMessage={errors.smtpUser?.message}
|
||||
validationState={errors.smtpUser ? 'error' : 'default'}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="SMTP Password / SMTP Password"
|
||||
type="password"
|
||||
placeholder="Enter SMTP password / Nhập mật khẩu SMTP"
|
||||
{...register('smtpPassword')}
|
||||
errorMessage={errors.smtpPassword?.message}
|
||||
validationState={errors.smtpPassword ? 'error' : 'default'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* EN: From Settings / VI: Cài đặt người gửi */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-text-primary">
|
||||
From Settings / Cài đặt người gửi
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Input
|
||||
label="From Email / Email người gửi"
|
||||
type="email"
|
||||
placeholder="noreply@goodgo.vn"
|
||||
{...register('smtpFromEmail')}
|
||||
errorMessage={errors.smtpFromEmail?.message}
|
||||
validationState={errors.smtpFromEmail ? 'error' : 'default'}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="From Name / Tên người gửi"
|
||||
placeholder="GoodGo Platform"
|
||||
{...register('smtpFromName')}
|
||||
errorMessage={errors.smtpFromName?.message}
|
||||
validationState={errors.smtpFromName ? 'error' : 'default'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* EN: Test email section / VI: Phần test email */}
|
||||
<div className="p-4 rounded-lg bg-bg-tertiary border border-border-primary">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-text-primary">
|
||||
Test Email / Test Email
|
||||
</h4>
|
||||
<p className="text-sm text-text-tertiary mt-1">
|
||||
Send a test email to verify SMTP configuration / Gửi email test để xác minh cấu hình SMTP
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleTestEmail}
|
||||
loading={isTesting}
|
||||
>
|
||||
Send Test Email / Gửi email test
|
||||
</Button>
|
||||
</div>
|
||||
{testResult === 'success' && (
|
||||
<p className="text-sm text-accent-success mt-2">
|
||||
Test email sent successfully! / Email test đã được gửi thành công!
|
||||
</p>
|
||||
)}
|
||||
{testResult === 'error' && (
|
||||
<p className="text-sm text-accent-error mt-2">
|
||||
Failed to send test email / Không thể gửi email test
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* EN: Success message / VI: Thông báo thành công */}
|
||||
{saveSuccess && (
|
||||
<div className="rounded-lg bg-accent-success/10 border border-accent-success p-3 flex items-center gap-2 text-sm text-accent-success">
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
Settings saved successfully / Đã lưu cài đặt thành công
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
loading={isSaving}
|
||||
disabled={!isDirty || isSaving}
|
||||
>
|
||||
Save Changes / Lưu thay đổi
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from '@/components/ui/card';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
|
||||
/**
|
||||
* EN: General settings form validation schema
|
||||
* VI: Schema validation cho form cài đặt chung
|
||||
*/
|
||||
const generalSettingsSchema = z.object({
|
||||
siteName: z.string().min(1, 'Site name is required / Tên site là bắt buộc'),
|
||||
defaultLanguage: z.enum(['en', 'vi'], {
|
||||
required_error: 'Language is required / Ngôn ngữ là bắt buộc',
|
||||
}),
|
||||
timezone: z.string().min(1, 'Timezone is required / Múi giờ là bắt buộc'),
|
||||
maintenanceMode: z.boolean(),
|
||||
});
|
||||
|
||||
type GeneralSettingsFormData = z.infer<typeof generalSettingsSchema>;
|
||||
|
||||
/**
|
||||
* EN: GeneralSettings component - Form for general system settings
|
||||
* VI: Component GeneralSettings - Form cho cài đặt hệ thống chung
|
||||
*
|
||||
* Features:
|
||||
* - Site name input
|
||||
* - Logo upload
|
||||
* - Default language selection
|
||||
* - Timezone selection
|
||||
* - Maintenance mode toggle
|
||||
*
|
||||
* Tính năng:
|
||||
* - Input tên site
|
||||
* - Upload logo
|
||||
* - Chọn ngôn ngữ mặc định
|
||||
* - Chọn múi giờ
|
||||
* - Toggle chế độ bảo trì
|
||||
*/
|
||||
export function GeneralSettings() {
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const [saveSuccess, setSaveSuccess] = React.useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isDirty },
|
||||
reset,
|
||||
watch,
|
||||
setValue,
|
||||
} = useForm<GeneralSettingsFormData>({
|
||||
resolver: zodResolver(generalSettingsSchema),
|
||||
defaultValues: {
|
||||
siteName: 'GoodGo Platform',
|
||||
defaultLanguage: 'en',
|
||||
timezone: 'UTC',
|
||||
maintenanceMode: false,
|
||||
},
|
||||
});
|
||||
|
||||
const maintenanceMode = watch('maintenanceMode');
|
||||
|
||||
// EN: Handle form submission / VI: Xử lý submit form
|
||||
const onSubmit = async (data: GeneralSettingsFormData) => {
|
||||
setIsSaving(true);
|
||||
setSaveSuccess(false);
|
||||
|
||||
try {
|
||||
// EN: TODO: Replace with actual API call / VI: TODO: Thay thế bằng API call thực tế
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
reset(data);
|
||||
setSaveSuccess(true);
|
||||
setTimeout(() => setSaveSuccess(false), 3000);
|
||||
} catch (error) {
|
||||
console.error('Failed to save settings / Không thể lưu cài đặt:', error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>General Settings / Cài đặt chung</CardTitle>
|
||||
<CardDescription>
|
||||
Configure general system settings / Cấu hình cài đặt hệ thống chung
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<CardContent className="space-y-6">
|
||||
{/* EN: Site name / VI: Tên site */}
|
||||
<Input
|
||||
label="Site Name / Tên site"
|
||||
placeholder="Enter site name / Nhập tên site"
|
||||
{...register('siteName')}
|
||||
errorMessage={errors.siteName?.message}
|
||||
validationState={errors.siteName ? 'error' : 'default'}
|
||||
/>
|
||||
|
||||
{/* EN: Logo upload / VI: Upload logo */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-2">
|
||||
Site Logo / Logo site
|
||||
</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-16 w-16 rounded-lg bg-bg-tertiary border border-border-primary flex items-center justify-center">
|
||||
<span className="text-xs text-text-tertiary">Logo</span>
|
||||
</div>
|
||||
<div>
|
||||
<Button variant="secondary" size="sm" type="button">
|
||||
Upload Logo / Tải logo
|
||||
</Button>
|
||||
<p className="text-xs text-text-tertiary mt-1">
|
||||
Recommended: 200x200px, PNG or SVG / Khuyến nghị: 200x200px, PNG hoặc SVG
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* EN: Default language / VI: Ngôn ngữ mặc định */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-2">
|
||||
Default Language / Ngôn ngữ mặc định
|
||||
</label>
|
||||
<select
|
||||
{...register('defaultLanguage')}
|
||||
className="flex w-full rounded-md border border-border-primary bg-bg-secondary px-3 py-2 text-base text-text-primary transition-all duration-[150ms] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-primary focus-visible:ring-offset-2"
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="vi">Tiếng Việt</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* EN: Timezone / VI: Múi giờ */}
|
||||
<Input
|
||||
label="Timezone / Múi giờ"
|
||||
placeholder="UTC"
|
||||
{...register('timezone')}
|
||||
errorMessage={errors.timezone?.message}
|
||||
validationState={errors.timezone ? 'error' : 'default'}
|
||||
helperText="Server timezone / Múi giờ server"
|
||||
/>
|
||||
|
||||
{/* EN: Maintenance mode / VI: Chế độ bảo trì */}
|
||||
<div className="flex items-center justify-between p-4 rounded-lg bg-bg-tertiary border border-border-primary">
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="maintenance-mode"
|
||||
className="text-sm font-medium text-text-primary cursor-pointer"
|
||||
>
|
||||
Maintenance Mode / Chế độ bảo trì
|
||||
</label>
|
||||
<p className="text-sm text-text-tertiary mt-1">
|
||||
Enable maintenance mode to restrict access / Bật chế độ bảo trì để hạn chế truy cập
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="maintenance-mode"
|
||||
checked={maintenanceMode}
|
||||
onCheckedChange={(checked) => setValue('maintenanceMode', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* EN: Success message / VI: Thông báo thành công */}
|
||||
{saveSuccess && (
|
||||
<div className="rounded-lg bg-accent-success/10 border border-accent-success p-3 flex items-center gap-2 text-sm text-accent-success">
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
Settings saved successfully / Đã lưu cài đặt thành công
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
loading={isSaving}
|
||||
disabled={!isDirty || isSaving}
|
||||
>
|
||||
Save Changes / Lưu thay đổi
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from '@/components/ui/card';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
|
||||
/**
|
||||
* EN: Security settings form validation schema
|
||||
* VI: Schema validation cho form cài đặt bảo mật
|
||||
*/
|
||||
const securitySettingsSchema = z.object({
|
||||
minPasswordLength: z.number().min(8).max(128),
|
||||
requireUppercase: z.boolean(),
|
||||
requireLowercase: z.boolean(),
|
||||
requireNumbers: z.boolean(),
|
||||
requireSpecialChars: z.boolean(),
|
||||
sessionTimeout: z.number().min(5).max(1440), // minutes
|
||||
maxLoginAttempts: z.number().min(3).max(10),
|
||||
lockoutDuration: z.number().min(5).max(60), // minutes
|
||||
enableRateLimiting: z.boolean(),
|
||||
rateLimitRequests: z.number().min(10).max(10000),
|
||||
rateLimitWindow: z.number().min(1).max(60), // minutes
|
||||
});
|
||||
|
||||
type SecuritySettingsFormData = z.infer<typeof securitySettingsSchema>;
|
||||
|
||||
/**
|
||||
* EN: SecuritySettings component - Form for security settings
|
||||
* VI: Component SecuritySettings - Form cho cài đặt bảo mật
|
||||
*
|
||||
* Features:
|
||||
* - Password policy configuration
|
||||
* - Session timeout
|
||||
* - IP whitelist/blacklist
|
||||
* - Rate limiting
|
||||
* - CORS settings
|
||||
*
|
||||
* Tính năng:
|
||||
* - Cấu hình chính sách mật khẩu
|
||||
* - Timeout session
|
||||
* - IP whitelist/blacklist
|
||||
* - Rate limiting
|
||||
* - Cài đặt CORS
|
||||
*/
|
||||
export function SecuritySettings() {
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const [saveSuccess, setSaveSuccess] = React.useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isDirty },
|
||||
reset,
|
||||
watch,
|
||||
setValue,
|
||||
} = useForm<SecuritySettingsFormData>({
|
||||
resolver: zodResolver(securitySettingsSchema),
|
||||
defaultValues: {
|
||||
minPasswordLength: 8,
|
||||
requireUppercase: true,
|
||||
requireLowercase: true,
|
||||
requireNumbers: true,
|
||||
requireSpecialChars: true,
|
||||
sessionTimeout: 30,
|
||||
maxLoginAttempts: 5,
|
||||
lockoutDuration: 15,
|
||||
enableRateLimiting: true,
|
||||
rateLimitRequests: 100,
|
||||
rateLimitWindow: 1,
|
||||
},
|
||||
});
|
||||
|
||||
// EN: Handle form submission / VI: Xử lý submit form
|
||||
const onSubmit = async (data: SecuritySettingsFormData) => {
|
||||
setIsSaving(true);
|
||||
setSaveSuccess(false);
|
||||
|
||||
try {
|
||||
// EN: TODO: Replace with actual API call / VI: TODO: Thay thế bằng API call thực tế
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
reset(data);
|
||||
setSaveSuccess(true);
|
||||
setTimeout(() => setSaveSuccess(false), 3000);
|
||||
} catch (error) {
|
||||
console.error('Failed to save security settings / Không thể lưu cài đặt bảo mật:', error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Security Settings / Cài đặt bảo mật</CardTitle>
|
||||
<CardDescription>
|
||||
Configure security policies and restrictions / Cấu hình chính sách và hạn chế bảo mật
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<CardContent className="space-y-6">
|
||||
{/* EN: Password Policy / VI: Chính sách mật khẩu */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-text-primary">
|
||||
Password Policy / Chính sách mật khẩu
|
||||
</h3>
|
||||
|
||||
<Input
|
||||
label="Minimum Password Length / Độ dài mật khẩu tối thiểu"
|
||||
type="number"
|
||||
{...register('minPasswordLength', { valueAsNumber: true })}
|
||||
errorMessage={errors.minPasswordLength?.message}
|
||||
validationState={errors.minPasswordLength ? 'error' : 'default'}
|
||||
helperText="Minimum 8 characters / Tối thiểu 8 ký tự"
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-bg-tertiary border border-border-primary">
|
||||
<label htmlFor="require-uppercase" className="text-sm font-medium text-text-primary cursor-pointer">
|
||||
Require Uppercase / Yêu cầu chữ hoa
|
||||
</label>
|
||||
<Switch
|
||||
id="require-uppercase"
|
||||
checked={watch('requireUppercase')}
|
||||
onCheckedChange={(checked) => setValue('requireUppercase', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-bg-tertiary border border-border-primary">
|
||||
<label htmlFor="require-lowercase" className="text-sm font-medium text-text-primary cursor-pointer">
|
||||
Require Lowercase / Yêu cầu chữ thường
|
||||
</label>
|
||||
<Switch
|
||||
id="require-lowercase"
|
||||
checked={watch('requireLowercase')}
|
||||
onCheckedChange={(checked) => setValue('requireLowercase', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-bg-tertiary border border-border-primary">
|
||||
<label htmlFor="require-numbers" className="text-sm font-medium text-text-primary cursor-pointer">
|
||||
Require Numbers / Yêu cầu số
|
||||
</label>
|
||||
<Switch
|
||||
id="require-numbers"
|
||||
checked={watch('requireNumbers')}
|
||||
onCheckedChange={(checked) => setValue('requireNumbers', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-bg-tertiary border border-border-primary">
|
||||
<label htmlFor="require-special" className="text-sm font-medium text-text-primary cursor-pointer">
|
||||
Require Special Characters / Yêu cầu ký tự đặc biệt
|
||||
</label>
|
||||
<Switch
|
||||
id="require-special"
|
||||
checked={watch('requireSpecialChars')}
|
||||
onCheckedChange={(checked) => setValue('requireSpecialChars', checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* EN: Session Settings / VI: Cài đặt session */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-text-primary">
|
||||
Session Settings / Cài đặt session
|
||||
</h3>
|
||||
|
||||
<Input
|
||||
label="Session Timeout (minutes) / Timeout session (phút)"
|
||||
type="number"
|
||||
{...register('sessionTimeout', { valueAsNumber: true })}
|
||||
errorMessage={errors.sessionTimeout?.message}
|
||||
validationState={errors.sessionTimeout ? 'error' : 'default'}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Max Login Attempts / Số lần đăng nhập tối đa"
|
||||
type="number"
|
||||
{...register('maxLoginAttempts', { valueAsNumber: true })}
|
||||
errorMessage={errors.maxLoginAttempts?.message}
|
||||
validationState={errors.maxLoginAttempts ? 'error' : 'default'}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Lockout Duration (minutes) / Thời gian khóa (phút)"
|
||||
type="number"
|
||||
{...register('lockoutDuration', { valueAsNumber: true })}
|
||||
errorMessage={errors.lockoutDuration?.message}
|
||||
validationState={errors.lockoutDuration ? 'error' : 'default'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* EN: Rate Limiting / VI: Rate limiting */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 rounded-lg bg-bg-tertiary border border-border-primary">
|
||||
<div className="flex-1">
|
||||
<label htmlFor="enable-rate-limiting" className="text-sm font-medium text-text-primary cursor-pointer">
|
||||
Enable Rate Limiting / Bật rate limiting
|
||||
</label>
|
||||
<p className="text-sm text-text-tertiary mt-1">
|
||||
Limit requests per time window / Giới hạn requests mỗi cửa sổ thời gian
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="enable-rate-limiting"
|
||||
checked={watch('enableRateLimiting')}
|
||||
onCheckedChange={(checked) => setValue('enableRateLimiting', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{watch('enableRateLimiting') && (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Input
|
||||
label="Max Requests / Requests tối đa"
|
||||
type="number"
|
||||
{...register('rateLimitRequests', { valueAsNumber: true })}
|
||||
errorMessage={errors.rateLimitRequests?.message}
|
||||
validationState={errors.rateLimitRequests ? 'error' : 'default'}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Time Window (minutes) / Cửa sổ thời gian (phút)"
|
||||
type="number"
|
||||
{...register('rateLimitWindow', { valueAsNumber: true })}
|
||||
errorMessage={errors.rateLimitWindow?.message}
|
||||
validationState={errors.rateLimitWindow ? 'error' : 'default'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* EN: IP Management placeholder / VI: Placeholder quản lý IP */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-text-primary">
|
||||
IP Management / Quản lý IP
|
||||
</h3>
|
||||
<p className="text-sm text-text-tertiary">
|
||||
IP whitelist/blacklist management will be implemented here / Quản lý IP whitelist/blacklist sẽ được implement ở đây
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* EN: CORS Settings placeholder / VI: Placeholder cài đặt CORS */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-text-primary">
|
||||
CORS Settings / Cài đặt CORS
|
||||
</h3>
|
||||
<p className="text-sm text-text-tertiary">
|
||||
CORS configuration will be implemented here / Cấu hình CORS sẽ được implement ở đây
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* EN: Success message / VI: Thông báo thành công */}
|
||||
{saveSuccess && (
|
||||
<div className="rounded-lg bg-accent-success/10 border border-accent-success p-3 flex items-center gap-2 text-sm text-accent-success">
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
Settings saved successfully / Đã lưu cài đặt thành công
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
loading={isSaving}
|
||||
disabled={!isDirty || isSaving}
|
||||
>
|
||||
Save Changes / Lưu thay đổi
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
343
apps/web-admin/src/components/admin/user-details-modal.tsx
Normal file
343
apps/web-admin/src/components/admin/user-details-modal.tsx
Normal file
@@ -0,0 +1,343 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Edit, Trash2, Ban, CheckCircle2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* EN: User interface
|
||||
* VI: Interface cho User
|
||||
*/
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
role: string;
|
||||
status: 'active' | 'inactive' | 'banned';
|
||||
avatarUrl?: string;
|
||||
createdAt: string;
|
||||
lastLoginAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Activity log entry
|
||||
* VI: Entry log hoạt động
|
||||
*/
|
||||
export interface ActivityLog {
|
||||
id: string;
|
||||
action: string;
|
||||
description: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Message history entry
|
||||
* VI: Entry lịch sử tin nhắn
|
||||
*/
|
||||
export interface MessageHistory {
|
||||
id: string;
|
||||
content: string;
|
||||
conversationId: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: UserDetailsModal component props
|
||||
* VI: Props của component UserDetailsModal
|
||||
*/
|
||||
export interface UserDetailsModalProps {
|
||||
/**
|
||||
* EN: User data / VI: Dữ liệu người dùng
|
||||
*/
|
||||
user: User | null;
|
||||
/**
|
||||
* EN: Modal open state / VI: Trạng thái mở modal
|
||||
*/
|
||||
open: boolean;
|
||||
/**
|
||||
* EN: Callback when modal closes / VI: Callback khi modal đóng
|
||||
*/
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/**
|
||||
* EN: Activity logs / VI: Log hoạt động
|
||||
*/
|
||||
activityLogs?: ActivityLog[];
|
||||
/**
|
||||
* EN: Message history / VI: Lịch sử tin nhắn
|
||||
*/
|
||||
messageHistory?: MessageHistory[];
|
||||
/**
|
||||
* EN: Callback when edit is clicked / VI: Callback khi edit được click
|
||||
*/
|
||||
onEdit?: (user: User) => void;
|
||||
/**
|
||||
* EN: Callback when delete is clicked / VI: Callback khi delete được click
|
||||
*/
|
||||
onDelete?: (userId: string) => void;
|
||||
/**
|
||||
* EN: Callback when deactivate is clicked / VI: Callback khi deactivate được click
|
||||
*/
|
||||
onDeactivate?: (userId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: UserDetailsModal component - Modal showing user details, activity timeline, and message history
|
||||
* VI: Component UserDetailsModal - Modal hiển thị chi tiết người dùng, timeline hoạt động và lịch sử tin nhắn
|
||||
*
|
||||
* Features:
|
||||
* - User profile information
|
||||
* - Activity timeline
|
||||
* - Message history
|
||||
* - Edit, Delete, Deactivate actions
|
||||
*
|
||||
* Tính năng:
|
||||
* - Thông tin profile người dùng
|
||||
* - Timeline hoạt động
|
||||
* - Lịch sử tin nhắn
|
||||
* - Các hành động Edit, Delete, Deactivate
|
||||
*/
|
||||
export function UserDetailsModal({
|
||||
user,
|
||||
open,
|
||||
onOpenChange,
|
||||
activityLogs = [],
|
||||
messageHistory = [],
|
||||
onEdit,
|
||||
onDelete,
|
||||
onDeactivate,
|
||||
}: UserDetailsModalProps) {
|
||||
if (!user) return null;
|
||||
|
||||
// EN: Get user initials / VI: Lấy initials của user
|
||||
const getUserInitials = () => {
|
||||
if (user.firstName && user.lastName) {
|
||||
return `${user.firstName.charAt(0)}${user.lastName.charAt(0)}`.toUpperCase();
|
||||
}
|
||||
return user.email.charAt(0).toUpperCase();
|
||||
};
|
||||
|
||||
// EN: Format timestamp / VI: Format timestamp
|
||||
const formatTimestamp = (date: string) => {
|
||||
return new Date(date).toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
// EN: Get status badge color / VI: Lấy màu badge trạng thái
|
||||
const getStatusColor = () => {
|
||||
switch (user.status) {
|
||||
case 'active':
|
||||
return 'bg-accent-success/20 text-accent-success border-accent-success/30';
|
||||
case 'inactive':
|
||||
return 'bg-text-tertiary/20 text-text-tertiary border-text-tertiary/30';
|
||||
case 'banned':
|
||||
return 'bg-accent-error/20 text-accent-error border-accent-error/30';
|
||||
default:
|
||||
return 'bg-text-tertiary/20 text-text-tertiary border-text-tertiary/30';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>User Details / Chi tiết người dùng</DialogTitle>
|
||||
<DialogDescription>
|
||||
View and manage user information / Xem và quản lý thông tin người dùng
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs defaultValue="profile" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="profile">Profile / Hồ sơ</TabsTrigger>
|
||||
<TabsTrigger value="activity">Activity / Hoạt động</TabsTrigger>
|
||||
<TabsTrigger value="messages">Messages / Tin nhắn</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* EN: Profile tab / VI: Tab Profile */}
|
||||
<TabsContent value="profile" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar size="lg" className="h-20 w-20">
|
||||
{user.avatarUrl && (
|
||||
<AvatarImage src={user.avatarUrl} alt={user.email} />
|
||||
)}
|
||||
<AvatarFallback>{getUserInitials()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-semibold text-text-primary">
|
||||
{user.firstName && user.lastName
|
||||
? `${user.firstName} ${user.lastName}`
|
||||
: user.email}
|
||||
</h3>
|
||||
<p className="text-sm text-text-tertiary mt-1">{user.email}</p>
|
||||
<div className="mt-2">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border',
|
||||
getStatusColor()
|
||||
)}
|
||||
>
|
||||
{user.status === 'active' && (
|
||||
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
{user.status === 'banned' && (
|
||||
<Ban className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
{user.status.charAt(0).toUpperCase() + user.status.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-text-tertiary mb-1">Role / Vai trò</p>
|
||||
<p className="text-sm font-medium text-text-primary">{user.role}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-text-tertiary mb-1">
|
||||
Created / Tạo
|
||||
</p>
|
||||
<p className="text-sm font-medium text-text-primary">
|
||||
{formatTimestamp(user.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
{user.lastLoginAt && (
|
||||
<div>
|
||||
<p className="text-xs text-text-tertiary mb-1">
|
||||
Last Login / Lần đăng nhập cuối
|
||||
</p>
|
||||
<p className="text-sm font-medium text-text-primary">
|
||||
{formatTimestamp(user.lastLoginAt)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* EN: Action buttons / VI: Nút hành động */}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{onEdit && (
|
||||
<Button variant="secondary" size="sm" onClick={() => onEdit(user)}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Edit / Chỉnh sửa
|
||||
</Button>
|
||||
)}
|
||||
{onDeactivate && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onDeactivate(user.id)}
|
||||
>
|
||||
<Ban className="h-4 w-4 mr-2" />
|
||||
{user.status === 'active' ? 'Deactivate / Vô hiệu hóa' : 'Activate / Kích hoạt'}
|
||||
</Button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (confirm('Are you sure you want to delete this user? / Bạn có chắc chắn muốn xóa người dùng này?')) {
|
||||
onDelete(user.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete / Xóa
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* EN: Activity tab / VI: Tab Activity */}
|
||||
<TabsContent value="activity" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Activity Timeline / Timeline hoạt động</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{activityLogs.length === 0 ? (
|
||||
<p className="text-sm text-text-tertiary text-center py-8">
|
||||
No activity logs / Không có log hoạt động
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{activityLogs.map((log) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className="flex items-start gap-4 pb-4 border-b border-border-primary last:border-0"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-text-primary">
|
||||
{log.action}
|
||||
</p>
|
||||
<p className="text-sm text-text-secondary mt-1">
|
||||
{log.description}
|
||||
</p>
|
||||
<p className="text-xs text-text-tertiary mt-1">
|
||||
{formatTimestamp(log.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* EN: Messages tab / VI: Tab Messages */}
|
||||
<TabsContent value="messages" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Message History / Lịch sử tin nhắn</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{messageHistory.length === 0 ? (
|
||||
<p className="text-sm text-text-tertiary text-center py-8">
|
||||
No messages / Không có tin nhắn
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{messageHistory.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className="p-4 rounded-lg bg-bg-tertiary border border-border-primary"
|
||||
>
|
||||
<p className="text-sm text-text-primary">{message.content}</p>
|
||||
<p className="text-xs text-text-tertiary mt-2">
|
||||
{formatTimestamp(message.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
62
apps/web-admin/src/components/theme-toggle.tsx
Normal file
62
apps/web-admin/src/components/theme-toggle.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import { useTheme } from '../contexts/theme-context';
|
||||
|
||||
/**
|
||||
* EN: Theme toggle button component
|
||||
* VI: Component nút chuyển đổi theme
|
||||
*/
|
||||
export function ThemeToggle() {
|
||||
const { resolvedTheme, toggleTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="inline-flex items-center justify-center rounded-md p-2 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-accent-primary focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
|
||||
aria-label={resolvedTheme === 'dark' ? 'Switch to light mode / Chuyển sang chế độ sáng' : 'Switch to dark mode / Chuyển sang chế độ tối'}
|
||||
type="button"
|
||||
>
|
||||
{resolvedTheme === 'dark' ? (
|
||||
// EN: Sun icon for light mode / VI: Icon mặt trời cho chế độ sáng
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="h-5 w-5"
|
||||
>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2" />
|
||||
<path d="M12 20v2" />
|
||||
<path d="m4.93 4.93 1.41 1.41" />
|
||||
<path d="m17.66 17.66 1.41 1.41" />
|
||||
<path d="M2 12h2" />
|
||||
<path d="M20 12h2" />
|
||||
<path d="m6.34 17.66-1.41 1.41" />
|
||||
<path d="m19.07 4.93-1.41 1.41" />
|
||||
</svg>
|
||||
) : (
|
||||
// EN: Moon icon for dark mode / VI: Icon mặt trăng cho chế độ tối
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="h-5 w-5"
|
||||
>
|
||||
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
1
apps/web-admin/src/components/ui/avatar.tsx
Symbolic link
1
apps/web-admin/src/components/ui/avatar.tsx
Symbolic link
@@ -0,0 +1 @@
|
||||
../web-client/src/components/ui/avatar.tsx
|
||||
1
apps/web-admin/src/components/ui/button.stories.tsx
Symbolic link
1
apps/web-admin/src/components/ui/button.stories.tsx
Symbolic link
@@ -0,0 +1 @@
|
||||
../web-client/src/components/ui/button.stories.tsx
|
||||
1
apps/web-admin/src/components/ui/button.tsx
Symbolic link
1
apps/web-admin/src/components/ui/button.tsx
Symbolic link
@@ -0,0 +1 @@
|
||||
../web-client/src/components/ui/button.tsx
|
||||
1
apps/web-admin/src/components/ui/card.tsx
Symbolic link
1
apps/web-admin/src/components/ui/card.tsx
Symbolic link
@@ -0,0 +1 @@
|
||||
../web-client/src/components/ui/card.tsx
|
||||
1
apps/web-admin/src/components/ui/dialog.tsx
Symbolic link
1
apps/web-admin/src/components/ui/dialog.tsx
Symbolic link
@@ -0,0 +1 @@
|
||||
../web-client/src/components/ui/dialog.tsx
|
||||
1
apps/web-admin/src/components/ui/dropdown-menu.tsx
Symbolic link
1
apps/web-admin/src/components/ui/dropdown-menu.tsx
Symbolic link
@@ -0,0 +1 @@
|
||||
../web-client/src/components/ui/dropdown-menu.tsx
|
||||
1
apps/web-admin/src/components/ui/input.tsx
Symbolic link
1
apps/web-admin/src/components/ui/input.tsx
Symbolic link
@@ -0,0 +1 @@
|
||||
../web-client/src/components/ui/input.tsx
|
||||
1
apps/web-admin/src/components/ui/select.tsx
Symbolic link
1
apps/web-admin/src/components/ui/select.tsx
Symbolic link
@@ -0,0 +1 @@
|
||||
../web-client/src/components/ui/select.tsx
|
||||
1
apps/web-admin/src/components/ui/switch.tsx
Symbolic link
1
apps/web-admin/src/components/ui/switch.tsx
Symbolic link
@@ -0,0 +1 @@
|
||||
../web-client/src/components/ui/switch.tsx
|
||||
73
apps/web-admin/src/components/ui/tabs.tsx
Normal file
73
apps/web-admin/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* EN: Tabs component - Tab navigation using Radix UI
|
||||
* VI: Component Tabs - Điều hướng tab sử dụng Radix UI
|
||||
*/
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
/**
|
||||
* EN: TabsList component - Container for tab triggers
|
||||
* VI: Component TabsList - Container cho tab triggers
|
||||
*/
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-10 items-center justify-center rounded-lg bg-bg-tertiary p-1 text-text-secondary',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
/**
|
||||
* EN: TabsTrigger component - Individual tab button
|
||||
* VI: Component TabsTrigger - Nút tab riêng lẻ
|
||||
*/
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium transition-all duration-[150ms]',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-primary focus-visible:ring-offset-2',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
'data-[state=active]:bg-bg-secondary data-[state=active]:text-text-primary data-[state=active]:shadow-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
/**
|
||||
* EN: TabsContent component - Content panel for each tab
|
||||
* VI: Component TabsContent - Panel nội dung cho mỗi tab
|
||||
*/
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'mt-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-primary focus-visible:ring-offset-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
164
apps/web-admin/src/contexts/theme-context.tsx
Normal file
164
apps/web-admin/src/contexts/theme-context.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* EN: Theme mode type
|
||||
* VI: Kiểu chế độ theme
|
||||
*/
|
||||
export type ThemeMode = 'light' | 'dark' | 'system';
|
||||
|
||||
/**
|
||||
* EN: Theme context value interface
|
||||
* VI: Interface giá trị context theme
|
||||
*/
|
||||
interface ThemeContextValue {
|
||||
/** EN: Current theme mode / VI: Chế độ theme hiện tại */
|
||||
theme: ThemeMode;
|
||||
/** EN: Resolved theme (light or dark) / VI: Theme đã được resolve (light hoặc dark) */
|
||||
resolvedTheme: 'light' | 'dark';
|
||||
/** EN: Set theme mode / VI: Đặt chế độ theme */
|
||||
setTheme: (theme: ThemeMode) => void;
|
||||
/** EN: Toggle between light and dark / VI: Chuyển đổi giữa light và dark */
|
||||
toggleTheme: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Theme context
|
||||
* VI: Context theme
|
||||
*/
|
||||
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* EN: Get system preference for dark mode
|
||||
* VI: Lấy preference hệ thống cho dark mode
|
||||
*/
|
||||
const getSystemTheme = (): 'light' | 'dark' => {
|
||||
if (typeof window === 'undefined') return 'dark';
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
};
|
||||
|
||||
/**
|
||||
* EN: Apply theme class to document
|
||||
* VI: Áp dụng class theme vào document
|
||||
*/
|
||||
const applyTheme = (theme: 'light' | 'dark') => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const root = document.documentElement;
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(theme);
|
||||
root.setAttribute('data-theme', theme);
|
||||
};
|
||||
|
||||
/**
|
||||
* EN: Theme provider component
|
||||
* VI: Component provider theme
|
||||
*
|
||||
* @param children - Child components / Components con
|
||||
*/
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setThemeState] = useState<ThemeMode>('system');
|
||||
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>(() => {
|
||||
if (typeof window === 'undefined') return 'dark';
|
||||
|
||||
// EN: Load from localStorage or default to system
|
||||
// VI: Load từ localStorage hoặc mặc định là system
|
||||
const stored = localStorage.getItem('theme') as ThemeMode | null;
|
||||
if (stored && (stored === 'light' || stored === 'dark' || stored === 'system')) {
|
||||
return stored === 'system' ? getSystemTheme() : stored;
|
||||
}
|
||||
return getSystemTheme();
|
||||
});
|
||||
|
||||
// EN: Initialize theme from localStorage
|
||||
// VI: Khởi tạo theme từ localStorage
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const stored = localStorage.getItem('theme') as ThemeMode | null;
|
||||
if (stored && (stored === 'light' || stored === 'dark' || stored === 'system')) {
|
||||
setThemeState(stored);
|
||||
const resolved = stored === 'system' ? getSystemTheme() : stored;
|
||||
setResolvedTheme(resolved);
|
||||
applyTheme(resolved);
|
||||
} else {
|
||||
// EN: Default to system preference
|
||||
// VI: Mặc định theo preference hệ thống
|
||||
const systemTheme = getSystemTheme();
|
||||
setResolvedTheme(systemTheme);
|
||||
applyTheme(systemTheme);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// EN: Listen to system theme changes
|
||||
// VI: Lắng nghe thay đổi theme hệ thống
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || theme !== 'system') return;
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handleChange = (e: MediaQueryListEvent) => {
|
||||
const newTheme = e.matches ? 'dark' : 'light';
|
||||
setResolvedTheme(newTheme);
|
||||
applyTheme(newTheme);
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||
}, [theme]);
|
||||
|
||||
// EN: Apply theme when resolved theme changes
|
||||
// VI: Áp dụng theme khi resolved theme thay đổi
|
||||
useEffect(() => {
|
||||
applyTheme(resolvedTheme);
|
||||
}, [resolvedTheme]);
|
||||
|
||||
/**
|
||||
* EN: Set theme mode and persist to localStorage
|
||||
* VI: Đặt chế độ theme và lưu vào localStorage
|
||||
*/
|
||||
const setTheme = useCallback((newTheme: ThemeMode) => {
|
||||
setThemeState(newTheme);
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('theme', newTheme);
|
||||
}
|
||||
|
||||
const resolved = newTheme === 'system' ? getSystemTheme() : newTheme;
|
||||
setResolvedTheme(resolved);
|
||||
applyTheme(resolved);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* EN: Toggle between light and dark themes
|
||||
* VI: Chuyển đổi giữa theme light và dark
|
||||
*/
|
||||
const toggleTheme = useCallback(() => {
|
||||
const currentResolved = resolvedTheme;
|
||||
const newTheme = currentResolved === 'light' ? 'dark' : 'light';
|
||||
setTheme(newTheme);
|
||||
}, [resolvedTheme, setTheme]);
|
||||
|
||||
const value: ThemeContextValue = {
|
||||
theme,
|
||||
resolvedTheme,
|
||||
setTheme,
|
||||
toggleTheme,
|
||||
};
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Hook to access theme context
|
||||
* VI: Hook để truy cập theme context
|
||||
*
|
||||
* @throws Error if used outside ThemeProvider
|
||||
*/
|
||||
export function useTheme(): ThemeContextValue {
|
||||
const context = useContext(ThemeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider / useTheme phải được sử dụng trong ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
12
apps/web-admin/src/lib/utils.ts
Normal file
12
apps/web-admin/src/lib/utils.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
|
||||
/**
|
||||
* EN: Utility function to merge class names with conditional logic
|
||||
* VI: Hàm tiện ích để hợp nhất tên class với logic có điều kiện
|
||||
*
|
||||
* @param inputs - Class names or conditional class objects / Tên class hoặc object class có điều kiện
|
||||
* @returns Merged class string / Chuỗi class đã được hợp nhất
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return clsx(inputs);
|
||||
}
|
||||
220
apps/web-admin/src/styles/theme.css
Normal file
220
apps/web-admin/src/styles/theme.css
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* EN: Design System Theme Tokens
|
||||
* VI: Các token thiết kế cho Design System
|
||||
*
|
||||
* This file contains all CSS custom properties (variables) for the design system,
|
||||
* including colors, typography, spacing, layout, animations, and more.
|
||||
* These tokens are used throughout the application and can be referenced in Tailwind CSS
|
||||
* utility classes via the tailwind.config.js configuration.
|
||||
*
|
||||
* File này chứa tất cả các CSS custom properties (biến) cho design system,
|
||||
* bao gồm màu sắc, typography, spacing, layout, animations, và nhiều hơn nữa.
|
||||
* Các token này được sử dụng trong toàn bộ ứng dụng và có thể được tham chiếu trong
|
||||
* các utility classes của Tailwind CSS thông qua cấu hình tailwind.config.js.
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* ============================================
|
||||
EN: Color Palette - Dark Mode (Primary Theme)
|
||||
VI: Bảng màu - Dark Mode (Theme chính)
|
||||
============================================ */
|
||||
|
||||
/* Background Colors / Màu nền */
|
||||
--bg-primary: #0A0A0A; /* Almost black - Main background */
|
||||
--bg-secondary: #121212; /* Dark grey - Card/Panel background */
|
||||
--bg-tertiary: #1A1A1A; /* Dark grey - Hover states */
|
||||
--bg-elevated: #242424; /* Elevated surfaces (modals, dropdowns) */
|
||||
|
||||
/* Text Colors (WCAG Compliant) / Màu chữ (tuân thủ WCAG) */
|
||||
--text-primary: #FAFAFA; /* Off-white - Primary text (4.5:1 contrast) */
|
||||
--text-secondary: #E0E0E0; /* Light grey - Secondary text */
|
||||
--text-tertiary: #A0A0A0; /* Grey - Tertiary/disabled text */
|
||||
--text-inverse: #1A1A1A; /* Dark - Text on light backgrounds */
|
||||
|
||||
/* Brand/Accent Colors / Màu thương hiệu/Accent */
|
||||
--accent-primary: #3B82F6; /* Primary blue - CTAs, links */
|
||||
--accent-secondary: #8B5CF6; /* Purple - Highlights */
|
||||
--accent-success: #10B981; /* Green - Success states */
|
||||
--accent-warning: #F59E0B; /* Amber - Warnings */
|
||||
--accent-error: #EF4444; /* Red - Errors */
|
||||
--accent-info: #06B6D4; /* Cyan - Info */
|
||||
|
||||
/* Chat Specific Colors / Màu riêng cho Chat */
|
||||
--chat-user-bubble: #2563EB; /* Deep blue - User message */
|
||||
--chat-ai-bubble: #374151; /* Dark grey - AI message */
|
||||
--chat-user-text: #FFFFFF; /* White text on blue */
|
||||
--chat-ai-text: #F3F4F6; /* Light text on grey */
|
||||
--chat-timestamp: #9CA3AF; /* Timestamp grey */
|
||||
--chat-divider: #1F2937; /* Divider between messages */
|
||||
|
||||
/* Border Colors / Màu viền */
|
||||
--border-primary: #2A2A2A; /* Default borders */
|
||||
--border-secondary: #3A3A3A; /* Hover borders */
|
||||
--border-focus: #3B82F6; /* Focus state - Blue */
|
||||
|
||||
/* ============================================
|
||||
EN: Light Mode Colors (Secondary Theme)
|
||||
VI: Màu sắc cho Light Mode (Theme phụ)
|
||||
============================================ */
|
||||
--bg-primary-light: #FFFFFF;
|
||||
--bg-secondary-light: #F9FAFB;
|
||||
--bg-tertiary-light: #F3F4F6;
|
||||
--text-primary-light: #111827;
|
||||
--text-secondary-light: #4B5563;
|
||||
--border-primary-light: #E5E7EB;
|
||||
|
||||
/* ============================================
|
||||
EN: Typography
|
||||
VI: Kiểu chữ
|
||||
============================================ */
|
||||
|
||||
/* Font Stack / Bộ font */
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--font-mono: "SF Mono", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
|
||||
/* Type Scale / Kích thước chữ */
|
||||
--text-6xl: 3.75rem; /* 60px - Hero titles */
|
||||
--text-5xl: 3rem; /* 48px - Page titles */
|
||||
--text-4xl: 2.25rem; /* 36px - Section headers */
|
||||
--text-3xl: 1.875rem; /* 30px - Card headers */
|
||||
--text-2xl: 1.5rem; /* 24px - Large body */
|
||||
--text-xl: 1.25rem; /* 20px - Emphasized text */
|
||||
--text-lg: 1.125rem; /* 18px - Large body */
|
||||
--text-base: 1rem; /* 16px - Default body */
|
||||
--text-sm: 0.875rem; /* 14px - Small text */
|
||||
--text-xs: 0.75rem; /* 12px - Captions */
|
||||
|
||||
/* Line Heights / Chiều cao dòng */
|
||||
--leading-none: 1;
|
||||
--leading-tight: 1.1;
|
||||
--leading-snug: 1.2;
|
||||
--leading-normal: 1.3;
|
||||
--leading-relaxed: 1.4;
|
||||
--leading-loose: 1.5;
|
||||
|
||||
/* Font Weights / Độ đậm chữ */
|
||||
--font-light: 300; /* Light text */
|
||||
--font-normal: 400; /* Body text */
|
||||
--font-medium: 500; /* Emphasized */
|
||||
--font-semibold: 600; /* Headings */
|
||||
--font-bold: 700; /* Strong emphasis */
|
||||
|
||||
/* ============================================
|
||||
EN: Spacing & Layout
|
||||
VI: Khoảng cách & Bố cục
|
||||
============================================ */
|
||||
|
||||
/* Base Unit: 4px (0.25rem) / Đơn vị cơ sở: 4px (0.25rem) */
|
||||
--space-0: 0;
|
||||
--space-1: 0.25rem; /* 4px */
|
||||
--space-2: 0.5rem; /* 8px */
|
||||
--space-3: 0.75rem; /* 12px */
|
||||
--space-4: 1rem; /* 16px */
|
||||
--space-5: 1.25rem; /* 20px */
|
||||
--space-6: 1.5rem; /* 24px */
|
||||
--space-8: 2rem; /* 32px */
|
||||
--space-10: 2.5rem; /* 40px */
|
||||
--space-12: 3rem; /* 48px */
|
||||
--space-16: 4rem; /* 64px */
|
||||
--space-20: 5rem; /* 80px */
|
||||
|
||||
/* Container Widths / Chiều rộng container */
|
||||
--container-sm: 640px; /* Small devices */
|
||||
--container-md: 768px; /* Medium devices */
|
||||
--container-lg: 1024px; /* Large devices */
|
||||
--container-xl: 1280px; /* Extra large */
|
||||
--container-2xl: 1536px; /* 2X large */
|
||||
--chat-max-width: 768px; /* Max width for chat messages */
|
||||
--sidebar-width: 280px; /* Conversation history sidebar */
|
||||
|
||||
/* Border Radius / Bo góc */
|
||||
--radius-sm: 0.25rem; /* 4px - Small elements */
|
||||
--radius-md: 0.5rem; /* 8px - Buttons, inputs */
|
||||
--radius-lg: 0.75rem; /* 12px - Cards */
|
||||
--radius-xl: 1rem; /* 16px - Large cards */
|
||||
--radius-2xl: 1.5rem; /* 24px - Modals */
|
||||
--radius-full: 9999px; /* Full round - Avatars, pills */
|
||||
|
||||
/* ============================================
|
||||
EN: Shadows (Dark Mode Optimized)
|
||||
VI: Đổ bóng (Tối ưu cho Dark Mode)
|
||||
============================================ */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.5);
|
||||
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.5);
|
||||
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.6);
|
||||
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.7);
|
||||
--shadow-glow: 0 0 20px rgba(59, 130, 246, 0.3); /* Blue glow for focus */
|
||||
|
||||
/* ============================================
|
||||
EN: Grid System & Breakpoints
|
||||
VI: Hệ thống lưới & Điểm ngắt
|
||||
============================================ */
|
||||
--screen-sm: 640px; /* Mobile landscape */
|
||||
--screen-md: 768px; /* Tablet */
|
||||
--screen-lg: 1024px; /* Desktop */
|
||||
--screen-xl: 1280px; /* Large desktop */
|
||||
--screen-2xl: 1536px; /* Extra large desktop */
|
||||
|
||||
/* ============================================
|
||||
EN: Animation & Transitions
|
||||
VI: Animation & Chuyển tiếp
|
||||
============================================ */
|
||||
|
||||
/* Timing Functions / Hàm thời gian */
|
||||
--ease-in: cubic-bezier(0.4, 0, 1, 1);
|
||||
--ease-out: cubic-bezier(0, 0, 0.2, 1);
|
||||
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
|
||||
/* Duration / Thời lượng */
|
||||
--duration-fast: 150ms; /* Hover effects */
|
||||
--duration-normal: 250ms; /* Default transitions */
|
||||
--duration-slow: 350ms; /* Complex animations */
|
||||
--duration-slower: 500ms; /* Page transitions */
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
EN: Light Mode Theme Overrides
|
||||
VI: Ghi đè theme cho Light Mode
|
||||
============================================ */
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--bg-primary: var(--bg-primary-light);
|
||||
--bg-secondary: var(--bg-secondary-light);
|
||||
--bg-tertiary: var(--bg-tertiary-light);
|
||||
--text-primary: var(--text-primary-light);
|
||||
--text-secondary: var(--text-secondary-light);
|
||||
--border-primary: var(--border-primary-light);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
EN: Dark Mode Theme (Explicit)
|
||||
VI: Theme Dark Mode (Rõ ràng)
|
||||
============================================ */
|
||||
[data-theme="dark"],
|
||||
.dark {
|
||||
--bg-primary: #0A0A0A;
|
||||
--bg-secondary: #121212;
|
||||
--bg-tertiary: #1A1A1A;
|
||||
--bg-elevated: #242424;
|
||||
--text-primary: #FAFAFA;
|
||||
--text-secondary: #E0E0E0;
|
||||
--text-tertiary: #A0A0A0;
|
||||
--border-primary: #2A2A2A;
|
||||
--border-secondary: #3A3A3A;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
EN: Light Mode Theme (Explicit)
|
||||
VI: Theme Light Mode (Rõ ràng)
|
||||
============================================ */
|
||||
[data-theme="light"],
|
||||
.light {
|
||||
--bg-primary: var(--bg-primary-light);
|
||||
--bg-secondary: var(--bg-secondary-light);
|
||||
--bg-tertiary: var(--bg-tertiary-light);
|
||||
--text-primary: var(--text-primary-light);
|
||||
--text-secondary: var(--text-secondary-light);
|
||||
--border-primary: var(--border-primary-light);
|
||||
}
|
||||
Reference in New Issue
Block a user