Commit 59fee8b7 authored by ThinhNC's avatar ThinhNC

Merge branch 'feat/header-ux-polish-and-page-titles' into 'develop'

feat(ui): polish header controls, standardize page titles, and fix auth form UX

See merge request !5
parents 6e08ec76 c54b1110
......@@ -92,3 +92,40 @@ Tất cả các trang, component, thông báo và form trong dự án từ nay v
- 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.
## 8. Quy Chuẩn Đặt Title & Metadata Cho Từng Trang (Page Titles & SEO) - BẮT BUỘC
Tất cả các trang khi phát triển mới hoặc bảo trì **BẮT BUỘC PHẢI CÓ TIÊU ĐỀ (PAGE TITLE) VÀ METADATA ĐẦY ĐỦ**:
- **Title Template cấu hình tại `src/app/layout.tsx`**:
```tsx
export const metadata: Metadata = {
title: {
default: "Bảng Điều Khiển | Data Crawler System",
template: "%s | Data Crawler",
},
description: "Hệ thống quản lý và giám sát tác vụ cào dữ liệu web realtime...",
};
```
- **Quy tắc khi tạo Trang Mới**:
- Nếu `page.tsx`**Client Component** (`"use client"`): Vì Next.js App Router cấm export `metadata` từ Client Component, luôn tạo thêm file `layout.tsx` (Server Component) trong cùng thư mục route để export `metadata` và pass through `children`:
```tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Tên Trang",
description: "Mô tả ngắn gọn về chức năng của trang",
};
export default function PageLayout({ children }: { children: React.ReactNode }) {
return children;
}
```
- Nếu `page.tsx`**Server Component**: Export trực tiếp `export const metadata: Metadata = { title: "Tên Trang", description: "..." }`.
- **Danh sách Title chuẩn các trang hiện tại**:
- `/` (Dashboard / Trang chủ): `Bảng Điều Khiển | Data Crawler System`
- `/login`: `Đăng Nhập | Data Crawler`
- `/register`: `Đăng Ký Tài Khoản | Data Crawler`
- `/forgot-password`: `Quên Mật Khẩu | Data Crawler`
- `/reset-password`: `Đặt Lại Mật Khẩu | Data Crawler`
- `/verify-email`: `Xác Thực Email | Data Crawler`
- `/forbidden`: `Truy Cập Bị Từ Chối (403) | Data Crawler`
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Quên Mật Khẩu",
description: "Khôi phục mật khẩu tài khoản hệ thống Data Crawler qua email xác nhận",
};
export default function ForgotPasswordLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}
......@@ -10,7 +10,6 @@ import {
Loader2,
ArrowLeft,
CheckCircle2,
Send,
RotateCcw,
} from "lucide-react";
import { forgotPasswordSchema, ForgotPasswordInput } from "@/schemas/auth.schema";
......@@ -124,7 +123,6 @@ export default function ForgotPasswordPage() {
onClick={handleResend}
className="w-full rounded-2xl border-border cursor-pointer hover:bg-muted text-xs"
>
<RotateCcw className="mr-2 h-3.5 w-3.5" />
{countdown > 0
? `Gửi lại sau ${countdown}s`
: t.auth.forgot.resendBtn}
......@@ -134,7 +132,6 @@ export default function ForgotPasswordPage() {
variant="ghost"
className="w-full rounded-2xl text-xs text-muted-foreground hover:text-foreground cursor-pointer"
>
<ArrowLeft className="mr-2 h-3.5 w-3.5" />
{t.auth.forgot.backToLogin}
</Button>
</Link>
......@@ -148,7 +145,7 @@ export default function ForgotPasswordPage() {
{t.auth.forgot.email}
</Label>
<div className="relative">
<Mail className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Mail className="pointer-events-none absolute left-3.5 top-[calc(50%+1px)] h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="email"
type="email"
......@@ -176,10 +173,7 @@ export default function ForgotPasswordPage() {
{t.auth.forgot.submitting}
</>
) : (
<>
<Send className="mr-2 h-4 w-4" />
{t.auth.forgot.submit}
</>
t.auth.forgot.submit
)}
</Button>
......@@ -188,7 +182,6 @@ export default function ForgotPasswordPage() {
href="/login"
className="inline-flex items-center text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
{t.auth.forgot.backToLogin}
</Link>
</div>
......
......@@ -16,7 +16,7 @@ export default function AuthLayout({
<div className="pointer-events-none absolute -bottom-24 right-1/4 -z-10 h-96 w-96 rounded-full bg-teal-500/10 blur-3xl dark:bg-teal-500/10" />
{/* Auth Minimal Header */}
<header className="mx-auto flex w-full max-w-7xl items-center justify-between">
<header className="flex w-full items-center justify-between">
<Link
href="/"
className="flex items-center gap-2.5 transition-opacity hover:opacity-90"
......@@ -25,10 +25,7 @@ export default function AuthLayout({
<Bot className="h-5 w-5" />
</div>
<span className="font-bold text-base tracking-tight text-foreground">
DataCrawler{" "}
<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">
v1.0
</span>
DataCrawler
</span>
</Link>
......
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Đăng Nhập",
description: "Đăng nhập vào hệ thống quản trị và giám sát dữ liệu Data Crawler",
};
export default function LoginLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}
......@@ -86,7 +86,7 @@ function LoginForm() {
{t.auth.login.email}
</Label>
<div className="relative">
<Mail className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Mail className="pointer-events-none absolute left-3.5 top-[calc(50%+1px)] h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="email"
type="email"
......
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Đăng Ký Tài Khoản",
description: "Đăng ký tài khoản mới trên hệ thống thu thập và phân tích dữ liệu Data Crawler",
};
export default function RegisterLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Đặt Lại Mật Khẩu",
description: "Thiết lập mật khẩu bảo mật mới cho tài khoản hệ thống Data Crawler",
};
export default function ResetPasswordLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}
......@@ -14,7 +14,6 @@ import {
CheckCircle2,
AlertTriangle,
ArrowRight,
ShieldCheck,
} from "lucide-react";
import { resetPasswordSchema, ResetPasswordInput } from "@/schemas/auth.schema";
import { authService } from "@/services/auth.service";
......@@ -235,10 +234,7 @@ function ResetPasswordForm() {
{t.auth.reset.submitting}
</>
) : (
<>
<ShieldCheck className="mr-2 h-4 w-4" />
{t.auth.reset.submit}
</>
t.auth.reset.submit
)}
</Button>
</form>
......
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Xác Thực Email",
description: "Xác thực và kích hoạt tài khoản người dùng hệ thống Data Crawler",
};
export default function VerifyEmailLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Truy Cập Bị Từ Chối (403)",
description: "Bạn không có quyền truy cập vào khu vực này trong hệ thống Data Crawler",
};
export default function ForbiddenLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}
......@@ -15,7 +15,10 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Data Crawler System | Nền Tảng Thu Thập Dữ Liệu Tự Động",
title: {
default: "Bảng Điều Khiển | Data Crawler System",
template: "%s | Data Crawler",
},
description:
"Hệ thống quản lý và giám sát tác vụ cào dữ liệu web realtime, tích hợp Next.js, TanStack Query, React Hook Form và Tailwind CSS.",
};
......
......@@ -15,14 +15,11 @@ export function LanguageToggle() {
<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"
title={`${t.lang.toggleTitle}: ${locale === "vi" ? "Switch to English" : "Chuyển sang Tiếng Việt"}`}
className="inline-flex h-8 items-center gap-1.5 rounded-xl border border-border bg-card/60 px-2.5 text-xs font-semibold text-foreground transition-all hover:bg-muted cursor-pointer shadow-sm"
>
<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>
<span className="font-mono tracking-wide font-bold">{locale === "vi" ? "VN" : "EN"}</span>
</button>
);
}
......@@ -2,7 +2,7 @@
import React from "react";
import Link from "next/link";
import { Activity, Bot, Database, Globe, RefreshCw } from "lucide-react";
import { Activity, Bot, Database, Globe } from "lucide-react";
import { ThemeToggle } from "./theme-toggle";
import { LanguageToggle } from "./language-toggle";
import { UserMenu } from "./user-menu";
......@@ -13,21 +13,13 @@ export function Navbar() {
return (
<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="flex h-16 w-full items-center justify-between px-4 sm:px-6 lg:px-8">
{/* Brand */}
<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-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" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-bold text-lg tracking-tight text-foreground">DataCrawler</span>
<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">
v1.0
</span>
</div>
<p className="text-xs text-muted-foreground hidden sm:block">{t.nav.brandSub}</p>
</div>
<span className="font-bold text-lg tracking-tight text-foreground">DataCrawler</span>
</Link>
{/* Center Nav Links */}
......@@ -55,10 +47,10 @@ export function Navbar() {
</Link>
</nav>
{/* Right Controls: Theme Toggle, Language Toggle, Status, Reload & User Menu */}
{/* Right Controls: Theme Toggle, Language Toggle, Status & User Menu */}
<div className="flex items-center gap-2 sm:gap-3">
{/* 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">
<div className="hidden sm: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="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>
......@@ -72,15 +64,6 @@ export function Navbar() {
{/* Theme Switcher */}
<ThemeToggle />
{/* Reload Page */}
<button
onClick={() => window.location.reload()}
title={t.nav.reload}
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-3.5 w-3.5" />
</button>
{/* User Profile & Auth Menu */}
<UserMenu />
</div>
......
"use client";
import React, { useSyncExternalStore } from "react";
import React, { useState, useRef, useEffect, useSyncExternalStore } from "react";
import { useTheme } from "next-themes";
import { Monitor, Moon, Sun } from "lucide-react";
import { Check, Monitor, Moon, Sun } from "lucide-react";
import { useLanguage } from "@/providers/language-provider";
const emptySubscribe = () => () => {};
......@@ -10,61 +10,122 @@ const emptySubscribe = () => () => {};
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
const { t } = useLanguage();
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const mounted = useSyncExternalStore(emptySubscribe, () => true, () => false);
// Close dropdown on click outside or Escape key
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
setIsOpen(false);
}
}
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleKeyDown);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleKeyDown);
};
}, [isOpen]);
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" />
<div className="h-8 w-8 rounded-xl border border-border/60 bg-card/60 animate-pulse" />
);
}
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>
const themeOptions = [
{
key: "light",
label: t.theme.light,
icon: Sun,
iconColor: "text-amber-500",
},
{
key: "dark",
label: t.theme.dark,
icon: Moon,
iconColor: "text-emerald-500",
},
{
key: "system",
label: t.theme.system,
icon: Monitor,
iconColor: "text-emerald-600 dark:text-emerald-400",
},
] as const;
<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>
const currentOption =
themeOptions.find((opt) => opt.key === theme) || themeOptions[2];
const CurrentIcon = currentOption.icon;
return (
<div ref={menuRef} className="relative inline-block text-left">
<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"
onClick={() => setIsOpen((prev) => !prev)}
aria-haspopup="true"
aria-expanded={isOpen}
title={t.theme.toggleTitle}
className={`flex h-8 w-8 items-center justify-center rounded-xl border transition-all cursor-pointer shadow-sm ${
isOpen
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "border-border bg-card/60 text-muted-foreground hover:bg-muted hover:text-foreground"
}`}
>
<Monitor className="h-4 w-4" />
<span className="sr-only">{t.theme.system}</span>
<CurrentIcon className={`h-4 w-4 ${currentOption.iconColor}`} />
<span className="sr-only">{t.theme.toggleTitle}</span>
</button>
{isOpen && (
<div
role="menu"
aria-orientation="vertical"
className="absolute right-0 mt-2 w-36 origin-top-right rounded-2xl border border-emerald-500/20 bg-card/95 p-1.5 shadow-xl shadow-emerald-950/10 backdrop-blur-xl z-50 animate-in fade-in-50 zoom-in-95 duration-150"
>
<div className="space-y-0.5">
{themeOptions.map((opt) => {
const Icon = opt.icon;
const isSelected = theme === opt.key;
return (
<button
key={opt.key}
role="menuitem"
type="button"
onClick={() => {
setTheme(opt.key);
setIsOpen(false);
}}
className={`flex w-full items-center justify-between gap-2 rounded-xl px-2.5 py-1.5 text-xs transition-colors cursor-pointer ${
isSelected
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 font-semibold border border-emerald-500/20"
: "text-muted-foreground hover:bg-muted hover:text-foreground border border-transparent"
}`}
>
<div className="flex items-center gap-2">
<Icon className={`h-3.5 w-3.5 ${opt.iconColor}`} />
<span>{opt.label}</span>
</div>
{isSelected && (
<Check className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
)}
</button>
);
})}
</div>
</div>
)}
</div>
);
}
......@@ -137,7 +137,7 @@ export const translations = {
submitting: "Đang gửi...",
backToLogin: "Quay lại Đăng nhập",
successTitle: "Đã gửi liên kết khôi phục!",
successDesc: "Nếu email tồn tại trong hệ thống, bạn sẽ nhận được đường dẫn đặt lại mật khẩu trong ít phút.",
successDesc: "Đường dẫn đặt lại mật khẩu đã được gửi đến email của bạn. Vui lòng kiểm tra hộp thư.",
resendBtn: "Gửi lại email",
},
reset: {
......@@ -323,7 +323,7 @@ export const translations = {
submitting: "Sending...",
backToLogin: "Back to Sign In",
successTitle: "Reset Link Sent!",
successDesc: "If the email exists in our system, you will receive a password reset link shortly.",
successDesc: "A password reset link has been sent to your email address. Please check your inbox.",
resendBtn: "Resend email",
},
reset: {
......
......@@ -14,20 +14,27 @@ const LanguageContext = createContext<LanguageContextType | undefined>(undefined
const LANGUAGE_STORAGE_KEY = "data_crawler_lang";
export function LanguageProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<Locale>(() => {
if (typeof window !== "undefined") {
// Always initialize with "vi" to match SSR output and prevent hydration mismatch
const [locale, setLocaleState] = useState<Locale>("vi");
React.useEffect(() => {
try {
const saved = localStorage.getItem(LANGUAGE_STORAGE_KEY) as Locale | null;
if (saved && (saved === "vi" || saved === "en")) {
return saved;
setLocaleState(saved);
document.documentElement.lang = saved;
return;
}
const browserLang = navigator.language?.toLowerCase() || "";
if (browserLang.startsWith("vi")) {
return "vi";
const initial = browserLang.startsWith("vi") ? "vi" : "en";
if (initial !== "vi") {
setLocaleState(initial);
document.documentElement.lang = initial;
}
return "en";
} catch {
// Ignore errors if localStorage is restricted
}
return "vi";
});
}, []);
const setLocale = (newLocale: Locale) => {
setLocaleState(newLocale);
......
......@@ -3,6 +3,24 @@
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
// Suppress false-positive React 19 dev warning caused by next-themes inline ThemeScript
if (process.env.NODE_ENV === "development") {
const globalObj = (typeof window !== "undefined" ? window : globalThis) as unknown as {
__suppressed_react19_script_warning?: boolean;
};
if (!globalObj.__suppressed_react19_script_warning) {
globalObj.__suppressed_react19_script_warning = true;
const origError = console.error;
console.error = (...args: unknown[]) => {
const msg = typeof args[0] === "string" ? args[0] : "";
if (msg.includes("Encountered a script tag")) {
return;
}
origError.apply(console, args);
};
}
}
export function ThemeProvider({
children,
...props
......
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