Commit cda8a149 authored by ThinhNC's avatar ThinhNC

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

parent 26e36b4d
...@@ -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>
......
This diff is collapsed.
"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