Commit 4f3aa682 authored by ThinhNC's avatar ThinhNC

Merge branch 'feat/theme-and-i18n-support' into 'develop'

feat(ui): implement light-dark-system theme and bilingual en-vi support

See merge request !2
parents a4c11554 cda8a149
...@@ -20,3 +20,8 @@ trigger: always_on ...@@ -20,3 +20,8 @@ trigger: always_on
- **Bảng màu**: Emerald/Forest Green (`#10b981`, `#059669`), Sage Green (`#A8D5BA`), Terracotta/Clay (`#C67B5C`), Cát ấm/Cream (`#F5F5DC`), Nền tối rêu sâu (`#090d16`, `#061a14`). - **Bảng màu**: Emerald/Forest Green (`#10b981`, `#059669`), Sage Green (`#A8D5BA`), Terracotta/Clay (`#C67B5C`), Cát ấm/Cream (`#F5F5DC`), Nền tối rêu sâu (`#090d16`, `#061a14`).
- **Thành phần đặc trưng**: Thẻ Impact Metrics (KPI đo lường), Interactive Calculator/Filter preview, Certification & Status Badges bo góc pill, bo góc hữu cơ `rounded-2xl` - `rounded-3xl`, viền mờ `border-emerald-500/15`. - **Thành phần đặc trưng**: Thẻ Impact Metrics (KPI đo lường), Interactive Calculator/Filter preview, Certification & Status Badges bo góc pill, bo góc hữu cơ `rounded-2xl` - `rounded-3xl`, viền mờ `border-emerald-500/15`.
- **Tránh**: Màu neon gắt, gradient tím/hồng kiểu AI, góc cạnh vuông sắc nhọn. - **Tránh**: Màu neon gắt, gradient tím/hồng kiểu AI, góc cạnh vuông sắc nhọn.
4. **Đa Giao Diện & Song Ngữ (Bắt buộc ghi nhớ)**:
- **3 Themes**: Light (nền cát ấm/be `#FAF7F2`), Dark (nền rêu sẫm `#090d16`), System (theo OS). Sử dụng `next-themes`.
- **Song ngữ Anh - Việt**: Mọi UI text, dialog, label, thông báo lỗi phải hỗ trợ song ngữ (`vi` & `en`), lưu trong `localStorage`.
- **Navbar**: Luôn có component `ThemeToggle``LanguageToggle`.
...@@ -79,3 +79,16 @@ Tất cả các trang, component, bảng thống kê, dashboard và form phát t ...@@ -79,3 +79,16 @@ Tất cả các trang, component, bảng thống kê, dashboard và form phát t
- **Điều cấm kỵ (Anti-patterns)**: - **Điều cấm kỵ (Anti-patterns)**:
- TUYỆT ĐỐI KHÔNG dùng màu neon chói gắt hoặc gradient tím/hồng generic kiểu AI. - TUYỆT ĐỐI KHÔNG dùng màu neon chói gắt hoặc gradient tím/hồng generic kiểu AI.
- KHÔNG dùng góc vuông thô ráp hoặc viền đen đặc cứng nhắc. - KHÔNG dùng góc vuông thô ráp hoặc viền đen đặc cứng nhắc.
## 7. Đa Giao Diện (Sáng / Tối / Theo Hệ Thống) & Song Ngữ (Anh - Việt) - BẮT BUỘC
Tất cả các trang, component, thông báo và form trong dự án từ nay về sau **BẮT BUỘC PHẢI HỖ TRỢ ĐẦY ĐỦ**:
- **3 Chế độ Giao diện (Themes)**:
- `light` (Giao diện sáng): Nền cát ấm/be nhạt (`#FAF7F2` hoặc `#F5F5DC`), thẻ trắng kem, chữ than sẫm mềm (`#1C2826`), viền xanh ngọc bích nhạt.
- `dark` (Giao diện tối): Nền rêu sẫm/xanh đen tối sâu (`#090d16` / `#061a14`), thẻ rêu than, chữ sáng mịn (`#F1F5F9`), viền `border-emerald-500/15`.
- `system` (Theo hệ thống): Tự động nhận diện thiết lập giao diện của hệ điều hành người dùng (`prefers-color-scheme`).
- Sử dụng `next-themes` (`ThemeProvider` với `attribute="class"`).
- **Đa ngôn ngữ Anh - Việt (Bilingual i18n)**:
- Hỗ trợ chuyển đổi qua lại giữa **Tiếng Việt (`vi`)****English (`en`)**.
- Không hardcode chuỗi text trực tiếp vào JSX; luôn sử dụng từ điển ngôn ngữ và hook `useLanguage` / `useTranslation`.
- Lưu trữ lựa chọn ngôn ngữ và theme trong `localStorage` để ghi nhớ cho các phiên tiếp theo.
- **Vị trí Controls**: Thanh Navbar/Header luôn phải tích hợp `ThemeToggle``LanguageToggle` trực quan, dễ thao tác.
...@@ -4,9 +4,10 @@ Bảng tiêu chí dùng để nghiệm thu mã nguồn UI do AI hoặc lập tr ...@@ -4,9 +4,10 @@ Bảng tiêu chí dùng để nghiệm thu mã nguồn UI do AI hoặc lập tr
| Tiêu chí | Trọng số | Mô tả đánh giá | Điểm đạt được | | Tiêu chí | Trọng số | Mô tả đánh giá | Điểm đạt được |
| :--- | :---: | :--- | :---: | | :--- | :---: | :--- | :---: |
| **Độ chính xác chức năng** | **30** | Component render đúng dữ liệu, đúng props, xử lý đầy đủ các state (Loading, Empty, Error, Success). | /30 | | **Độ chính xác chức năng** | **25** | Component render đúng dữ liệu, đúng props, xử lý đầy đủ các state (Loading, Empty, Error, Success). | /25 |
| **Độ hoàn thiện thẩm mỹ (Visual Polish)** | **20** | Phối màu hài hòa, khoảng cách padding/margin cân đối, typography rõ ràng, không vỡ layout. | /20 | | **Hỗ trợ 3 Themes & Song ngữ** | **20** | Hỗ trợ mượt mà **Light / Dark / System** (không vỡ màu, không FOUC); Mọi text hỗ trợ song ngữ **Anh - Việt** qua `useLanguage`. | /20 |
| **Trải nghiệm tương tác (UX & Feedback)** | **20** | Trạng thái hover/active mượt mà, cursor-pointer đầy đủ, toast notification thông báo kết quả. | /20 | | **Độ hoàn thiện thẩm mỹ (Biophilic)** | **20** | Phối màu Earth tones & Greens hài hòa, bo góc `rounded-2xl` - `rounded-3xl`, bóng mờ êm dịu, không dùng màu neon chói. | /20 |
| **Type-Safety & Code Quality** | **15** | TypeScript strict mode không có `any`, không có cảnh báo linter, cấu trúc code sạch (clean code). | /15 | | **Trải nghiệm tương tác (UX & Feedback)** | **15** | Trạng thái hover/active mượt mà, cursor-pointer đầy đủ, toast notification thông báo kết quả. | /15 |
| **Khả năng tiếp cận & Responsive** | **15** | Đảm bảo tương phản màu sắc đạt WCAG AA, hiển thị mượt trên Mobile, Tablet và Desktop. | /15 | | **Type-Safety & Code Quality** | **10** | TypeScript strict mode không có `any`, không có cảnh báo linter, cấu trúc code sạch (clean code). | /10 |
| **Khả năng tiếp cận & Responsive** | **10** | Đảm bảo tương phản màu sắc đạt WCAG AA, hiển thị mượt trên Mobile, Tablet và Desktop. | /10 |
| **TỔNG ĐIỂM** | **100** | **Ngưỡng đạt: Tối thiểu 85/100 điểm để bàn giao** | **/100** | | **TỔNG ĐIỂM** | **100** | **Ngưỡng đạt: Tối thiểu 85/100 điểm để bàn giao** | **/100** |
...@@ -26,3 +26,8 @@ Bạn là một Senior Frontend Engineer chuyên sâu về hệ sinh thái Next. ...@@ -26,3 +26,8 @@ Bạn là một Senior Frontend Engineer chuyên sâu về hệ sinh thái Next.
- **Khối & Đường cong**: Bo góc `rounded-2xl` - `rounded-3xl`, viền mờ `border-emerald-500/15`, đổ bóng êm dịu, hiệu ứng chuyển màu sinh thái mượt mà. - **Khối & Đường cong**: Bo góc `rounded-2xl` - `rounded-3xl`, viền mờ `border-emerald-500/15`, đổ bóng êm dịu, hiệu ứng chuyển màu sinh thái mượt mà.
- **Thành phần**: Luôn tích hợp thẻ Impact Metrics, Interactive Calculator / preview controls, và Certification Badges bo tròn dạng pill. - **Thành phần**: Luôn tích hợp thẻ Impact Metrics, Interactive Calculator / preview controls, và Certification Badges bo tròn dạng pill.
- **Cấm kỵ**: Tuyệt đối không dùng màu neon gắt, gradient tím/hồng generic hoặc khối hộp góc vuông thô ráp. - **Cấm kỵ**: Tuyệt đối không dùng màu neon gắt, gradient tím/hồng generic hoặc khối hộp góc vuông thô ráp.
## Chuẩn Giao Diện Sáng/Tối/Hệ Thống & Đa Ngôn Ngữ (Bắt Buộc)
- **3 Theme modes**: Phải xử lý cả Light Mode (tone cát ấm `#FAF7F2`), Dark Mode (tone rêu sẫm `#090d16`), và System Mode thông qua `next-themes`.
- **Song ngữ Anh - Việt**: Không hardcode text thô, luôn dùng từ điển i18n (`useLanguage` hook) hỗ trợ cả `vi``en`.
- **Navbar Controls**: Luôn hiển thị `ThemeToggle``LanguageToggle` trên header.
...@@ -21,6 +21,7 @@ ...@@ -21,6 +21,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^1.41.0", "lucide-react": "^1.41.0",
"next": "16.3.4", "next": "16.3.4",
"next-themes": "^0.4.6",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8", "react-dom": "19.2.8",
"react-hook-form": "^7.87.0", "react-hook-form": "^7.87.0",
......
...@@ -44,6 +44,9 @@ importers: ...@@ -44,6 +44,9 @@ importers:
next: next:
specifier: 16.3.4 specifier: 16.3.4
version: 16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) version: 16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
react: react:
specifier: 19.2.8 specifier: 19.2.8
version: 19.2.8 version: 19.2.8
...@@ -2095,6 +2098,12 @@ packages: ...@@ -2095,6 +2098,12 @@ packages:
natural-compare@1.4.0: natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
next-themes@0.4.6:
resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
peerDependencies:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
next@16.3.4: next@16.3.4:
resolution: {integrity: sha512-/Ztf6CeRH+ejEXUrYtqI4gkS66eFIHuSwqi60RgcpWKodxFZx2/dqVCMKBwILfAHXQ+F1b1vAudgj3mnxqtoIA==} resolution: {integrity: sha512-/Ztf6CeRH+ejEXUrYtqI4gkS66eFIHuSwqi60RgcpWKodxFZx2/dqVCMKBwILfAHXQ+F1b1vAudgj3mnxqtoIA==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
...@@ -4561,6 +4570,11 @@ snapshots: ...@@ -4561,6 +4570,11 @@ snapshots:
natural-compare@1.4.0: {} natural-compare@1.4.0: {}
next-themes@0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
dependencies:
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
dependencies: dependencies:
'@next/env': 16.3.4 '@next/env': 16.3.4
......
@import "tailwindcss"; @import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
:root { :root {
/* Organic Biophilic - Light Mode (Cát ấm / Be sáng / Xanh lá tự nhiên) */
--background: #fbfbf9;
--foreground: #1c2826;
--card: #ffffff;
--card-foreground: #1c2826;
--popover: #ffffff;
--popover-foreground: #1c2826;
--primary: #059669;
--primary-foreground: #ffffff;
--secondary: #f0fdf4;
--secondary-foreground: #166534;
--muted: #f4f5f0;
--muted-foreground: #64748b;
--accent: #e6f4ea;
--accent-foreground: #065f46;
--destructive: #ef4444;
--destructive-foreground: #ffffff;
--border: #e2e8f0;
--input: #e2e8f0;
--ring: #10b981;
--radius: 1rem;
}
.dark {
/* Organic Biophilic - Dark Mode (Rêu sẫm / Xanh đen sâu / Ngọc lục bảo) */
--background: #090d16; --background: #090d16;
--foreground: #f1f5f9; --foreground: #f1f5f9;
--card: #0f172a; --card: #0f172a;
...@@ -20,7 +47,6 @@ ...@@ -20,7 +47,6 @@
--border: #1e293b; --border: #1e293b;
--input: #1e293b; --input: #1e293b;
--ring: #10b981; --ring: #10b981;
--radius: 0.75rem;
} }
@theme inline { @theme inline {
...@@ -56,6 +82,7 @@ body { ...@@ -56,6 +82,7 @@ body {
radial-gradient(at 10% 10%, rgba(16, 185, 129, 0.08) 0px, transparent 40%), radial-gradient(at 10% 10%, rgba(16, 185, 129, 0.08) 0px, transparent 40%),
radial-gradient(at 90% 90%, rgba(6, 182, 212, 0.08) 0px, transparent 40%); radial-gradient(at 90% 90%, rgba(6, 182, 212, 0.08) 0px, transparent 40%);
background-attachment: fixed; background-attachment: fixed;
transition: background-color 0.3s ease, color 0.3s ease;
} }
/* Custom scrollbar */ /* Custom scrollbar */
...@@ -64,12 +91,12 @@ body { ...@@ -64,12 +91,12 @@ body {
height: 8px; height: 8px;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: #090d16; background: var(--background);
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: #1e293b; background: var(--border);
border-radius: 4px; border-radius: 4px;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: #334155; background: var(--muted-foreground);
} }
...@@ -26,16 +26,16 @@ export default function RootLayout({ ...@@ -26,16 +26,16 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="vi" className="dark"> <html lang="vi" suppressHydrationWarning>
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased min-h-screen flex flex-col bg-[#090d16] text-slate-100 selection:bg-emerald-500/30 selection:text-emerald-300`} className={`${geistSans.variable} ${geistMono.variable} antialiased min-h-screen flex flex-col bg-background text-foreground selection:bg-emerald-500/30 selection:text-emerald-400 transition-colors duration-200`}
> >
<Providers> <Providers>
<Navbar /> <Navbar />
<main className="flex-1 container mx-auto max-w-7xl px-4 py-8 sm:px-6"> <main className="flex-1 container mx-auto max-w-7xl px-4 py-8 sm:px-6">
{children} {children}
</main> </main>
<footer className="border-t border-slate-800/60 py-6 text-center text-xs text-slate-500"> <footer className="border-t border-border/60 py-6 text-center text-xs text-muted-foreground">
<p>Data Crawler Architecture © {new Date().getFullYear()} — Powered by Next.js & TanStack Query</p> <p>Data Crawler Architecture © {new Date().getFullYear()} — Powered by Next.js & TanStack Query</p>
</footer> </footer>
</Providers> </Providers>
......
...@@ -25,47 +25,48 @@ import { ...@@ -25,47 +25,48 @@ import {
Server, Server,
Sparkles, Sparkles,
} from "lucide-react"; } from "lucide-react";
import { useLanguage } from "@/providers/language-provider";
export default function DashboardPage() { export default function DashboardPage() {
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const { data: stats, isLoading: isStatsLoading } = useCrawlerStats(); const { data: stats, isLoading: isStatsLoading } = useCrawlerStats();
const { t } = useLanguage();
return ( return (
<div className="space-y-8"> <div className="space-y-8">
{/* Top Banner / Hero */} {/* Top Banner / Hero - Organic Biophilic Styling */}
<div className="relative overflow-hidden rounded-2xl border border-slate-800 bg-gradient-to-r from-slate-900 via-slate-900/90 to-slate-950 p-6 sm:p-8 shadow-2xl"> <div className="relative overflow-hidden rounded-3xl border border-emerald-500/20 bg-gradient-to-br from-card via-card/90 to-emerald-950/10 p-6 sm:p-8 shadow-sm backdrop-blur-md transition-all duration-200">
<div className="absolute right-0 top-0 -mt-10 -mr-10 h-64 w-64 rounded-full bg-emerald-500/10 blur-3xl pointer-events-none" /> <div className="absolute right-0 top-0 -mt-10 -mr-10 h-64 w-64 rounded-full bg-emerald-500/10 blur-3xl pointer-events-none" />
<div className="relative z-10 flex flex-col md:flex-row md:items-center md:justify-between gap-6"> <div className="relative z-10 flex flex-col md:flex-row md:items-center md:justify-between gap-6">
<div className="space-y-2"> <div className="space-y-2">
<div className="inline-flex items-center gap-2 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1 text-xs font-semibold text-emerald-400"> <div className="inline-flex items-center gap-2 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1 text-xs font-semibold text-emerald-600 dark:text-emerald-400">
<Sparkles className="h-3.5 w-3.5" /> <Sparkles className="h-3.5 w-3.5" />
Next.js 16 + TanStack Query + shadcn/ui Architecture {t.hero.badge}
</div> </div>
<h1 className="text-2xl sm:text-3xl font-extrabold tracking-tight text-white"> <h1 className="text-2xl sm:text-3xl font-extrabold tracking-tight text-foreground">
Data Crawler Management {t.hero.title}
</h1> </h1>
<p className="text-sm text-slate-400 max-w-2xl"> <p className="text-sm text-muted-foreground max-w-2xl">
Hệ thống quản lý, giám sát và bóc tách dữ liệu đa nguồn theo thời gian thực. {t.hero.description}
Được thiết kế theo cấu trúc App Router chuẩn và tối ưu hóa hiệu năng state management.
</p> </p>
</div> </div>
<div className="flex items-center gap-3 shrink-0"> <div className="flex items-center gap-3 shrink-0">
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}> <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button className="bg-emerald-600 hover:bg-emerald-500 text-white font-semibold shadow-lg shadow-emerald-600/20 px-5 py-2.5 h-auto cursor-pointer"> <Button className="rounded-2xl bg-emerald-600 hover:bg-emerald-500 text-white font-semibold shadow-md shadow-emerald-600/20 px-5 py-2.5 h-auto cursor-pointer transition-all hover:scale-[1.02]">
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Tạo Tác Vụ Mới {t.hero.createTaskBtn}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md rounded-3xl border-border/80 bg-card text-card-foreground">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2 text-foreground">
<Globe2 className="h-5 w-5 text-emerald-400" /> <Globe2 className="h-5 w-5 text-emerald-500" />
Tạo Tác Vụ Cào Dữ Liệu {t.dialog.title}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription className="text-muted-foreground">
Điền thông tin URL và tiêu chí bóc tách. Form được xác thực tự động bởi React Hook Form + Zod. {t.dialog.description}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<CrawlerTaskForm onSuccess={() => setIsDialogOpen(false)} /> <CrawlerTaskForm onSuccess={() => setIsDialogOpen(false)} />
...@@ -75,39 +76,39 @@ export default function DashboardPage() { ...@@ -75,39 +76,39 @@ export default function DashboardPage() {
</div> </div>
</div> </div>
{/* Metrics Grid */} {/* Impact Metrics Grid */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard <StatCard
title="Tác vụ đang chạy" title={t.stats.runningTasks}
value={isStatsLoading ? "..." : (stats?.activeTasks ?? 0)} value={isStatsLoading ? "..." : (stats?.activeTasks ?? 0)}
description="Đang cào dữ liệu song song" description={t.stats.runningTasksDesc}
icon={Radio} icon={Radio}
trend={{ value: "Realtime", isPositive: true }} trend={{ value: "Realtime", isPositive: true }}
colorClassName="from-emerald-500/20 to-teal-500/5 text-emerald-400 border-emerald-500/30" colorClassName="from-emerald-500/20 to-teal-500/5 text-emerald-600 dark:text-emerald-400 border-emerald-500/30"
/> />
<StatCard <StatCard
title="Trang web đã cào" title={t.stats.crawledPages}
value={isStatsLoading ? "..." : (stats?.totalPagesCrawled ?? 0).toLocaleString()} value={isStatsLoading ? "..." : (stats?.totalPagesCrawled ?? 0).toLocaleString()}
description="Lưu lượng trang đã duyệt" description={t.stats.totalTasksDesc}
icon={Activity} icon={Activity}
trend={{ value: "14.2%", isPositive: true }} trend={{ value: "14.2%", isPositive: true }}
colorClassName="from-cyan-500/20 to-blue-500/5 text-cyan-400 border-cyan-500/30" colorClassName="from-cyan-500/20 to-blue-500/5 text-cyan-600 dark:text-cyan-400 border-cyan-500/30"
/> />
<StatCard <StatCard
title="Mục dữ liệu bóc tách" title={t.stats.completedTasks}
value={isStatsLoading ? "..." : (stats?.totalItemsExtracted ?? 0).toLocaleString()} value={isStatsLoading ? "..." : (stats?.totalItemsExtracted ?? 0).toLocaleString()}
description="Bản ghi trích xuất thành công" description={t.stats.completedTasksDesc}
icon={Database} icon={Database}
trend={{ value: "28.5%", isPositive: true }} trend={{ value: "28.5%", isPositive: true }}
colorClassName="from-indigo-500/20 to-purple-500/5 text-indigo-400 border-indigo-500/30" colorClassName="from-teal-500/20 to-emerald-500/5 text-teal-600 dark:text-teal-400 border-teal-500/30"
/> />
<StatCard <StatCard
title="Tỷ lệ thành công" title={t.stats.ecoScore}
value={isStatsLoading ? "..." : `${stats?.successRate ?? 98.4}%`} value={isStatsLoading ? "..." : `${stats?.successRate ?? 98.4}%`}
description="Độ tin cậy xử lý HTTP requests" description={t.stats.failedTasksDesc}
icon={CheckCircle2} icon={CheckCircle2}
trend={{ value: "0.8%", isPositive: true }} trend={{ value: "0.8%", isPositive: true }}
colorClassName="from-teal-500/20 to-emerald-500/5 text-teal-400 border-teal-500/30" colorClassName="from-emerald-500/20 to-lime-500/5 text-emerald-600 dark:text-emerald-400 border-emerald-500/30"
/> />
</div> </div>
...@@ -115,12 +116,12 @@ export default function DashboardPage() { ...@@ -115,12 +116,12 @@ export default function DashboardPage() {
<div className="space-y-4" id="tasks"> <div className="space-y-4" id="tasks">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h2 className="text-lg font-bold text-slate-100 flex items-center gap-2"> <h2 className="text-lg font-bold text-foreground flex items-center gap-2">
<Server className="h-5 w-5 text-emerald-400" /> <Server className="h-5 w-5 text-emerald-500" />
Danh Sách Tác Vụ Cào Dữ Liệu {t.table.title}
</h2> </h2>
<p className="text-xs text-slate-400"> <p className="text-xs text-muted-foreground">
Cập nhật tự động mỗi 5s bằng TanStack Query (Polling / Cache sync) {t.table.subtitle}
</p> </p>
</div> </div>
</div> </div>
...@@ -129,29 +130,29 @@ export default function DashboardPage() { ...@@ -129,29 +130,29 @@ export default function DashboardPage() {
</div> </div>
{/* Architecture & Tech Stack Highlights */} {/* Architecture & Tech Stack Highlights */}
<div className="rounded-xl border border-slate-800/80 bg-slate-900/40 p-6 backdrop-blur-sm"> <div className="rounded-3xl border border-border/70 bg-card/60 p-6 backdrop-blur-sm transition-colors duration-200">
<div className="flex items-center gap-2 text-sm font-semibold text-slate-200 mb-4"> <div className="flex items-center gap-2 text-sm font-semibold text-foreground mb-4">
<FileCode2 className="h-4 w-4 text-emerald-400" /> <FileCode2 className="h-4 w-4 text-emerald-500" />
Tổng Quan Kiến Trúc Đã Khởi Tạo Architecture & System Highlights
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-xs text-slate-300"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-xs text-muted-foreground">
<div className="space-y-1.5 rounded-lg border border-slate-800 bg-slate-900/60 p-3.5"> <div className="space-y-1.5 rounded-2xl border border-border/60 bg-muted/40 p-4">
<span className="font-semibold text-emerald-400">Next.js App Router (src/app)</span> <span className="font-semibold text-emerald-600 dark:text-emerald-400">Next.js App Router (src/app)</span>
<p className="text-slate-400"> <p>
Sử dụng đầy đủ layout, loading skeletons, error boundaries, và not-found page theo tài liệu <code className="text-slate-300">project-structure.md</code>. Layouts, loading skeletons, error boundaries, and not-found pages structured for high reliability.
</p> </p>
</div> </div>
<div className="space-y-1.5 rounded-lg border border-slate-800 bg-slate-900/60 p-3.5"> <div className="space-y-1.5 rounded-2xl border border-border/60 bg-muted/40 p-4">
<span className="font-semibold text-cyan-400">State & Validation Stack</span> <span className="font-semibold text-cyan-600 dark:text-cyan-400">Theme & Bilingual i18n</span>
<p className="text-slate-400"> <p>
TanStack Query v5 (React Query) quản lý server state và auto-refetch, React Hook Form kết hợp Zod schema đảm bảo type-safety hoàn hảo. Light, Dark, and System modes powered by next-themes with instant English / Tiếng Việt localization.
</p> </p>
</div> </div>
<div className="space-y-1.5 rounded-lg border border-slate-800 bg-slate-900/60 p-3.5"> <div className="space-y-1.5 rounded-2xl border border-border/60 bg-muted/40 p-4">
<span className="font-semibold text-teal-400">Axios Client & Fallback Layer</span> <span className="font-semibold text-teal-600 dark:text-teal-400">TanStack Query & Axios Layer</span>
<p className="text-slate-400"> <p>
Axios instance tích hợp Interceptor đính kèm Token, bắt lỗi ApiError chuẩn hóa và hỗ trợ mock fallback để frontend chạy ngay lập tức. Server state synchronization, interceptor tokens, standardized error boundaries, and mock fallback.
</p> </p>
</div> </div>
</div> </div>
......
"use client";
import React from "react";
import { useLanguage } from "@/providers/language-provider";
import { Languages } from "lucide-react";
export function LanguageToggle() {
const { locale, setLocale, t } = useLanguage();
const toggleLanguage = () => {
setLocale(locale === "vi" ? "en" : "vi");
};
return (
<button
type="button"
onClick={toggleLanguage}
title={`${t.lang.toggleTitle}: ${locale === "vi" ? "Chuyển sang English" : "Switch to Tiếng Việt"}`}
className="inline-flex items-center gap-1.5 rounded-2xl border border-emerald-500/20 bg-emerald-950/10 px-3 py-1.5 text-xs font-semibold text-slate-700 transition-all hover:border-emerald-500/40 hover:bg-emerald-500/10 cursor-pointer dark:bg-emerald-950/30 dark:text-slate-200 dark:hover:bg-emerald-950/50"
>
<Languages className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
<span className="font-mono tracking-wider">{locale === "vi" ? "VI" : "EN"}</span>
<span className="text-[10px] text-slate-400 dark:text-slate-500">
/ {locale === "vi" ? "EN" : "VI"}
</span>
</button>
);
}
...@@ -3,68 +3,81 @@ ...@@ -3,68 +3,81 @@
import React from "react"; import React from "react";
import Link from "next/link"; import Link from "next/link";
import { Activity, Bot, Database, Globe, RefreshCw } from "lucide-react"; import { Activity, Bot, Database, Globe, RefreshCw } from "lucide-react";
import { ThemeToggle } from "./theme-toggle";
import { LanguageToggle } from "./language-toggle";
import { useLanguage } from "@/providers/language-provider";
export function Navbar() { export function Navbar() {
const { t } = useLanguage();
return ( return (
<header className="sticky top-0 z-40 w-full border-b border-slate-800/80 bg-slate-950/80 backdrop-blur-md"> <header className="sticky top-0 z-40 w-full border-b border-border/70 bg-card/80 backdrop-blur-md transition-colors duration-200">
<div className="container mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-6"> <div className="container mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-6">
{/* Brand */} {/* Brand */}
<Link href="/" className="flex items-center gap-3 transition-opacity hover:opacity-90"> <Link href="/" className="flex items-center gap-3 transition-opacity hover:opacity-90">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-emerald-500 to-cyan-600 shadow-md shadow-emerald-500/20"> <div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-gradient-to-br from-emerald-500 to-teal-700 shadow-md shadow-emerald-500/20">
<Bot className="h-6 w-6 text-white" /> <Bot className="h-6 w-6 text-white" />
</div> </div>
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-bold text-lg tracking-tight text-white">DataCrawler</span> <span className="font-bold text-lg tracking-tight text-foreground">DataCrawler</span>
<span className="rounded bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-semibold tracking-wider text-emerald-400 border border-emerald-500/20"> <span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-semibold tracking-wider text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
PRO v1.0 PRO v1.0
</span> </span>
</div> </div>
<p className="text-xs text-slate-400">Hệ thống cào và xử lý dữ liệu tự động</p> <p className="text-xs text-muted-foreground hidden sm:block">{t.nav.brandSub}</p>
</div> </div>
</Link> </Link>
{/* Center Nav Links */} {/* Center Nav Links */}
<nav className="hidden md:flex items-center gap-6 text-sm font-medium text-slate-300"> <nav className="hidden md:flex items-center gap-6 text-sm font-medium text-muted-foreground">
<Link <Link
href="/" href="/"
className="flex items-center gap-1.5 text-emerald-400 hover:text-emerald-300 transition-colors" className="flex items-center gap-1.5 text-emerald-600 dark:text-emerald-400 hover:text-emerald-500 transition-colors font-semibold"
> >
<Activity className="h-4 w-4" /> <Activity className="h-4 w-4" />
Dashboard {t.nav.dashboard}
</Link> </Link>
<Link <Link
href="#tasks" href="#tasks"
className="flex items-center gap-1.5 hover:text-slate-100 transition-colors" className="flex items-center gap-1.5 hover:text-foreground transition-colors"
> >
<Globe className="h-4 w-4" /> <Globe className="h-4 w-4" />
Tác vụ Crawl {t.nav.tasks}
</Link> </Link>
<Link <Link
href="#storage" href="#storage"
className="flex items-center gap-1.5 hover:text-slate-100 transition-colors" className="flex items-center gap-1.5 hover:text-foreground transition-colors"
> >
<Database className="h-4 w-4" /> <Database className="h-4 w-4" />
Kho Dữ Liệu {t.nav.storage}
</Link> </Link>
</nav> </nav>
{/* Right Status */} {/* Right Controls: Theme Toggle, Language Toggle, Status & Reload */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-2 sm:gap-3">
<div className="hidden sm:flex items-center gap-2 rounded-full border border-slate-800 bg-slate-900/60 px-3 py-1 text-xs text-slate-300"> {/* Active Status Badge */}
<div className="hidden lg:flex items-center gap-2 rounded-full border border-emerald-500/20 bg-emerald-500/5 px-3 py-1 text-xs text-emerald-700 dark:text-emerald-300">
<span className="relative flex h-2 w-2"> <span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75"></span> <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75"></span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500"></span> <span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500"></span>
</span> </span>
<span>Crawler Engine Active</span> <span>{t.nav.statusActive}</span>
</div> </div>
{/* Language Switcher */}
<LanguageToggle />
{/* Theme Switcher */}
<ThemeToggle />
{/* Reload Page */}
<button <button
onClick={() => window.location.reload()} onClick={() => window.location.reload()}
title="Tải lại trang" title={t.nav.reload}
className="rounded-lg border border-slate-800 bg-slate-900/50 p-2 text-slate-400 hover:bg-slate-800 hover:text-slate-200 transition-colors" className="flex h-8 w-8 items-center justify-center rounded-xl border border-border bg-card/60 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
> >
<RefreshCw className="h-4 w-4" /> <RefreshCw className="h-3.5 w-3.5" />
</button> </button>
</div> </div>
</div> </div>
......
...@@ -21,26 +21,26 @@ export function StatCard({ ...@@ -21,26 +21,26 @@ export function StatCard({
description, description,
icon: Icon, icon: Icon,
trend, trend,
colorClassName = "from-emerald-500/20 to-teal-500/5 text-emerald-400 border-emerald-500/30", colorClassName = "from-emerald-500/20 to-teal-500/5 text-emerald-600 dark:text-emerald-400 border-emerald-500/30",
}: StatCardProps) { }: StatCardProps) {
return ( return (
<Card className="relative overflow-hidden border border-slate-800/80 bg-slate-900/60 backdrop-blur-md"> <Card className="relative overflow-hidden border border-border/70 bg-card/80 backdrop-blur-md rounded-2xl shadow-sm hover:scale-[1.01] transition-all duration-200">
{/* Subtle Glow background */} {/* Subtle Glow background */}
<div <div
className={cn( className={cn(
"absolute -right-6 -top-6 h-28 w-28 rounded-full bg-gradient-to-br opacity-20 blur-2xl", "absolute -right-6 -top-6 h-28 w-28 rounded-full bg-gradient-to-br opacity-20 blur-2xl pointer-events-none",
colorClassName colorClassName
)} )}
/> />
<CardContent className="p-5"> <CardContent className="p-5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider text-slate-400"> <p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{title} {title}
</p> </p>
<div <div
className={cn( className={cn(
"flex h-9 w-9 items-center justify-center rounded-lg border bg-gradient-to-br", "flex h-9 w-9 items-center justify-center rounded-xl border bg-gradient-to-br",
colorClassName colorClassName
)} )}
> >
...@@ -49,14 +49,16 @@ export function StatCard({ ...@@ -49,14 +49,16 @@ export function StatCard({
</div> </div>
<div className="mt-3 flex items-baseline gap-2"> <div className="mt-3 flex items-baseline gap-2">
<span className="text-2xl sm:text-3xl font-bold tracking-tight text-white"> <span className="text-2xl sm:text-3xl font-bold tracking-tight text-foreground">
{value} {value}
</span> </span>
{trend && ( {trend && (
<span <span
className={cn( className={cn(
"text-xs font-semibold", "text-xs font-semibold",
trend.isPositive ? "text-emerald-400" : "text-red-400" trend.isPositive
? "text-emerald-600 dark:text-emerald-400"
: "text-red-600 dark:text-red-400"
)} )}
> >
{trend.isPositive ? "+" : ""} {trend.isPositive ? "+" : ""}
...@@ -66,7 +68,7 @@ export function StatCard({ ...@@ -66,7 +68,7 @@ export function StatCard({
</div> </div>
{description && ( {description && (
<p className="mt-1 text-xs text-slate-400">{description}</p> <p className="mt-1 text-xs text-muted-foreground">{description}</p>
)} )}
</CardContent> </CardContent>
</Card> </Card>
......
"use client";
import React, { useEffect, useState } from "react";
import { useTheme } from "next-themes";
import { Monitor, Moon, Sun } from "lucide-react";
import { useLanguage } from "@/providers/language-provider";
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
const { t } = useLanguage();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return (
<div className="flex h-9 w-[104px] items-center rounded-xl border border-emerald-500/20 bg-emerald-950/20 p-1 opacity-60" />
);
}
return (
<div
className="inline-flex items-center rounded-2xl border border-emerald-500/20 bg-emerald-950/10 p-1 backdrop-blur-sm dark:bg-emerald-950/30"
role="group"
aria-label={t.theme.toggleTitle}
>
<button
type="button"
onClick={() => setTheme("light")}
title={`${t.theme.light} (${t.theme.toggleTitle})`}
className={`flex h-7 w-7 items-center justify-center rounded-xl transition-all cursor-pointer ${
theme === "light"
? "bg-emerald-500 text-white shadow-sm shadow-emerald-600/30"
: "text-slate-600 hover:text-emerald-700 dark:text-slate-400 dark:hover:text-emerald-300"
}`}
>
<Sun className="h-4 w-4" />
<span className="sr-only">{t.theme.light}</span>
</button>
<button
type="button"
onClick={() => setTheme("dark")}
title={`${t.theme.dark} (${t.theme.toggleTitle})`}
className={`flex h-7 w-7 items-center justify-center rounded-xl transition-all cursor-pointer ${
theme === "dark"
? "bg-emerald-500 text-white shadow-sm shadow-emerald-600/30"
: "text-slate-600 hover:text-emerald-700 dark:text-slate-400 dark:hover:text-emerald-300"
}`}
>
<Moon className="h-4 w-4" />
<span className="sr-only">{t.theme.dark}</span>
</button>
<button
type="button"
onClick={() => setTheme("system")}
title={`${t.theme.system} (${t.theme.toggleTitle})`}
className={`flex h-7 w-7 items-center justify-center rounded-xl transition-all cursor-pointer ${
theme === "system"
? "bg-emerald-500 text-white shadow-sm shadow-emerald-600/30"
: "text-slate-600 hover:text-emerald-700 dark:text-slate-400 dark:hover:text-emerald-300"
}`}
>
<Monitor className="h-4 w-4" />
<span className="sr-only">{t.theme.system}</span>
</button>
</div>
);
}
...@@ -8,7 +8,7 @@ const Card = React.forwardRef< ...@@ -8,7 +8,7 @@ const Card = React.forwardRef<
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
"rounded-xl border border-slate-800 bg-slate-900/60 backdrop-blur-md text-slate-100 shadow-lg shadow-black/20", "rounded-2xl border border-border/70 bg-card/80 backdrop-blur-md text-card-foreground shadow-sm shadow-emerald-950/5 dark:shadow-black/20 transition-colors duration-200",
className className
)} )}
{...props} {...props}
...@@ -34,7 +34,7 @@ const CardTitle = React.forwardRef< ...@@ -34,7 +34,7 @@ const CardTitle = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn("font-semibold leading-none tracking-tight text-slate-100", className)} className={cn("font-semibold leading-none tracking-tight text-foreground", className)}
{...props} {...props}
/> />
)); ));
...@@ -46,7 +46,7 @@ const CardDescription = React.forwardRef< ...@@ -46,7 +46,7 @@ const CardDescription = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn("text-sm text-slate-400", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
)); ));
......
export type Locale = "vi" | "en";
export const translations = {
vi: {
// Navigation & Common
nav: {
brandSub: "Hệ thống cào và xử lý dữ liệu tự động",
dashboard: "Bảng điều khiển",
tasks: "Tác vụ Crawl",
storage: "Kho Dữ Liệu",
statusActive: "Động cơ Crawler Đang Chạy",
reload: "Tải lại trang",
copyright: "Data Crawler Architecture © 2026 — Được hỗ trợ bởi Next.js & TanStack Query",
},
// Theme & Language
theme: {
light: "Sáng",
dark: "Tối",
system: "Hệ thống",
toggleTitle: "Chuyển giao diện",
},
lang: {
vi: "Tiếng Việt",
en: "English",
toggleTitle: "Đổi ngôn ngữ",
},
// Hero & Header
hero: {
badge: "Next.js 16 + TanStack Query + shadcn/ui",
title: "Quản Lý & Giám Sát Data Crawler",
description:
"Hệ thống giám sát và bóc tách dữ liệu đa nguồn theo thời gian thực. Thiết kế chuẩn Organic Biophilic, tối ưu hiệu năng và thân thiện sinh thái.",
createTaskBtn: "Tạo Tác Vụ Mới",
},
// Dialog
dialog: {
title: "Tạo Tác Vụ Cào Dữ Liệu",
description:
"Điền thông tin URL và tiêu chí bóc tách. Form được xác thực tự động bởi React Hook Form + Zod.",
urlLabel: "URL nguồn cào",
urlPlaceholder: "https://example.com/news/...",
nameLabel: "Tên tác vụ",
namePlaceholder: "Ví dụ: Tin tức Công nghệ",
depthLabel: "Độ sâu cào (Depth)",
rateLabel: "Tốc độ yêu cầu (req/s)",
submitBtn: "Khởi tạo tác vụ",
cancelBtn: "Hủy bỏ",
submitting: "Đang khởi tạo...",
},
// Stats & KPI Cards
stats: {
totalTasks: "Tổng Số Tác Vụ",
totalTasksDesc: "Tác vụ đã được thiết lập",
runningTasks: "Đang Thu Thập",
runningTasksDesc: "Workers đang cào dữ liệu",
completedTasks: "Đã Hoàn Thành",
completedTasksDesc: "Tác vụ bóc tách thành công",
failedTasks: "Gặp Lỗi",
failedTasksDesc: "Cần kiểm tra lại kết nối",
crawledPages: "Trang Đã Bóc Tách",
ecoScore: "Chỉ Số Sinh Thái (Eco)",
},
// Task Table
table: {
title: "Danh Sách Tác Vụ Đang Quản Lý",
subtitle: "Theo dõi trạng thái và tiến độ xử lý dữ liệu cào theo thời gian thực",
id: "Mã Tác Vụ",
name: "Tên Tác Vụ & Nguồn",
status: "Trạng Thái",
progress: "Tiến Độ",
createdAt: "Thời Gian Tạo",
actions: "Thao Tác",
empty: "Chưa có tác vụ cào dữ liệu nào. Hãy tạo tác vụ mới!",
retry: "Thử lại",
viewDetails: "Chi tiết",
pause: "Tạm dừng",
resume: "Tiếp tục",
},
// Status Badges
status: {
pending: "Chờ xử lý",
running: "Đang chạy",
completed: "Hoàn tất",
failed: "Thất bại",
paused: "Tạm dừng",
},
},
en: {
// Navigation & Common
nav: {
brandSub: "Automated Data Scraping & Extraction Platform",
dashboard: "Dashboard",
tasks: "Crawl Tasks",
storage: "Data Lake",
statusActive: "Crawler Engine Active",
reload: "Reload page",
copyright: "Data Crawler Architecture © 2026 — Powered by Next.js & TanStack Query",
},
// Theme & Language
theme: {
light: "Light",
dark: "Dark",
system: "System",
toggleTitle: "Switch theme",
},
lang: {
vi: "Tiếng Việt",
en: "English",
toggleTitle: "Switch language",
},
// Hero & Header
hero: {
badge: "Next.js 16 + TanStack Query + shadcn/ui",
title: "Data Crawler Management",
description:
"Real-time multi-source data scraping and extraction platform. Designed with Organic Biophilic principles, optimized for performance and eco-efficiency.",
createTaskBtn: "Create New Task",
},
// Dialog
dialog: {
title: "Create Crawler Task",
description:
"Enter target URL and extraction rules. Fully validated with React Hook Form + Zod.",
urlLabel: "Target Source URL",
urlPlaceholder: "https://example.com/news/...",
nameLabel: "Task Name",
namePlaceholder: "e.g., Tech News Crawler",
depthLabel: "Crawl Depth",
rateLabel: "Request Rate (req/s)",
submitBtn: "Launch Task",
cancelBtn: "Cancel",
submitting: "Creating...",
},
// Stats & KPI Cards
stats: {
totalTasks: "Total Tasks",
totalTasksDesc: "Configured scraper jobs",
runningTasks: "Actively Scraping",
runningTasksDesc: "Workers collecting data",
completedTasks: "Completed",
completedTasksDesc: "Successfully parsed items",
failedTasks: "Failed Tasks",
failedTasksDesc: "Connection or proxy issues",
crawledPages: "Parsed Pages",
ecoScore: "Eco Efficiency Score",
},
// Task Table
table: {
title: "Managed Scraper Tasks",
subtitle: "Monitor scraping status and extraction progress in real-time",
id: "Task ID",
name: "Name & Source URL",
status: "Status",
progress: "Progress",
createdAt: "Created At",
actions: "Actions",
empty: "No crawler tasks yet. Create your first task to get started!",
retry: "Retry",
viewDetails: "Details",
pause: "Pause",
resume: "Resume",
},
// Status Badges
status: {
pending: "Pending",
running: "Running",
completed: "Completed",
failed: "Failed",
paused: "Paused",
},
},
};
export type TranslationKeys = typeof translations.vi;
"use client";
import React, { createContext, useContext, useEffect, useState, ReactNode } from "react";
import { Locale, translations, TranslationKeys } from "@/lib/i18n/translations";
interface LanguageContextType {
locale: Locale;
setLocale: (locale: Locale) => void;
t: TranslationKeys;
}
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
const LANGUAGE_STORAGE_KEY = "data_crawler_lang";
export function LanguageProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<Locale>("vi");
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
const saved = localStorage.getItem(LANGUAGE_STORAGE_KEY) as Locale | null;
if (saved && (saved === "vi" || saved === "en")) {
setLocaleState(saved);
} else {
// Auto-detect browser language
const browserLang = navigator.language?.toLowerCase() || "";
if (browserLang.startsWith("vi")) {
setLocaleState("vi");
} else {
setLocaleState("en");
}
}
}, []);
const setLocale = (newLocale: Locale) => {
setLocaleState(newLocale);
if (typeof window !== "undefined") {
localStorage.setItem(LANGUAGE_STORAGE_KEY, newLocale);
document.documentElement.lang = newLocale;
}
};
const t = translations[locale];
return (
<LanguageContext.Provider value={{ locale, setLocale, t }}>
{children}
</LanguageContext.Provider>
);
}
export function useLanguage() {
const context = useContext(LanguageContext);
if (!context) {
// Fallback when outside provider
return {
locale: "vi" as Locale,
setLocale: () => {},
t: translations.vi,
};
}
return context;
}
...@@ -5,6 +5,8 @@ import { QueryClientProvider } from "@tanstack/react-query"; ...@@ -5,6 +5,8 @@ import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { getQueryClient } from "@/lib/query-client"; import { getQueryClient } from "@/lib/query-client";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import { ThemeProvider } from "./theme-provider";
import { LanguageProvider } from "./language-provider";
interface ProvidersProps { interface ProvidersProps {
children: ReactNode; children: ReactNode;
...@@ -14,10 +16,19 @@ export function Providers({ children }: ProvidersProps) { ...@@ -14,10 +16,19 @@ export function Providers({ children }: ProvidersProps) {
const queryClient = getQueryClient(); const queryClient = getQueryClient();
return ( return (
<QueryClientProvider client={queryClient}> <ThemeProvider
{children} attribute="class"
<Toaster position="top-right" richColors closeButton /> defaultTheme="system"
<ReactQueryDevtools initialIsOpen={false} /> enableSystem
</QueryClientProvider> disableTransitionOnChange={false}
>
<LanguageProvider>
<QueryClientProvider client={queryClient}>
{children}
<Toaster position="top-right" richColors closeButton />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</LanguageProvider>
</ThemeProvider>
); );
} }
"use client";
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment