Commit 580535d8 authored by ThinhNC's avatar ThinhNC

Merge branch 'feat/responsive-bottom-nav-and-ui-polish' into 'develop'

feat(ui): implement responsive bottom nav and polish interface metrics & localization

See merge request !9
parents 67f87917 8a7095fc
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Trung Tâm Xuất Dữ Liệu",
description:
"Quản lý danh sách các tệp dữ liệu đã xuất (CSV, JSON, XLSX, Markdown, ZIP) và tải file trực tiếp về máy tính.",
};
export default function ExportsLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}
"use client";
import React, { useState, useMemo, useRef, useEffect } from "react";
import Link from "next/link";
import {
Download,
Plus,
Search,
FileSpreadsheet,
FileText,
FileJson,
FileCode,
Archive,
RefreshCw,
Trash2,
ExternalLink,
ChevronDown,
Check,
HardDrive,
CheckCircle2,
Clock,
AlertTriangle,
FileArchive,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useLanguage } from "@/providers/language-provider";
import {
useCrawlExportsList,
useDownloadExport,
useDeleteCrawlExport,
} from "@/hooks/use-crawl-exports";
import { CrawlExport, ExportType } from "@/types/export";
import { CreateExportModal } from "@/components/exports/create-export-modal";
export default function ExportsPage() {
const { t, locale } = useLanguage();
const [searchQuery, setSearchQuery] = useState("");
const [selectedFormat, setSelectedFormat] = useState<string>("ALL");
const [selectedStatus, setSelectedStatus] = useState<string>("ALL");
const [isFormatDropdownOpen, setIsFormatDropdownOpen] = useState(false);
const formatDropdownRef = useRef<HTMLDivElement>(null);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const {
data: exportsData,
isLoading,
isError,
refetch,
} = useCrawlExportsList({ limit: 50 });
const downloadMutation = useDownloadExport();
const deleteMutation = useDeleteCrawlExport();
const exportsList = exportsData?.items || [];
// Close dropdown on click outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (formatDropdownRef.current && !formatDropdownRef.current.contains(event.target as Node)) {
setIsFormatDropdownOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
// Filter exports
const filteredExports = useMemo(() => {
return exportsList.filter((item) => {
const matchesSearch =
item.fileName.toLowerCase().includes(searchQuery.toLowerCase()) ||
item.jobId.toLowerCase().includes(searchQuery.toLowerCase());
const matchesFormat =
selectedFormat === "ALL" || item.exportType === selectedFormat;
const matchesStatus =
selectedStatus === "ALL" || item.status === selectedStatus;
return matchesSearch && matchesFormat && matchesStatus;
});
}, [exportsList, searchQuery, selectedFormat, selectedStatus]);
// Compute metrics
const completedCount = useMemo(
() => exportsList.filter((e) => e.status === "COMPLETED").length,
[exportsList]
);
const totalBytes = useMemo(
() => exportsList.reduce((acc, curr) => acc + (curr.fileSize || 0), 0),
[exportsList]
);
const formatFileSize = (bytes: number | null) => {
if (!bytes) return "--";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
};
const getFormatBadge = (type: ExportType) => {
switch (type) {
case "CSV":
return {
icon: FileText,
cls: "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20",
};
case "JSON":
return {
icon: FileJson,
cls: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
};
case "XLSX":
return {
icon: FileSpreadsheet,
cls: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
};
case "MARKDOWN":
return {
icon: FileCode,
cls: "bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/20",
};
case "ZIP":
return {
icon: Archive,
cls: "bg-slate-500/10 text-slate-600 dark:text-slate-400 border-slate-500/20",
};
}
};
const handleDownload = (item: CrawlExport) => {
downloadMutation.mutate({ exportId: item.id, fileName: item.fileName });
};
const handleDelete = (item: CrawlExport) => {
if (window.confirm(`${t.exports.table.deleteConfirm}\n("${item.fileName}")`)) {
deleteMutation.mutate(item.id);
}
};
return (
<div className="min-h-screen bg-background pb-16">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 pt-8 space-y-8">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="space-y-1">
<div className="inline-flex items-center gap-2 rounded-full bg-emerald-500/10 px-3 py-1 text-xs font-semibold text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<Download className="h-3.5 w-3.5" />
<span>{t.exports.title}</span>
</div>
<h1 className="text-2xl sm:text-3xl font-extrabold tracking-tight text-foreground">
{t.exports.title}
</h1>
<p className="text-sm text-muted-foreground max-w-2xl">
{t.exports.subtitle}
</p>
</div>
<Button
onClick={() => setIsCreateModalOpen(true)}
className="rounded-2xl h-11 px-5 bg-emerald-600 hover:bg-emerald-700 text-white shadow-md shadow-emerald-600/20 transition-all hover:scale-[1.02] cursor-pointer"
>
<Plus className="h-4 w-4 mr-1.5" />
{t.exports.createBtn}
</Button>
</div>
{/* Impact Metrics / KPI Cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="p-4 rounded-3xl border border-emerald-500/15 bg-card/60 shadow-sm backdrop-blur-sm">
<p className="text-xs font-medium text-muted-foreground">{t.exports.stats.total}</p>
<p className="text-2xl font-bold tracking-tight text-foreground mt-1">{exportsList.length}</p>
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 mt-0.5">{t.exports.stats.totalDesc}</p>
</div>
<div className="p-4 rounded-3xl border border-emerald-500/15 bg-card/60 shadow-sm backdrop-blur-sm">
<p className="text-xs font-medium text-muted-foreground">{t.exports.stats.completed}</p>
<p className="text-2xl font-bold tracking-tight text-foreground mt-1">{completedCount}</p>
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 mt-0.5">{t.exports.stats.completedDesc}</p>
</div>
<div className="p-4 rounded-3xl border border-emerald-500/15 bg-card/60 shadow-sm backdrop-blur-sm">
<p className="text-xs font-medium text-muted-foreground">{t.exports.stats.storage}</p>
<p className="text-2xl font-bold tracking-tight text-foreground mt-1">
{formatFileSize(totalBytes)}
</p>
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 mt-0.5">{t.exports.stats.storageDesc}</p>
</div>
<div className="p-4 rounded-3xl border border-emerald-500/15 bg-card/60 shadow-sm backdrop-blur-sm">
<p className="text-xs font-medium text-muted-foreground">{t.exports.stats.formats}</p>
<p className="text-2xl font-bold tracking-tight text-foreground mt-1">5</p>
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 mt-0.5">{t.exports.stats.formatsDesc}</p>
</div>
</div>
{/* Filter Bar */}
<div className="flex flex-col sm:flex-row gap-3 items-stretch sm:items-center justify-between">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t.exports.searchPlaceholder}
className="pl-9 rounded-2xl border-border/80 text-xs bg-card/60 focus:ring-emerald-500/20"
/>
</div>
{/* Format Dropdown conforming to ThemeToggle style */}
<div className="relative" ref={formatDropdownRef}>
<button
type="button"
onClick={() => setIsFormatDropdownOpen(!isFormatDropdownOpen)}
className="w-full sm:w-auto flex items-center justify-between gap-2.5 rounded-2xl border border-border/80 bg-card/80 px-3.5 py-2 text-xs font-medium text-foreground hover:bg-muted/60 transition-colors cursor-pointer"
>
<span>
{selectedFormat === "ALL" ? t.exports.allFormats : selectedFormat}
</span>
<ChevronDown
className={`h-3.5 w-3.5 text-muted-foreground transition-transform duration-200 ${
isFormatDropdownOpen ? "rotate-180" : ""
}`}
/>
</button>
{isFormatDropdownOpen && (
<div className="absolute right-0 mt-2 w-48 rounded-2xl border border-emerald-500/20 bg-card/95 p-1.5 shadow-xl backdrop-blur-xl z-50 animate-in fade-in-50 zoom-in-95 duration-150">
<button
type="button"
onClick={() => {
setSelectedFormat("ALL");
setIsFormatDropdownOpen(false);
}}
className={`w-full flex items-center justify-between rounded-xl px-2.5 py-2 text-xs cursor-pointer ${
selectedFormat === "ALL"
? "bg-emerald-500/10 text-emerald-600 font-semibold"
: "text-muted-foreground hover:bg-muted"
}`}
>
<span>{t.exports.allFormats}</span>
{selectedFormat === "ALL" && <Check className="h-3.5 w-3.5" />}
</button>
{(["CSV", "JSON", "XLSX", "MARKDOWN", "ZIP"] as ExportType[]).map((fmt) => (
<button
key={fmt}
type="button"
onClick={() => {
setSelectedFormat(fmt);
setIsFormatDropdownOpen(false);
}}
className={`w-full flex items-center justify-between rounded-xl px-2.5 py-2 text-xs cursor-pointer ${
selectedFormat === fmt
? "bg-emerald-500/10 text-emerald-600 font-semibold"
: "text-muted-foreground hover:bg-muted"
}`}
>
<span>{fmt}</span>
{selectedFormat === fmt && <Check className="h-3.5 w-3.5" />}
</button>
))}
</div>
)}
</div>
</div>
{/* Exports Table Content */}
{isLoading ? (
<div className="space-y-3">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="h-20 rounded-3xl bg-muted/40 animate-pulse" />
))}
</div>
) : isError ? (
<div className="p-8 text-center rounded-3xl border border-red-500/20 bg-red-500/5 space-y-3">
<p className="text-sm text-red-500 font-medium">
Đã có lỗi xảy ra khi tải danh sách tệp xuất.
</p>
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
className="rounded-xl text-xs cursor-pointer"
>
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
{t.exports.retry}
</Button>
</div>
) : filteredExports.length === 0 ? (
<div className="p-12 text-center rounded-3xl border border-dashed border-border/80 bg-card/40 space-y-3">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
<FileArchive className="h-6 w-6" />
</div>
<h3 className="text-base font-bold text-foreground">
{t.exports.emptyTitle}
</h3>
<p className="text-xs text-muted-foreground max-w-md mx-auto">
{t.exports.emptyDesc}
</p>
<Button
size="sm"
onClick={() => setIsCreateModalOpen(true)}
className="rounded-2xl text-xs bg-emerald-600 hover:bg-emerald-700 text-white shadow-sm shadow-emerald-600/20 cursor-pointer"
>
<Plus className="h-3.5 w-3.5 mr-1" />
{t.exports.createBtn}
</Button>
</div>
) : (
<div className="overflow-x-auto rounded-3xl border border-emerald-500/15 bg-card/80 shadow-sm">
<table className="w-full text-left text-xs">
<thead className="border-b border-border/60 bg-muted/40 text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">
<tr>
<th className="px-5 py-3.5">{t.exports.table.file}</th>
<th className="px-4 py-3.5">{t.exports.table.job}</th>
<th className="px-4 py-3.5">{t.exports.table.size}</th>
<th className="px-4 py-3.5">{t.exports.table.status}</th>
<th className="px-4 py-3.5">{t.exports.table.createdAt}</th>
<th className="px-5 py-3.5 text-right">{t.exports.table.actions}</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{filteredExports.map((item) => {
const badge = getFormatBadge(item.exportType);
const FormatIcon = badge.icon;
return (
<tr
key={item.id}
className="hover:bg-muted/30 transition-colors group"
>
{/* File Name & Format */}
<td className="px-5 py-4">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-xl border ${badge.cls} shrink-0`}>
<FormatIcon className="h-4 w-4" />
</div>
<div className="min-w-0">
<p className="font-bold text-foreground truncate max-w-xs group-hover:text-emerald-600 dark:group-hover:text-emerald-400 transition-colors">
{item.fileName}
</p>
<span className="text-[10px] text-muted-foreground font-mono">
{item.exportType}
</span>
</div>
</div>
</td>
{/* Job ID */}
<td className="px-4 py-4">
<Link
href={`/crawl-jobs/${item.jobId}`}
className="inline-flex items-center gap-1 font-mono text-xs text-emerald-600 dark:text-emerald-400 hover:underline"
>
<span>{item.jobId.slice(0, 12)}...</span>
<ExternalLink className="h-3 w-3" />
</Link>
</td>
{/* File Size */}
<td className="px-4 py-4 font-mono text-muted-foreground">
{formatFileSize(item.fileSize)}
</td>
{/* Status */}
<td className="px-4 py-4">
{item.status === "COMPLETED" ? (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 px-2.5 py-0.5 text-[10px] font-semibold border border-emerald-500/20">
<CheckCircle2 className="h-3 w-3" />
{t.exports.status.completed}
</span>
) : item.status === "PROCESSING" ? (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/10 text-amber-600 dark:text-amber-400 px-2.5 py-0.5 text-[10px] font-semibold border border-amber-500/20">
<RefreshCw className="h-3 w-3 animate-spin" />
{t.exports.status.processing}
</span>
) : (
<span className="inline-flex items-center gap-1 rounded-full bg-red-500/10 text-red-600 dark:text-red-400 px-2.5 py-0.5 text-[10px] font-semibold border border-red-500/20">
<AlertTriangle className="h-3 w-3" />
{t.exports.status.failed}
</span>
)}
</td>
{/* Created At */}
<td className="px-4 py-4 text-muted-foreground">
{new Date(item.createdAt).toLocaleDateString(
locale === "vi" ? "vi-VN" : "en-US",
{
hour: "2-digit",
minute: "2-digit",
day: "numeric",
month: "short",
}
)}
</td>
{/* Actions */}
<td className="px-5 py-4 text-right">
<div className="flex items-center justify-end gap-1.5">
<Button
size="sm"
onClick={() => handleDownload(item)}
disabled={
item.status !== "COMPLETED" ||
downloadMutation.isPending
}
className="rounded-xl h-8 px-2.5 text-xs bg-emerald-600/15 hover:bg-emerald-600 text-emerald-700 dark:text-emerald-300 hover:text-white border border-emerald-500/25 transition-all cursor-pointer disabled:opacity-40"
>
<Download className="h-3.5 w-3.5 mr-1" />
{t.exports.table.download}
</Button>
<button
type="button"
onClick={() => handleDelete(item)}
className="p-1.5 rounded-xl text-muted-foreground hover:bg-red-500/10 hover:text-red-500 transition-colors cursor-pointer"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{/* Modal */}
<CreateExportModal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
/>
</div>
);
}
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Quản Lý Lập Lịch Tự Động",
description:
"Giao diện thiết lập chu kỳ cào dữ liệu định kỳ, hỗ trợ bộ chọn trực quan theo chu kỳ hoặc biểu thức Cron và theo dõi lịch sử thực thi.",
};
export default function SchedulesLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}
"use client";
import React, { useState, useMemo, useRef, useEffect } from "react";
import {
Calendar,
Plus,
Search,
Clock,
Play,
History,
Edit2,
Trash2,
ExternalLink,
ChevronDown,
Check,
RefreshCw,
Zap,
Layers,
CalendarCheck,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useLanguage } from "@/providers/language-provider";
import {
useCrawlSchedulesList,
useUpdateCrawlSchedule,
useDeleteCrawlSchedule,
useTriggerScheduleRun,
} from "@/hooks/use-crawl-schedules";
import { CrawlSchedule, ScheduleFrequency } from "@/types/crawl-schedule";
import { ScheduleFormModal } from "@/components/schedules/schedule-form-modal";
import { ScheduleHistoryModal } from "@/components/schedules/schedule-history-modal";
export default function SchedulesPage() {
const { t, locale } = useLanguage();
const [searchQuery, setSearchQuery] = useState("");
const [selectedFrequency, setSelectedFrequency] = useState<string>("ALL");
const [isFreqDropdownOpen, setIsFreqDropdownOpen] = useState(false);
const freqDropdownRef = useRef<HTMLDivElement>(null);
const [isFormModalOpen, setIsFormModalOpen] = useState(false);
const [editingSchedule, setEditingSchedule] = useState<CrawlSchedule | null>(null);
const [isHistoryModalOpen, setIsHistoryModalOpen] = useState(false);
const [historySchedule, setHistorySchedule] = useState<CrawlSchedule | null>(null);
const {
data: schedulesData,
isLoading,
isError,
refetch,
} = useCrawlSchedulesList({
limit: 50,
});
const updateMutation = useUpdateCrawlSchedule();
const deleteMutation = useDeleteCrawlSchedule();
const triggerRunMutation = useTriggerScheduleRun();
const schedules = schedulesData?.items || [];
// Close dropdown on click outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (freqDropdownRef.current && !freqDropdownRef.current.contains(event.target as Node)) {
setIsFreqDropdownOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
// Filter schedules
const filteredSchedules = useMemo(() => {
return schedules.filter((s) => {
const matchesSearch =
s.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
s.startUrl.toLowerCase().includes(searchQuery.toLowerCase());
const matchesFreq =
selectedFrequency === "ALL" || s.frequency === selectedFrequency;
return matchesSearch && matchesFreq;
});
}, [schedules, searchQuery, selectedFrequency]);
// Compute metrics
const activeCount = useMemo(() => schedules.filter((s) => s.isActive).length, [schedules]);
const autoDiffCount = useMemo(() => schedules.filter((s) => s.autoDiff).length, [schedules]);
const handleToggleStatus = (schedule: CrawlSchedule) => {
updateMutation.mutate({
id: schedule.id,
dto: { isActive: !schedule.isActive },
});
};
const handleRunNow = (scheduleId: string) => {
triggerRunMutation.mutate(scheduleId);
};
const handleDelete = (id: string, name: string) => {
if (window.confirm(`${t.schedules.card.deleteConfirm}\n("${name}")`)) {
deleteMutation.mutate(id);
}
};
const getFrequencyBadgeLabel = (freq: ScheduleFrequency) => {
switch (freq) {
case "DAILY":
return t.schedules.frequency.daily;
case "WEEKLY":
return t.schedules.frequency.weekly;
case "MONTHLY":
return t.schedules.frequency.monthly;
case "CUSTOM":
return t.schedules.frequency.custom;
}
};
return (
<div className="min-h-screen bg-background pb-16">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 pt-8 space-y-8">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="space-y-1">
<div className="inline-flex items-center gap-2 rounded-full bg-emerald-500/10 px-3 py-1 text-xs font-semibold text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<Calendar className="h-3.5 w-3.5" />
<span>{t.schedules.title}</span>
</div>
<h1 className="text-2xl sm:text-3xl font-extrabold tracking-tight text-foreground">
{t.schedules.title}
</h1>
<p className="text-sm text-muted-foreground max-w-2xl">
{t.schedules.subtitle}
</p>
</div>
<Button
onClick={() => {
setEditingSchedule(null);
setIsFormModalOpen(true);
}}
className="rounded-2xl h-11 px-5 bg-emerald-600 hover:bg-emerald-700 text-white shadow-md shadow-emerald-600/20 transition-all hover:scale-[1.02] cursor-pointer"
>
<Plus className="h-4 w-4 mr-1.5" />
{t.schedules.createBtn}
</Button>
</div>
{/* Impact Metrics / KPI Cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="p-4 rounded-3xl border border-emerald-500/15 bg-card/60 shadow-sm backdrop-blur-sm">
<p className="text-xs font-medium text-muted-foreground">{t.schedules.stats.total}</p>
<p className="text-2xl font-bold tracking-tight text-foreground mt-1">{schedules.length}</p>
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 mt-0.5">{t.schedules.stats.totalDesc}</p>
</div>
<div className="p-4 rounded-3xl border border-emerald-500/15 bg-card/60 shadow-sm backdrop-blur-sm">
<p className="text-xs font-medium text-muted-foreground">{t.schedules.stats.active}</p>
<p className="text-2xl font-bold tracking-tight text-foreground mt-1">{activeCount}</p>
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 mt-0.5">{t.schedules.stats.activeDesc}</p>
</div>
<div className="p-4 rounded-3xl border border-emerald-500/15 bg-card/60 shadow-sm backdrop-blur-sm">
<p className="text-xs font-medium text-muted-foreground">{t.schedules.stats.today}</p>
<p className="text-2xl font-bold tracking-tight text-foreground mt-1">{activeCount > 0 ? activeCount : 0}</p>
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 mt-0.5">{t.schedules.stats.todayDesc}</p>
</div>
<div className="p-4 rounded-3xl border border-emerald-500/15 bg-card/60 shadow-sm backdrop-blur-sm">
<p className="text-xs font-medium text-muted-foreground">{t.schedules.stats.autoDiff}</p>
<p className="text-2xl font-bold tracking-tight text-foreground mt-1">{autoDiffCount}</p>
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 mt-0.5">{t.schedules.stats.autoDiffDesc}</p>
</div>
</div>
{/* Filter Bar */}
<div className="flex flex-col sm:flex-row gap-3 items-stretch sm:items-center justify-between">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t.schedules.searchPlaceholder}
className="pl-9 rounded-2xl border-border/80 text-xs bg-card/60 focus:ring-emerald-500/20"
/>
</div>
{/* Frequency Dropdown */}
<div className="relative" ref={freqDropdownRef}>
<button
type="button"
onClick={() => setIsFreqDropdownOpen(!isFreqDropdownOpen)}
className="w-full sm:w-auto flex items-center justify-between gap-2.5 rounded-2xl border border-border/80 bg-card/80 px-3.5 py-2 text-xs font-medium text-foreground hover:bg-muted/60 transition-colors cursor-pointer"
>
<div className="flex items-center gap-2">
<Clock className="h-3.5 w-3.5 text-emerald-500" />
<span>
{selectedFrequency === "ALL"
? t.schedules.allFrequencies
: getFrequencyBadgeLabel(selectedFrequency as ScheduleFrequency)}
</span>
</div>
<ChevronDown
className={`h-3.5 w-3.5 text-muted-foreground transition-transform duration-200 ${
isFreqDropdownOpen ? "rotate-180" : ""
}`}
/>
</button>
{isFreqDropdownOpen && (
<div className="absolute right-0 mt-2 w-48 rounded-2xl border border-emerald-500/20 bg-card/95 p-1.5 shadow-xl backdrop-blur-xl z-50 animate-in fade-in-50 zoom-in-95 duration-150">
<button
type="button"
onClick={() => {
setSelectedFrequency("ALL");
setIsFreqDropdownOpen(false);
}}
className={`w-full flex items-center justify-between rounded-xl px-2.5 py-2 text-xs cursor-pointer ${
selectedFrequency === "ALL"
? "bg-emerald-500/10 text-emerald-600 font-semibold"
: "text-muted-foreground hover:bg-muted"
}`}
>
<span>{t.schedules.allFrequencies}</span>
{selectedFrequency === "ALL" && <Check className="h-3.5 w-3.5" />}
</button>
{(["DAILY", "WEEKLY", "MONTHLY", "CUSTOM"] as ScheduleFrequency[]).map((f) => (
<button
key={f}
type="button"
onClick={() => {
setSelectedFrequency(f);
setIsFreqDropdownOpen(false);
}}
className={`w-full flex items-center justify-between rounded-xl px-2.5 py-2 text-xs cursor-pointer ${
selectedFrequency === f
? "bg-emerald-500/10 text-emerald-600 font-semibold"
: "text-muted-foreground hover:bg-muted"
}`}
>
<span>{getFrequencyBadgeLabel(f)}</span>
{selectedFrequency === f && <Check className="h-3.5 w-3.5" />}
</button>
))}
</div>
)}
</div>
</div>
{/* Schedules List Content */}
{isLoading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="h-28 rounded-3xl bg-muted/40 animate-pulse" />
))}
</div>
) : isError ? (
<div className="p-8 text-center rounded-3xl border border-red-500/20 bg-red-500/5 space-y-3">
<p className="text-sm text-red-500 font-medium">
Đã có lỗi xảy ra khi tải danh sách lịch cào tự động.
</p>
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
className="rounded-xl text-xs cursor-pointer"
>
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
{t.schedules.retry}
</Button>
</div>
) : filteredSchedules.length === 0 ? (
<div className="p-12 text-center rounded-3xl border border-dashed border-border/80 bg-card/40 space-y-3">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
<CalendarCheck className="h-6 w-6" />
</div>
<h3 className="text-base font-bold text-foreground">
{t.schedules.emptyTitle}
</h3>
<p className="text-xs text-muted-foreground max-w-md mx-auto">
{t.schedules.emptyDesc}
</p>
<Button
size="sm"
onClick={() => {
setEditingSchedule(null);
setIsFormModalOpen(true);
}}
className="rounded-2xl text-xs bg-emerald-600 hover:bg-emerald-700 text-white shadow-sm shadow-emerald-600/20 cursor-pointer"
>
<Plus className="h-3.5 w-3.5 mr-1" />
{t.schedules.createBtn}
</Button>
</div>
) : (
<div className="space-y-3">
{filteredSchedules.map((schedule) => (
<div
key={schedule.id}
className="group p-5 rounded-3xl border border-emerald-500/15 bg-card/80 hover:border-emerald-500/30 hover:shadow-lg hover:shadow-emerald-950/10 hover:scale-[1.005] transition-all duration-300"
>
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
{/* Left: Info & URL */}
<div className="space-y-1.5 flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-bold text-foreground group-hover:text-emerald-600 dark:group-hover:text-emerald-400 transition-colors truncate">
{schedule.name}
</h3>
{/* Frequency Badge */}
<span className="rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 px-2.5 py-0.5 text-[11px] font-semibold border border-emerald-500/20">
{getFrequencyBadgeLabel(schedule.frequency)}
</span>
{/* Active Status Badge */}
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold border ${
schedule.isActive
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
: "bg-muted text-muted-foreground border-border"
}`}
>
{schedule.isActive
? t.schedules.card.activeStatus
: t.schedules.card.pausedStatus}
</span>
</div>
<p className="text-xs text-muted-foreground truncate flex items-center gap-1 font-mono">
<span>{schedule.startUrl}</span>
</p>
<div className="flex flex-wrap items-center gap-4 text-[11px] text-muted-foreground pt-1">
<span>
{t.schedules.card.nextRun}:{" "}
<strong className="text-foreground font-semibold">
{schedule.nextRunAt
? new Date(schedule.nextRunAt).toLocaleDateString(
locale === "vi" ? "vi-VN" : "en-US",
{
hour: "2-digit",
minute: "2-digit",
day: "numeric",
month: "short",
}
)
: "--"}
</strong>
</span>
<span>
{t.schedules.card.lastRun}:{" "}
<strong className="text-foreground font-semibold">
{schedule.lastRunAt
? new Date(schedule.lastRunAt).toLocaleDateString(
locale === "vi" ? "vi-VN" : "en-US",
{
hour: "2-digit",
minute: "2-digit",
day: "numeric",
month: "short",
}
)
: "--"}
</strong>
</span>
</div>
</div>
{/* Right: Actions */}
<div className="flex flex-wrap items-center gap-2 self-start lg:self-center">
{/* Active/Paused Switch */}
<button
type="button"
onClick={() => handleToggleStatus(schedule)}
disabled={updateMutation.isPending}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
schedule.isActive ? "bg-emerald-600" : "bg-muted"
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
schedule.isActive ? "translate-x-5" : "translate-x-0"
}`}
/>
</button>
{/* Run Now Button */}
<Button
size="sm"
onClick={() => handleRunNow(schedule.id)}
disabled={triggerRunMutation.isPending}
className="rounded-xl h-8 px-2.5 text-xs bg-emerald-600 hover:bg-emerald-700 text-white shadow-sm shadow-emerald-600/20 cursor-pointer"
>
<Play className="h-3 w-3 mr-1" />
{triggerRunMutation.isPending
? t.schedules.card.runningNow
: t.schedules.card.runNow}
</Button>
{/* View History Button */}
<Button
size="sm"
variant="outline"
onClick={() => {
setHistorySchedule(schedule);
setIsHistoryModalOpen(true);
}}
className="rounded-xl h-8 px-2.5 text-xs border-border/80 text-muted-foreground hover:text-foreground cursor-pointer"
>
<History className="h-3 w-3 mr-1" />
{t.schedules.card.history}
</Button>
{/* Edit Button */}
<button
type="button"
onClick={() => {
setEditingSchedule(schedule);
setIsFormModalOpen(true);
}}
className="p-1.5 rounded-xl text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
>
<Edit2 className="h-3.5 w-3.5" />
</button>
{/* Delete Button */}
<button
type="button"
onClick={() => handleDelete(schedule.id, schedule.name)}
className="p-1.5 rounded-xl text-muted-foreground hover:bg-red-500/10 hover:text-red-500 transition-colors cursor-pointer"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Modals */}
<ScheduleFormModal
isOpen={isFormModalOpen}
onClose={() => setIsFormModalOpen(false)}
initialData={editingSchedule}
/>
<ScheduleHistoryModal
isOpen={isHistoryModalOpen}
onClose={() => setIsHistoryModalOpen(false)}
schedule={historySchedule}
/>
</div>
);
}
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Cài Đặt Nhà Phát Triển & Tích Hợp",
description:
"Quản lý khóa API Keys, cấu hình Webhook nhận sự kiện realtime và tích hợp ứng dụng bên ngoài với Data Crawler.",
};
export default function DeveloperSettingsLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}
"use client";
import React, { useState } from "react";
import {
Code2,
KeyRound,
Webhook,
Plus,
Copy,
Check,
Play,
Trash2,
Edit2,
RefreshCw,
Terminal,
ShieldAlert,
Send,
ExternalLink,
CheckCircle2,
AlertTriangle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useLanguage } from "@/providers/language-provider";
import {
useApiKeysList,
useToggleApiKey,
useRevokeApiKey,
useWebhookConfigsList,
useDeleteWebhookConfig,
useTestWebhookConfig,
useWebhookDeliveriesList,
useRedeliverWebhook,
} from "@/hooks/use-developer";
import { ApiKey, WebhookConfig } from "@/types/developer";
import { CreateApiKeyModal } from "@/components/developer/create-api-key-modal";
import { CreateWebhookModal } from "@/components/developer/create-webhook-modal";
export default function DeveloperSettingsPage() {
const { t, locale } = useLanguage();
const [activeTab, setActiveTab] = useState<"api-keys" | "webhooks" | "docs">("api-keys");
// API Keys state & hooks
const { data: apiKeys = [], isLoading: isLoadingKeys, refetch: refetchKeys } = useApiKeysList();
const toggleApiKeyMutation = useToggleApiKey();
const revokeApiKeyMutation = useRevokeApiKey();
const [isKeyModalOpen, setIsKeyModalOpen] = useState(false);
// Webhooks state & hooks
const { data: webhooks = [], isLoading: isLoadingWebhooks, refetch: refetchWebhooks } = useWebhookConfigsList();
const { data: deliveriesData, isLoading: isLoadingDeliveries, refetch: refetchDeliveries } = useWebhookDeliveriesList({ limit: 10 });
const testPingMutation = useTestWebhookConfig();
const deleteWebhookMutation = useDeleteWebhookConfig();
const redeliverMutation = useRedeliverWebhook();
const [isWebhookModalOpen, setIsWebhookModalOpen] = useState(false);
const [editingWebhook, setEditingWebhook] = useState<WebhookConfig | null>(null);
const [copiedSnippet, setCopiedSnippet] = useState<string | null>(null);
const deliveries = deliveriesData?.items || [];
const handleCopySnippet = (snippet: string, key: string) => {
navigator.clipboard.writeText(snippet);
setCopiedSnippet(key);
setTimeout(() => setCopiedSnippet(null), 2000);
};
const handleToggleKey = (key: ApiKey) => {
toggleApiKeyMutation.mutate({ id: key.id, isActive: !key.isActive });
};
const handleRevokeKey = (key: ApiKey) => {
if (window.confirm(`${t.developer.apiKeys.table.revokeConfirm}\n("${key.name}")`)) {
revokeApiKeyMutation.mutate(key.id);
}
};
const handleTestPing = (webhookId: string) => {
testPingMutation.mutate(webhookId);
};
const handleDeleteWebhook = (wh: WebhookConfig) => {
if (window.confirm(`${t.developer.webhooks.table.deleteConfirm}\n("${wh.url}")`)) {
deleteWebhookMutation.mutate(wh.id);
}
};
const curlSnippet = `curl -X POST "https://api.datacrawler.io/api/v1/crawl-jobs" \\
-H "X-API-Key: dc_live_your_secret_key_here" \\
-H "Content-Type: application/json" \\
-d '{
"startUrl": "https://vnexpress.net/thoi-su",
"mode": "CRAWL",
"maxPages": 20
}'`;
const nodeSnippet = `import axios from 'axios';
const client = axios.create({
baseURL: 'https://api.datacrawler.io/api/v1',
headers: {
'X-API-Key': 'dc_live_your_secret_key_here',
'Content-Type': 'application/json'
}
});
const response = await client.post('/crawl-jobs', {
startUrl: 'https://vnexpress.net/thoi-su',
mode: 'CRAWL',
maxPages: 20
});
console.log('Job Created:', response.data);`;
return (
<div className="min-h-screen bg-background pb-16">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 pt-8 space-y-8">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="space-y-1">
<div className="inline-flex items-center gap-2 rounded-full bg-emerald-500/10 px-3 py-1 text-xs font-semibold text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<Code2 className="h-3.5 w-3.5" />
<span>{t.developer.title}</span>
</div>
<h1 className="text-2xl sm:text-3xl font-extrabold tracking-tight text-foreground">
{t.developer.title}
</h1>
<p className="text-sm text-muted-foreground max-w-2xl">
{t.developer.subtitle}
</p>
</div>
</div>
{/* Tabs Bar */}
<div className="flex border-b border-border/70 space-x-2">
<button
type="button"
onClick={() => setActiveTab("api-keys")}
className={`flex items-center gap-2 px-4 py-3 text-xs font-bold border-b-2 transition-all cursor-pointer ${
activeTab === "api-keys"
? "border-emerald-500 text-emerald-600 dark:text-emerald-400"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<KeyRound className="h-4 w-4" />
<span>{t.developer.tabs.apiKeys}</span>
<span className="rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 text-[10px] px-2 py-0.5 border border-emerald-500/20">
{apiKeys.length}
</span>
</button>
<button
type="button"
onClick={() => setActiveTab("webhooks")}
className={`flex items-center gap-2 px-4 py-3 text-xs font-bold border-b-2 transition-all cursor-pointer ${
activeTab === "webhooks"
? "border-emerald-500 text-emerald-600 dark:text-emerald-400"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<Webhook className="h-4 w-4" />
<span>{t.developer.tabs.webhooks}</span>
<span className="rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 text-[10px] px-2 py-0.5 border border-emerald-500/20">
{webhooks.length}
</span>
</button>
<button
type="button"
onClick={() => setActiveTab("docs")}
className={`flex items-center gap-2 px-4 py-3 text-xs font-bold border-b-2 transition-all cursor-pointer ${
activeTab === "docs"
? "border-emerald-500 text-emerald-600 dark:text-emerald-400"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<Terminal className="h-4 w-4" />
<span>{t.developer.tabs.docs}</span>
</button>
</div>
{/* TAB 1: API KEYS */}
{activeTab === "api-keys" && (
<div className="space-y-6 animate-in fade-in-50 duration-200">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h3 className="text-base font-bold text-foreground">
{t.developer.apiKeys.title}
</h3>
<p className="text-xs text-muted-foreground">
{t.developer.apiKeys.desc}
</p>
</div>
<Button
onClick={() => setIsKeyModalOpen(true)}
className="rounded-2xl h-10 px-4 bg-emerald-600 hover:bg-emerald-700 text-white shadow-sm shadow-emerald-600/20 cursor-pointer"
>
<Plus className="h-4 w-4 mr-1.5" />
{t.developer.apiKeys.createBtn}
</Button>
</div>
{/* Keys Table */}
{isLoadingKeys ? (
<div className="h-32 rounded-3xl bg-muted/40 animate-pulse" />
) : apiKeys.length === 0 ? (
<div className="p-10 text-center rounded-3xl border border-dashed border-border/80 bg-card/40 space-y-3">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
<KeyRound className="h-6 w-6" />
</div>
<h4 className="text-sm font-bold text-foreground">
{t.developer.apiKeys.emptyTitle}
</h4>
<p className="text-xs text-muted-foreground max-w-sm mx-auto">
{t.developer.apiKeys.emptyDesc}
</p>
<Button
size="sm"
onClick={() => setIsKeyModalOpen(true)}
className="rounded-2xl text-xs bg-emerald-600 hover:bg-emerald-700 text-white cursor-pointer"
>
<Plus className="h-3.5 w-3.5 mr-1" />
{t.developer.apiKeys.createBtn}
</Button>
</div>
) : (
<div className="overflow-x-auto rounded-3xl border border-emerald-500/15 bg-card/80 shadow-sm">
<table className="w-full text-left text-xs">
<thead className="border-b border-border/60 bg-muted/40 text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">
<tr>
<th className="px-5 py-3.5">{t.developer.apiKeys.table.name}</th>
<th className="px-4 py-3.5">{t.developer.apiKeys.table.prefix}</th>
<th className="px-4 py-3.5">{t.developer.apiKeys.table.status}</th>
<th className="px-4 py-3.5">{t.developer.apiKeys.table.lastUsed}</th>
<th className="px-4 py-3.5">{t.developer.apiKeys.table.expires}</th>
<th className="px-5 py-3.5 text-right">{t.developer.apiKeys.table.actions}</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{apiKeys.map((key) => (
<tr key={key.id} className="hover:bg-muted/30 transition-colors">
<td className="px-5 py-4 font-bold text-foreground">
{key.name}
</td>
<td className="px-4 py-4 font-mono text-muted-foreground">
{key.keyPrefix}...****
</td>
<td className="px-4 py-4">
<button
type="button"
onClick={() => handleToggleKey(key)}
className={`rounded-full px-2.5 py-0.5 text-[10px] font-semibold border transition-colors cursor-pointer ${
key.isActive
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
: "bg-muted text-muted-foreground border-border"
}`}
>
{key.isActive
? t.developer.apiKeys.table.active
: t.developer.apiKeys.table.inactive}
</button>
</td>
<td className="px-4 py-4 text-muted-foreground">
{key.lastUsedAt
? new Date(key.lastUsedAt).toLocaleDateString(
locale === "vi" ? "vi-VN" : "en-US",
{ month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }
)
: t.developer.apiKeys.table.never}
</td>
<td className="px-4 py-4 text-muted-foreground">
{key.expiresAt
? new Date(key.expiresAt).toLocaleDateString(
locale === "vi" ? "vi-VN" : "en-US",
{ month: "short", day: "numeric", year: "numeric" }
)
: t.developer.apiKeys.table.never}
</td>
<td className="px-5 py-4 text-right">
<Button
size="sm"
variant="ghost"
onClick={() => handleRevokeKey(key)}
className="rounded-xl h-8 px-2.5 text-xs text-red-500 hover:bg-red-500/10 cursor-pointer"
>
<Trash2 className="h-3.5 w-3.5 mr-1" />
{t.developer.apiKeys.table.revoke}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{/* TAB 2: WEBHOOKS */}
{activeTab === "webhooks" && (
<div className="space-y-8 animate-in fade-in-50 duration-200">
{/* Webhook Configs Section */}
<div className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h3 className="text-base font-bold text-foreground">
{t.developer.webhooks.title}
</h3>
<p className="text-xs text-muted-foreground">
{t.developer.webhooks.desc}
</p>
</div>
<Button
onClick={() => {
setEditingWebhook(null);
setIsWebhookModalOpen(true);
}}
className="rounded-2xl h-10 px-4 bg-emerald-600 hover:bg-emerald-700 text-white shadow-sm shadow-emerald-600/20 cursor-pointer"
>
<Plus className="h-4 w-4 mr-1.5" />
{t.developer.webhooks.createBtn}
</Button>
</div>
{isLoadingWebhooks ? (
<div className="h-32 rounded-3xl bg-muted/40 animate-pulse" />
) : webhooks.length === 0 ? (
<div className="p-10 text-center rounded-3xl border border-dashed border-border/80 bg-card/40 space-y-3">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
<Webhook className="h-6 w-6" />
</div>
<h4 className="text-sm font-bold text-foreground">
{t.developer.webhooks.emptyTitle}
</h4>
<p className="text-xs text-muted-foreground max-w-sm mx-auto">
{t.developer.webhooks.emptyDesc}
</p>
<Button
size="sm"
onClick={() => {
setEditingWebhook(null);
setIsWebhookModalOpen(true);
}}
className="rounded-2xl text-xs bg-emerald-600 hover:bg-emerald-700 text-white cursor-pointer"
>
<Plus className="h-3.5 w-3.5 mr-1" />
{t.developer.webhooks.createBtn}
</Button>
</div>
) : (
<div className="space-y-3">
{webhooks.map((wh) => (
<div
key={wh.id}
className="flex flex-col lg:flex-row lg:items-center justify-between p-4 rounded-3xl border border-emerald-500/15 bg-card/80 gap-4"
>
<div className="space-y-1.5 min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="font-mono text-xs font-bold text-foreground truncate">
{wh.url}
</p>
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold border ${
wh.isActive
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
: "bg-muted text-muted-foreground border-border"
}`}
>
{wh.isActive
? t.developer.webhooks.table.active
: t.developer.webhooks.table.inactive}
</span>
</div>
<div className="flex flex-wrap gap-1.5 pt-1">
{wh.events.map((ev) => (
<span
key={ev}
className="rounded-lg bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 px-2 py-0.5 text-[10px] font-mono border border-emerald-500/20"
>
{ev}
</span>
))}
</div>
</div>
<div className="flex items-center gap-2 self-start lg:self-center">
<Button
size="sm"
onClick={() => handleTestPing(wh.id)}
disabled={testPingMutation.isPending}
className="rounded-xl h-8 px-2.5 text-xs bg-emerald-600/15 hover:bg-emerald-600 text-emerald-700 dark:text-emerald-300 hover:text-white border border-emerald-500/25 transition-all cursor-pointer"
>
<Send className="h-3 w-3 mr-1" />
{t.developer.webhooks.table.testPing}
</Button>
<button
type="button"
onClick={() => {
setEditingWebhook(wh);
setIsWebhookModalOpen(true);
}}
className="p-1.5 rounded-xl text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
>
<Edit2 className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => handleDeleteWebhook(wh)}
className="p-1.5 rounded-xl text-muted-foreground hover:bg-red-500/10 hover:text-red-500 transition-colors cursor-pointer"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Deliveries Log Section */}
<div className="space-y-4 pt-4 border-t border-border/60">
<div>
<h3 className="text-base font-bold text-foreground">
{t.developer.webhooks.deliveries.title}
</h3>
<p className="text-xs text-muted-foreground">
{t.developer.webhooks.deliveries.desc}
</p>
</div>
{isLoadingDeliveries ? (
<div className="h-28 rounded-3xl bg-muted/40 animate-pulse" />
) : deliveries.length === 0 ? (
<p className="text-xs text-muted-foreground py-4">
{t.developer.webhooks.deliveries.empty}
</p>
) : (
<div className="overflow-x-auto rounded-3xl border border-emerald-500/15 bg-card/80 shadow-sm">
<table className="w-full text-left text-xs">
<thead className="border-b border-border/60 bg-muted/40 text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">
<tr>
<th className="px-5 py-3.5">{t.developer.webhooks.deliveries.event}</th>
<th className="px-4 py-3.5">{t.developer.webhooks.deliveries.status}</th>
<th className="px-4 py-3.5">{t.developer.webhooks.deliveries.code}</th>
<th className="px-4 py-3.5">{t.developer.webhooks.deliveries.attempt}</th>
<th className="px-4 py-3.5">{t.developer.webhooks.deliveries.time}</th>
<th className="px-5 py-3.5 text-right">Thao tác</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{deliveries.map((del) => (
<tr key={del.id} className="hover:bg-muted/30 transition-colors">
<td className="px-5 py-3.5 font-mono text-xs font-bold text-foreground">
{del.event}
</td>
<td className="px-4 py-3.5">
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold border ${
del.status === "SUCCESS"
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20"
}`}
>
{del.status}
</span>
</td>
<td className="px-4 py-3.5 font-mono text-muted-foreground">
{del.statusCode || "--"}
</td>
<td className="px-4 py-3.5 text-muted-foreground">
{del.attempt}
</td>
<td className="px-4 py-3.5 text-muted-foreground">
{new Date(del.createdAt).toLocaleDateString(
locale === "vi" ? "vi-VN" : "en-US",
{
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
day: "numeric",
month: "short",
}
)}
</td>
<td className="px-5 py-3.5 text-right">
<Button
size="sm"
variant="ghost"
onClick={() => redeliverMutation.mutate(del.id)}
className="rounded-xl h-7 px-2 text-[11px] text-emerald-600 hover:bg-emerald-500/10 cursor-pointer"
>
<RefreshCw className="h-3 w-3 mr-1" />
{t.developer.webhooks.deliveries.redeliver}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)}
{/* TAB 3: CODE SNIPPETS / INTEGRATION */}
{activeTab === "docs" && (
<div className="space-y-6 animate-in fade-in-50 duration-200">
<div>
<h3 className="text-base font-bold text-foreground">
{t.developer.snippets.title}
</h3>
<p className="text-xs text-muted-foreground">
{t.developer.snippets.desc}
</p>
</div>
{/* cURL Snippet */}
<div className="rounded-3xl border border-emerald-500/20 bg-card p-5 space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-foreground flex items-center gap-1.5">
<Terminal className="h-4 w-4 text-emerald-500" />
cURL
</span>
<button
type="button"
onClick={() => handleCopySnippet(curlSnippet, "curl")}
className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground cursor-pointer"
>
{copiedSnippet === "curl" ? (
<Check className="h-3.5 w-3.5 text-emerald-500" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
<span>{copiedSnippet === "curl" ? "Đã sao chép" : "Sao chép"}</span>
</button>
</div>
<pre className="p-4 rounded-2xl bg-muted/60 font-mono text-xs text-foreground overflow-x-auto">
{curlSnippet}
</pre>
</div>
{/* Node.js Snippet */}
<div className="rounded-3xl border border-emerald-500/20 bg-card p-5 space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-foreground flex items-center gap-1.5">
<Code2 className="h-4 w-4 text-emerald-500" />
Node.js / Axios
</span>
<button
type="button"
onClick={() => handleCopySnippet(nodeSnippet, "node")}
className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground cursor-pointer"
>
{copiedSnippet === "node" ? (
<Check className="h-3.5 w-3.5 text-emerald-500" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
<span>{copiedSnippet === "node" ? "Đã sao chép" : "Sao chép"}</span>
</button>
</div>
<pre className="p-4 rounded-2xl bg-muted/60 font-mono text-xs text-foreground overflow-x-auto">
{nodeSnippet}
</pre>
</div>
</div>
)}
</div>
{/* Modals */}
<CreateApiKeyModal
isOpen={isKeyModalOpen}
onClose={() => setIsKeyModalOpen(false)}
/>
<CreateWebhookModal
isOpen={isWebhookModalOpen}
onClose={() => setIsWebhookModalOpen(false)}
initialData={editingWebhook}
/>
</div>
);
}
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Hồ Sơ Cá Nhân & Cài Đặt",
description:
"Quản lý thông tin tài khoản cá nhân, ảnh đại diện, bảo mật mật khẩu, phiên đăng nhập và tùy chọn vô hiệu hóa tài khoản.",
};
export default function ProfileSettingsLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}
"use client";
import React, { useState, useRef } from "react";
import {
User,
Shield,
KeyRound,
Laptop,
AlertTriangle,
Upload,
CheckCircle2,
Lock,
LogOut,
Mail,
HardDrive,
RefreshCw,
Check,
X,
Sparkles,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useLanguage } from "@/providers/language-provider";
import { useAuth } from "@/hooks/use-auth";
import {
useUserProfile,
useUserUsage,
useUpdateProfile,
useUploadAvatar,
useChangePassword,
useRequestDeactivation,
useRevokeAllSessions,
} from "@/hooks/use-profile";
export default function ProfileSettingsPage() {
const { t, locale } = useLanguage();
const { user, role, isAdmin, logout } = useAuth();
// Profile data & hooks
const { data: profile, isLoading: isLoadingProfile } = useUserProfile();
const { data: usageData } = useUserUsage();
const updateProfileMutation = useUpdateProfile();
const uploadAvatarMutation = useUploadAvatar();
const changePasswordMutation = useChangePassword();
const deactivateMutation = useRequestDeactivation();
const revokeSessionsMutation = useRevokeAllSessions();
// Form states
const [fullName, setFullName] = useState(user?.fullName || "");
const [avatarPreview, setAvatarPreview] = useState<string | null>(user?.avatarUrl || null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Password form states
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
// Deactivate modal state
const [isDeactivateModalOpen, setIsDeactivateModalOpen] = useState(false);
const [deactivatePassword, setDeactivatePassword] = useState("");
// Keep state synced with user profile
React.useEffect(() => {
if (profile) {
if (profile.fullName) setFullName(profile.fullName);
if (profile.avatarUrl) setAvatarPreview(profile.avatarUrl);
} else if (user) {
if (user.fullName) setFullName(user.fullName);
if (user.avatarUrl) setAvatarPreview(user.avatarUrl);
}
}, [profile, user]);
// Compute Password Strength
const passwordStrength = React.useMemo(() => {
if (!newPassword) return 0;
let score = 0;
if (newPassword.length >= 8) score += 1;
if (/[A-Z]/.test(newPassword)) score += 1;
if (/[0-9]/.test(newPassword)) score += 1;
if (/[^A-Za-z0-9]/.test(newPassword)) score += 1;
return score;
}, [newPassword]);
// Avatar Upload Handler
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Instant local preview
const reader = new FileReader();
reader.onloadend = () => {
setAvatarPreview(reader.result as string);
};
reader.readAsDataURL(file);
// Call upload API
uploadAvatarMutation.mutate(file);
};
// Update Profile Name Handler
const handleUpdateProfile = (e: React.FormEvent) => {
e.preventDefault();
if (!fullName.trim()) return;
updateProfileMutation.mutate({ fullName: fullName.trim() });
};
// Change Password Handler
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault();
if (!currentPassword || !newPassword || newPassword !== confirmPassword) return;
await changePasswordMutation.mutateAsync({
currentPassword,
newPassword,
confirmPassword,
});
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
};
// Deactivation Request Handler
const handleRequestDeactivation = async (e: React.FormEvent) => {
e.preventDefault();
if (!deactivatePassword) return;
await deactivateMutation.mutateAsync({ password: deactivatePassword });
setIsDeactivateModalOpen(false);
setDeactivatePassword("");
};
// Browser & OS Detection
const clientInfo = React.useMemo(() => {
if (typeof window === "undefined") {
return { browser: "Chrome", os: "Windows 11" };
}
const ua = navigator.userAgent;
let browser = "Chrome";
let os = "Windows";
if (ua.includes("Firefox")) browser = "Firefox";
else if (ua.includes("Edg")) browser = "Microsoft Edge";
else if (ua.includes("Safari") && !ua.includes("Chrome")) browser = "Safari";
if (ua.includes("Mac")) os = "macOS";
else if (ua.includes("Linux")) os = "Linux";
else if (ua.includes("Android")) os = "Android";
else if (ua.includes("iPhone") || ua.includes("iPad")) os = "iOS";
return { browser, os };
}, []);
const displayName = profile?.fullName || user?.fullName || user?.email || "User";
const initials = displayName
.split(" ")
.map((n) => n[0])
.join("")
.substring(0, 2)
.toUpperCase();
return (
<div className="min-h-screen bg-background pb-20">
<div className="mx-auto max-w-5xl px-4 sm:px-6 lg:px-8 pt-8 space-y-8">
{/* Header */}
<div className="space-y-1">
<div className="inline-flex items-center gap-2 rounded-full bg-emerald-500/10 px-3 py-1 text-xs font-semibold text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<User className="h-3.5 w-3.5" />
<span>{t.profile.title}</span>
</div>
<h1 className="text-2xl sm:text-3xl font-extrabold tracking-tight text-foreground">
{t.profile.title}
</h1>
<p className="text-sm text-muted-foreground max-w-2xl">
{t.profile.subtitle}
</p>
</div>
{/* SECTION 1: PERSONAL INFO */}
<div className="rounded-3xl border border-emerald-500/15 bg-card/80 p-6 sm:p-8 shadow-sm space-y-6">
<div className="flex items-center justify-between pb-4 border-b border-border/60">
<div>
<h2 className="text-base font-bold text-foreground">
{t.profile.personal.title}
</h2>
<p className="text-xs text-muted-foreground">
{t.profile.personal.desc}
</p>
</div>
<span className="rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 text-[11px] font-semibold px-3 py-1 border border-emerald-500/20">
{role || "CRAWLER_USER"}
</span>
</div>
<form onSubmit={handleUpdateProfile} className="space-y-6">
{/* Avatar & Upload */}
<div className="flex flex-col sm:flex-row sm:items-center gap-5">
<div className="relative group">
{avatarPreview ? (
<img
src={avatarPreview}
alt="Avatar"
className="h-20 w-20 rounded-3xl object-cover border-2 border-emerald-500/30 shadow-md"
/>
) : (
<div className="h-20 w-20 rounded-3xl bg-gradient-to-br from-emerald-500 to-teal-700 flex items-center justify-center text-white text-2xl font-bold shadow-md shadow-emerald-600/20">
{initials}
</div>
)}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="absolute inset-0 rounded-3xl bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center text-white text-[10px] font-medium cursor-pointer"
>
<Upload className="h-4 w-4 mb-0.5" />
<span>Thay ảnh</span>
</button>
<input
ref={fileInputRef}
type="file"
accept="image/png, image/jpeg, image/webp"
className="hidden"
onChange={handleAvatarChange}
/>
</div>
<div className="space-y-1">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => fileInputRef.current?.click()}
disabled={uploadAvatarMutation.isPending}
className="rounded-2xl text-xs h-9 px-4 border-emerald-500/30 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/10 cursor-pointer"
>
<Upload className="h-3.5 w-3.5 mr-1.5" />
{uploadAvatarMutation.isPending
? t.profile.personal.uploading
: t.profile.personal.changeAvatar}
</Button>
<p className="text-[11px] text-muted-foreground">
Hỗ trợ PNG, JPG hoặc WebP (tối đa 2MB)
</p>
</div>
</div>
{/* Inputs: Full Name & Email */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="prof-name" className="text-xs font-semibold">
{t.profile.personal.nameLabel}
</Label>
<Input
id="prof-name"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
placeholder={t.profile.personal.namePlaceholder}
className="rounded-2xl text-xs"
/>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label htmlFor="prof-email" className="text-xs font-semibold">
{t.profile.personal.emailLabel}
</Label>
<span className="inline-flex items-center gap-1 text-[10px] text-emerald-600 dark:text-emerald-400 font-semibold">
<CheckCircle2 className="h-3 w-3" />
{t.profile.personal.emailVerified}
</span>
</div>
<Input
id="prof-email"
value={user?.email || ""}
disabled
className="rounded-2xl text-xs opacity-70 bg-muted/40 font-mono"
/>
</div>
</div>
<div className="flex justify-end pt-2">
<Button
type="submit"
disabled={updateProfileMutation.isPending}
className="rounded-2xl text-xs h-9 px-5 bg-emerald-600 hover:bg-emerald-700 text-white shadow-sm shadow-emerald-600/20 cursor-pointer"
>
{updateProfileMutation.isPending
? t.profile.personal.saving
: t.profile.personal.saveBtn}
</Button>
</div>
</form>
{/* Quota & Usage Stats */}
{usageData && (
<div className="pt-4 border-t border-border/60 space-y-3">
<p className="text-xs font-bold text-foreground">
{t.profile.quota.title}
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<div className="p-3 rounded-2xl border border-emerald-500/15 bg-muted/30">
<span className="text-[11px] text-muted-foreground">{t.profile.quota.maxPages}</span>
<p className="text-base font-bold text-foreground mt-0.5">
{usageData.quota.maxPagesLimit}
</p>
</div>
<div className="p-3 rounded-2xl border border-emerald-500/15 bg-muted/30">
<span className="text-[11px] text-muted-foreground">{t.profile.quota.maxJobsDay}</span>
<p className="text-base font-bold text-foreground mt-0.5">
{usageData.usage.jobsUsedToday} / {usageData.quota.maxJobsPerDayLimit}
</p>
</div>
<div className="p-3 rounded-2xl border border-emerald-500/15 bg-muted/30">
<span className="text-[11px] text-muted-foreground">{t.profile.quota.concurrentLimit}</span>
<p className="text-base font-bold text-foreground mt-0.5">
{usageData.usage.concurrentJobsRunning} / {usageData.quota.maxConcurrentJobsLimit}
</p>
</div>
</div>
</div>
)}
</div>
{/* SECTION 2: CHANGE PASSWORD */}
<div className="rounded-3xl border border-emerald-500/15 bg-card/80 p-6 sm:p-8 shadow-sm space-y-6">
<div className="pb-4 border-b border-border/60">
<h2 className="text-base font-bold text-foreground">
{t.profile.password.title}
</h2>
<p className="text-xs text-muted-foreground">
{t.profile.password.desc}
</p>
</div>
<form onSubmit={handleChangePassword} className="space-y-4 max-w-md">
<div className="space-y-1.5">
<Label className="text-xs font-semibold">
{t.profile.password.currentLabel}
</Label>
<Input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
placeholder={t.profile.password.currentPlaceholder}
className="rounded-2xl text-xs"
required
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold">
{t.profile.password.newLabel}
</Label>
<Input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder={t.profile.password.newPlaceholder}
className="rounded-2xl text-xs"
required
/>
{/* Password Strength Indicator */}
{newPassword && (
<div className="space-y-1 pt-1">
<div className="flex gap-1 h-1.5">
<div
className={`flex-1 rounded-full ${
passwordStrength >= 1 ? "bg-red-500" : "bg-muted"
}`}
/>
<div
className={`flex-1 rounded-full ${
passwordStrength >= 2 ? "bg-amber-500" : "bg-muted"
}`}
/>
<div
className={`flex-1 rounded-full ${
passwordStrength >= 3 ? "bg-emerald-500" : "bg-muted"
}`}
/>
<div
className={`flex-1 rounded-full ${
passwordStrength >= 4 ? "bg-emerald-600" : "bg-muted"
}`}
/>
</div>
<p className="text-[10px] text-muted-foreground">
Độ mạnh:{" "}
<span className="font-semibold text-foreground">
{passwordStrength <= 1
? t.profile.password.strengthWeak
: passwordStrength <= 2
? t.profile.password.strengthMedium
: t.profile.password.strengthStrong}
</span>
</p>
</div>
)}
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold">
{t.profile.password.confirmLabel}
</Label>
<Input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder={t.profile.password.confirmPlaceholder}
className="rounded-2xl text-xs"
required
/>
{confirmPassword && newPassword !== confirmPassword && (
<p className="text-[10px] text-red-500">Mật khẩu xác nhận không khớp</p>
)}
</div>
<div className="pt-2">
<Button
type="submit"
disabled={
changePasswordMutation.isPending ||
!currentPassword ||
!newPassword ||
newPassword !== confirmPassword
}
className="rounded-2xl text-xs h-9 px-5 bg-emerald-600 hover:bg-emerald-700 text-white shadow-sm shadow-emerald-600/20 cursor-pointer"
>
{changePasswordMutation.isPending
? t.profile.password.submitting
: t.profile.password.submitBtn}
</Button>
</div>
</form>
</div>
{/* SECTION 3: ACTIVE SESSIONS */}
<div className="rounded-3xl border border-emerald-500/15 bg-card/80 p-6 sm:p-8 shadow-sm space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pb-4 border-b border-border/60">
<div>
<h2 className="text-base font-bold text-foreground">
{t.profile.sessions.title}
</h2>
<p className="text-xs text-muted-foreground">
{t.profile.sessions.desc}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => revokeSessionsMutation.mutate()}
disabled={revokeSessionsMutation.isPending}
className="rounded-2xl text-xs h-9 px-4 border-red-500/30 text-red-600 dark:text-red-400 hover:bg-red-500/10 cursor-pointer"
>
<LogOut className="h-3.5 w-3.5 mr-1.5" />
{revokeSessionsMutation.isPending
? t.profile.sessions.revoking
: t.profile.sessions.revokeAllBtn}
</Button>
</div>
<div className="p-4 rounded-2xl border border-emerald-500/20 bg-muted/20 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-3.5">
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20 shrink-0">
<Laptop className="h-5 w-5" />
</div>
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<p className="text-xs font-bold text-foreground">
{clientInfo.browser} trên {clientInfo.os}
</p>
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 px-2 py-0.5 text-[10px] font-semibold">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" />
{t.profile.sessions.statusActive}
</span>
</div>
<p className="text-[11px] text-muted-foreground font-mono">
IP: 127.0.0.1 (Local Client) • Phiên hiện tại
</p>
</div>
</div>
</div>
</div>
{/* SECTION 4: DANGER ZONE (ACCOUNT DEACTIVATION) */}
<div className="rounded-3xl border border-red-500/30 bg-red-500/5 p-6 sm:p-8 shadow-sm space-y-4">
<div className="flex items-start gap-3">
<div className="p-2 rounded-2xl bg-red-500/10 text-red-500 shrink-0">
<AlertTriangle className="h-5 w-5" />
</div>
<div className="space-y-1">
<h2 className="text-base font-bold text-red-600 dark:text-red-400">
{t.profile.danger.title}
</h2>
<p className="text-xs text-muted-foreground">
{t.profile.danger.desc}
</p>
</div>
</div>
<div className="pt-2">
<Button
variant="outline"
onClick={() => setIsDeactivateModalOpen(true)}
className="rounded-2xl text-xs h-9 px-4 border-red-500/40 text-red-600 dark:text-red-400 hover:bg-red-500/10 cursor-pointer"
>
{t.profile.danger.deactivateBtn}
</Button>
</div>
</div>
</div>
{/* Deactivation Confirmation Modal */}
{isDeactivateModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm animate-in fade-in-0 duration-200">
<div className="relative w-full max-w-md rounded-3xl border border-red-500/30 bg-card p-6 shadow-2xl overflow-hidden space-y-4">
<div className="flex items-center justify-between pb-3 border-b border-border/60">
<div className="flex items-center gap-2.5 text-red-600 dark:text-red-400 font-bold text-sm">
<AlertTriangle className="h-5 w-5" />
<span>{t.profile.danger.modalTitle}</span>
</div>
<button
onClick={() => setIsDeactivateModalOpen(false)}
className="rounded-xl p-1.5 text-muted-foreground hover:bg-muted"
>
<X className="h-4 w-4" />
</button>
</div>
<p className="text-xs text-muted-foreground">
{t.profile.danger.modalDesc}
</p>
<form onSubmit={handleRequestDeactivation} className="space-y-4">
<div className="space-y-1.5">
<Label className="text-xs font-semibold">
{t.profile.danger.passwordLabel}
</Label>
<Input
type="password"
value={deactivatePassword}
onChange={(e) => setDeactivatePassword(e.target.value)}
placeholder={t.profile.danger.passwordPlaceholder}
className="rounded-2xl text-xs"
required
/>
</div>
<div className="flex items-center justify-end gap-2.5 pt-2">
<Button
type="button"
variant="outline"
onClick={() => setIsDeactivateModalOpen(false)}
className="rounded-2xl text-xs h-9 px-4 cursor-pointer"
>
{t.profile.danger.cancel}
</Button>
<Button
type="submit"
disabled={!deactivatePassword || deactivateMutation.isPending}
className="rounded-2xl text-xs h-9 px-5 bg-red-600 hover:bg-red-700 text-white shadow-sm cursor-pointer"
>
{deactivateMutation.isPending
? t.profile.danger.canceling
: t.profile.danger.confirmBtn}
</Button>
</div>
</form>
</div>
</div>
)}
</div>
);
}
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Quản Lý Mẫu Bóc Tách",
description:
"Giao diện quản lý các bộ quy tắc bóc tách dữ liệu có cấu trúc (CSS Selector, XPath, Regex, JSON Schema) và gắn nhanh vào tác vụ cào.",
};
export default function TemplatesLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}
"use client";
import React, { useState, useMemo, useRef, useEffect } from "react";
import { useRouter } from "next/navigation";
import {
FileCode2,
Plus,
Search,
Globe,
Layers,
Sparkles,
Play,
Edit2,
Trash2,
ExternalLink,
Filter,
Check,
ChevronDown,
RefreshCw,
FolderCode,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useLanguage } from "@/providers/language-provider";
import {
useExtractionTemplatesList,
useDeleteExtractionTemplate,
} from "@/hooks/use-extraction-templates";
import { ExtractionTemplate } from "@/types/extraction-template";
import { TemplateFormModal } from "@/components/templates/template-form-modal";
import { TemplatePreviewModal } from "@/components/templates/template-preview-modal";
export default function TemplatesPage() {
const { t, locale } = useLanguage();
const router = useRouter();
const { data: templates = [], isLoading, isError, refetch } = useExtractionTemplatesList();
const deleteMutation = useDeleteExtractionTemplate();
const [searchQuery, setSearchQuery] = useState("");
const [selectedDomain, setSelectedDomain] = useState<string>("ALL");
const [isDomainDropdownOpen, setIsDomainDropdownOpen] = useState(false);
const domainDropdownRef = useRef<HTMLDivElement>(null);
const [isFormModalOpen, setIsFormModalOpen] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<ExtractionTemplate | null>(null);
const [isPreviewModalOpen, setIsPreviewModalOpen] = useState(false);
const [previewingTemplate, setPreviewingTemplate] = useState<ExtractionTemplate | null>(null);
// Close dropdown on click outside or Escape
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (domainDropdownRef.current && !domainDropdownRef.current.contains(event.target as Node)) {
setIsDomainDropdownOpen(false);
}
}
function handleEscape(event: KeyboardEvent) {
if (event.key === "Escape") {
setIsDomainDropdownOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, []);
// Compute unique domains
const uniqueDomains = useMemo(() => {
const set = new Set<string>();
templates.forEach((t) => {
if (t.domain) set.add(t.domain);
});
return Array.from(set);
}, [templates]);
// Filter templates
const filteredTemplates = useMemo(() => {
return templates.filter((tpl) => {
const matchesSearch =
tpl.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
tpl.domain.toLowerCase().includes(searchQuery.toLowerCase());
const matchesDomain =
selectedDomain === "ALL" || tpl.domain === selectedDomain;
return matchesSearch && matchesDomain;
});
}, [templates, searchQuery, selectedDomain]);
// Compute metrics
const totalRules = useMemo(() => {
return templates.reduce((acc, curr) => acc + (curr.fields?.length || 0), 0);
}, [templates]);
const coverageRate = useMemo(() => {
if (!templates.length || totalRules === 0) return "0%";
const templatesWithFields = templates.filter(
(tpl) => Array.isArray(tpl.fields) && tpl.fields.length > 0
).length;
return `${Math.round((templatesWithFields / templates.length) * 100)}%`;
}, [templates, totalRules]);
const handleQuickAttach = (templateId: string) => {
router.push(`/crawl-jobs?create=true&templateId=${templateId}`);
};
const handleDelete = (id: string, name: string) => {
if (window.confirm(`${t.templates.card.deleteConfirm}\n("${name}")`)) {
deleteMutation.mutate(id);
}
};
return (
<div className="min-h-screen bg-background pb-16">
{/* Container */}
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 pt-8 space-y-8">
{/* Page Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="space-y-1">
<div className="inline-flex items-center gap-2 rounded-full bg-emerald-500/10 px-3 py-1 text-xs font-semibold text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<FileCode2 className="h-3.5 w-3.5" />
<span>{t.templates.title}</span>
</div>
<h1 className="text-2xl sm:text-3xl font-extrabold tracking-tight text-foreground">
{t.templates.title}
</h1>
<p className="text-sm text-muted-foreground max-w-2xl">
{t.templates.subtitle}
</p>
</div>
<Button
onClick={() => {
setEditingTemplate(null);
setIsFormModalOpen(true);
}}
className="rounded-2xl h-11 px-5 bg-emerald-600 hover:bg-emerald-700 text-white shadow-md shadow-emerald-600/20 transition-all hover:scale-[1.02] cursor-pointer"
>
<Plus className="h-4 w-4 mr-1.5" />
{t.templates.createBtn}
</Button>
</div>
{/* Impact Metrics / KPI Cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="p-4 rounded-3xl border border-emerald-500/15 bg-card/60 shadow-sm backdrop-blur-sm">
<p className="text-xs font-medium text-muted-foreground">{t.templates.stats.total}</p>
<p className="text-2xl font-bold tracking-tight text-foreground mt-1">{templates.length}</p>
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 mt-0.5">{t.templates.stats.totalDesc}</p>
</div>
<div className="p-4 rounded-3xl border border-emerald-500/15 bg-card/60 shadow-sm backdrop-blur-sm">
<p className="text-xs font-medium text-muted-foreground">{t.templates.stats.domains}</p>
<p className="text-2xl font-bold tracking-tight text-foreground mt-1">{uniqueDomains.length}</p>
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 mt-0.5">{t.templates.stats.domainsDesc}</p>
</div>
<div className="p-4 rounded-3xl border border-emerald-500/15 bg-card/60 shadow-sm backdrop-blur-sm">
<p className="text-xs font-medium text-muted-foreground">{t.templates.stats.rules}</p>
<p className="text-2xl font-bold tracking-tight text-foreground mt-1">{totalRules}</p>
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 mt-0.5">{t.templates.stats.rulesDesc}</p>
</div>
<div className="p-4 rounded-3xl border border-emerald-500/15 bg-card/60 shadow-sm backdrop-blur-sm">
<p className="text-xs font-medium text-muted-foreground">{t.templates.stats.coverage}</p>
<p className="text-2xl font-bold tracking-tight text-foreground mt-1">{coverageRate}</p>
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 mt-0.5">{t.templates.stats.coverageDesc}</p>
</div>
</div>
{/* Search & Domain Filter Bar */}
<div className="flex flex-col sm:flex-row gap-3 items-stretch sm:items-center justify-between">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t.templates.searchPlaceholder}
className="pl-9 rounded-2xl border-border/80 text-xs bg-card/60 focus:ring-emerald-500/20"
/>
</div>
{/* Custom Domain Dropdown conforming to ThemeToggle style */}
<div className="relative" ref={domainDropdownRef}>
<button
type="button"
onClick={() => setIsDomainDropdownOpen(!isDomainDropdownOpen)}
className="w-full sm:w-auto flex items-center justify-between gap-2.5 rounded-2xl border border-border/80 bg-card/80 px-3.5 py-2 text-xs font-medium text-foreground hover:bg-muted/60 transition-colors cursor-pointer"
>
<div className="flex items-center gap-2">
<Globe className="h-3.5 w-3.5 text-emerald-500" />
<span>
{selectedDomain === "ALL"
? t.templates.allDomains
: selectedDomain}
</span>
</div>
<ChevronDown
className={`h-3.5 w-3.5 text-muted-foreground transition-transform duration-200 ${
isDomainDropdownOpen ? "rotate-180" : ""
}`}
/>
</button>
{isDomainDropdownOpen && (
<div className="absolute right-0 mt-2 w-52 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">
<button
type="button"
onClick={() => {
setSelectedDomain("ALL");
setIsDomainDropdownOpen(false);
}}
className={`w-full flex items-center justify-between rounded-xl px-2.5 py-2 text-xs transition-colors cursor-pointer ${
selectedDomain === "ALL"
? "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"
}`}
>
<span>{t.templates.allDomains}</span>
{selectedDomain === "ALL" && <Check className="h-3.5 w-3.5" />}
</button>
{uniqueDomains.map((dom) => (
<button
key={dom}
type="button"
onClick={() => {
setSelectedDomain(dom);
setIsDomainDropdownOpen(false);
}}
className={`w-full flex items-center justify-between rounded-xl px-2.5 py-2 text-xs transition-colors cursor-pointer ${
selectedDomain === dom
? "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"
}`}
>
<span className="truncate">{dom}</span>
{selectedDomain === dom && <Check className="h-3.5 w-3.5" />}
</button>
))}
</div>
)}
</div>
</div>
{/* Templates Grid Content */}
{isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className="h-48 rounded-3xl border border-border/60 bg-muted/40 animate-pulse"
/>
))}
</div>
) : isError ? (
<div className="p-8 text-center rounded-3xl border border-red-500/20 bg-red-500/5 space-y-3">
<p className="text-sm text-red-500 font-medium">
Đã có lỗi xảy ra khi tải danh sách mẫu bóc tách.
</p>
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
className="rounded-xl text-xs cursor-pointer"
>
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
{t.templates.retry}
</Button>
</div>
) : filteredTemplates.length === 0 ? (
<div className="p-12 text-center rounded-3xl border border-dashed border-border/80 bg-card/40 space-y-3">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
<FolderCode className="h-6 w-6" />
</div>
<h3 className="text-base font-bold text-foreground">
{t.templates.emptyTitle}
</h3>
<p className="text-xs text-muted-foreground max-w-md mx-auto">
{t.templates.emptyDesc}
</p>
<Button
size="sm"
onClick={() => {
setEditingTemplate(null);
setIsFormModalOpen(true);
}}
className="rounded-2xl text-xs bg-emerald-600 hover:bg-emerald-700 text-white shadow-sm shadow-emerald-600/20 cursor-pointer"
>
<Plus className="h-3.5 w-3.5 mr-1" />
{t.templates.createBtn}
</Button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{filteredTemplates.map((template) => (
<div
key={template.id}
className="group relative flex flex-col justify-between p-5 rounded-3xl border border-emerald-500/15 bg-card/80 hover:border-emerald-500/30 hover:shadow-lg hover:shadow-emerald-950/10 hover:scale-[1.01] transition-all duration-300"
>
<div>
{/* Top Bar: Domain badge & Actions */}
<div className="flex items-center justify-between gap-2 mb-3">
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-500/10 px-2.5 py-1 text-[11px] font-semibold text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<Globe className="h-3 w-3" />
{template.domain}
</span>
<span className="text-[11px] text-muted-foreground">
{template.fields.length} {t.templates.card.fieldsCount}
</span>
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => {
setPreviewingTemplate(template);
setIsPreviewModalOpen(true);
}}
title="Thử nghiệm mô phỏng"
className="p-1.5 rounded-xl text-muted-foreground hover:bg-emerald-500/10 hover:text-emerald-600 transition-colors cursor-pointer"
>
<Play className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => {
setEditingTemplate(template);
setIsFormModalOpen(true);
}}
title={t.templates.card.edit}
className="p-1.5 rounded-xl text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
>
<Edit2 className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => handleDelete(template.id, template.name)}
title={t.templates.card.delete}
className="p-1.5 rounded-xl text-muted-foreground hover:bg-red-500/10 hover:text-red-500 transition-colors cursor-pointer"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
{/* Template Title */}
<h3 className="text-sm font-bold text-foreground group-hover:text-emerald-600 dark:group-hover:text-emerald-400 transition-colors">
{template.name}
</h3>
{/* Fields Chip Preview */}
<div className="mt-3 flex flex-wrap gap-1.5">
{template.fields.slice(0, 5).map((f, idx) => (
<span
key={idx}
className="rounded-lg bg-muted/60 text-muted-foreground px-2 py-0.5 text-[10px] font-mono border border-border/60"
>
{f.name}
{f.required && (
<span className="text-emerald-500 ml-0.5">*</span>
)}
</span>
))}
{template.fields.length > 5 && (
<span className="rounded-lg bg-muted/40 text-muted-foreground px-1.5 py-0.5 text-[10px] font-mono">
+{template.fields.length - 5}
</span>
)}
</div>
</div>
{/* Bottom Actions */}
<div className="mt-5 pt-3 border-t border-border/50 flex items-center justify-between">
<span className="text-[11px] text-muted-foreground">
{new Date(template.createdAt).toLocaleDateString(
locale === "vi" ? "vi-VN" : "en-US",
{ month: "short", day: "numeric", year: "numeric" }
)}
</span>
<Button
size="sm"
onClick={() => handleQuickAttach(template.id)}
className="rounded-xl h-8 px-3 text-xs bg-emerald-600/15 hover:bg-emerald-600 text-emerald-700 dark:text-emerald-300 hover:text-white border border-emerald-500/25 transition-all cursor-pointer"
>
<ExternalLink className="h-3 w-3 mr-1" />
{t.templates.card.attachToJob}
</Button>
</div>
</div>
))}
</div>
)}
</div>
{/* Modals */}
<TemplateFormModal
isOpen={isFormModalOpen}
onClose={() => setIsFormModalOpen(false)}
initialData={editingTemplate}
/>
<TemplatePreviewModal
isOpen={isPreviewModalOpen}
onClose={() => setIsPreviewModalOpen(false)}
template={previewingTemplate}
/>
</div>
);
}
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
import React from "react"; import React from "react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { Navbar } from "@/components/common/navbar"; import { Navbar } from "@/components/common/navbar";
import { BottomNav } from "@/components/common/bottom-nav";
export const STANDALONE_PATHS = [ export const STANDALONE_PATHS = [
"/login", "/login",
...@@ -34,11 +35,12 @@ export function AppShell({ children }: AppShellProps) { ...@@ -34,11 +35,12 @@ export function AppShell({ children }: AppShellProps) {
<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-border/60 py-6 text-center text-xs text-muted-foreground"> <footer className="border-t border-border/60 py-6 pb-24 md:pb-24 min-[1025px]:pb-6 text-center text-xs text-muted-foreground">
<p> <p>
Data Crawler © {new Date().getFullYear()} - Code by @hnihTyoB Data Crawler © {new Date().getFullYear()} - Code by @hnihTyoB
</p> </p>
</footer> </footer>
<BottomNav />
</> </>
); );
} }
"use client";
import React, { useState, useEffect } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Activity, Calendar, Download, FileCode2, Globe } from "lucide-react";
import { useLanguage } from "@/providers/language-provider";
export function BottomNav() {
const { t } = useLanguage();
const pathname = usePathname();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const navItems = [
{
href: "/",
label: t.nav.dashboard,
icon: Activity,
isActive: pathname === "/",
},
{
href: "/crawl-jobs",
label: t.nav.tasks,
icon: Globe,
isActive: pathname.startsWith("/crawl-jobs"),
},
{
href: "/schedules",
label: t.nav.schedules,
icon: Calendar,
isActive: pathname.startsWith("/schedules"),
},
{
href: "/templates",
label: t.nav.templates,
icon: FileCode2,
isActive: pathname.startsWith("/templates"),
},
{
href: "/exports",
label: t.nav.exports,
icon: Download,
isActive: pathname.startsWith("/exports"),
},
];
if (!mounted) {
return null;
}
return (
<nav
suppressHydrationWarning
aria-label="Bottom Navigation Bar"
className="min-[1025px]:hidden fixed bottom-2 left-0 right-0 z-40 border-t border-border/80 bg-card/90 backdrop-blur-xl shadow-[0_-4px_24px_rgba(0,0,0,0.06)] dark:shadow-[0_-4px_24px_rgba(0,0,0,0.35)] transition-colors duration-200 pb-[env(safe-area-inset-bottom,0px)]"
>
<div className="grid grid-cols-5 h-16 md:h-18 max-w-2xl mx-auto px-2 items-center">
{navItems.map((item) => {
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
title={item.label}
aria-label={item.label}
className={`group relative flex flex-col items-center justify-center rounded-2xl py-1.5 px-1 transition-all duration-200 active:scale-95 ${
item.isActive
? "text-emerald-600 dark:text-emerald-400 font-semibold bg-emerald-500/10 dark:bg-emerald-500/20 shadow-xs"
: "text-muted-foreground hover:text-foreground hover:bg-muted/40 font-medium"
}`}
>
<div className="relative flex items-center justify-center">
<Icon
className={`transition-all duration-200 ${
item.isActive
? "h-5 w-5 sm:h-5.5 sm:w-5.5 md:h-5 md:w-5 stroke-[2.25]"
: "h-5 w-5 sm:h-5.5 sm:w-5.5 md:h-5 md:w-5 stroke-[1.75] group-hover:scale-110"
}`}
/>
{/* Chấm tròn biểu thị trạng thái đang hoạt động trên màn hình điện thoại */}
{item.isActive && (
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-emerald-600 dark:bg-emerald-400 md:hidden" />
)}
</div>
{/* Tên tab: Ẩn trên điện thoại (< md), hiển thị bên dưới icon trên iPad (md đến 1024px) */}
<span className="hidden md:block text-[11px] tracking-tight truncate max-w-full text-center mt-1">
{item.label}
</span>
</Link>
);
})}
</div>
</nav>
);
}
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
import React from "react"; import React from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { Activity, Bot, Database, Globe } from "lucide-react"; import { Activity, Bot, Calendar, Download, FileCode2, Globe } from "lucide-react";
import { ThemeToggle } from "./theme-toggle"; import { ThemeToggle } from "./theme-toggle";
import { LanguageToggle } from "./language-toggle"; import { LanguageToggle } from "./language-toggle";
import { UserMenu } from "./user-menu"; import { UserMenu } from "./user-menu";
...@@ -15,6 +15,9 @@ export function Navbar() { ...@@ -15,6 +15,9 @@ export function Navbar() {
const isDashboard = pathname === "/"; const isDashboard = pathname === "/";
const isCrawlJobs = pathname.startsWith("/crawl-jobs"); const isCrawlJobs = pathname.startsWith("/crawl-jobs");
const isSchedules = pathname.startsWith("/schedules");
const isTemplates = pathname.startsWith("/templates");
const isExports = pathname.startsWith("/exports");
return ( 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"> <header className="sticky top-0 z-40 w-full border-b border-border/70 bg-card/80 backdrop-blur-md transition-colors duration-200">
...@@ -28,7 +31,7 @@ export function Navbar() { ...@@ -28,7 +31,7 @@ export function Navbar() {
</Link> </Link>
{/* Center Nav Links */} {/* Center Nav Links */}
<nav className="hidden md:flex items-center gap-6 text-sm font-medium text-muted-foreground"> <nav className="hidden min-[1025px]:flex items-center gap-5 text-sm font-medium text-muted-foreground">
<Link <Link
href="/" href="/"
className={`flex items-center gap-1.5 transition-colors ${ className={`flex items-center gap-1.5 transition-colors ${
...@@ -52,11 +55,37 @@ export function Navbar() { ...@@ -52,11 +55,37 @@ export function Navbar() {
{t.nav.tasks} {t.nav.tasks}
</Link> </Link>
<Link <Link
href="#storage" href="/schedules"
className="flex items-center gap-1.5 hover:text-foreground transition-colors" className={`flex items-center gap-1.5 transition-colors ${
isSchedules
? "text-emerald-600 dark:text-emerald-400 font-semibold"
: "hover:text-foreground"
}`}
>
<Calendar className="h-4 w-4" />
{t.nav.schedules}
</Link>
<Link
href="/templates"
className={`flex items-center gap-1.5 transition-colors ${
isTemplates
? "text-emerald-600 dark:text-emerald-400 font-semibold"
: "hover:text-foreground"
}`}
>
<FileCode2 className="h-4 w-4" />
{t.nav.templates}
</Link>
<Link
href="/exports"
className={`flex items-center gap-1.5 transition-colors ${
isExports
? "text-emerald-600 dark:text-emerald-400 font-semibold"
: "hover:text-foreground"
}`}
> >
<Database className="h-4 w-4" /> <Download className="h-4 w-4" />
{t.nav.storage} {t.nav.exports}
</Link> </Link>
</nav> </nav>
......
...@@ -5,17 +5,15 @@ import Link from "next/link"; ...@@ -5,17 +5,15 @@ import Link from "next/link";
import { import {
User, User,
LogOut, LogOut,
Shield,
LayoutDashboard,
ChevronDown, ChevronDown,
Code2,
} from "lucide-react"; } from "lucide-react";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { useLanguage } from "@/providers/language-provider"; import { useLanguage } from "@/providers/language-provider";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
export function UserMenu() { export function UserMenu() {
const { user, isAuthenticated, isLoading, role, isAdmin, logout } = useAuth(); const { user, isAuthenticated, isLoading, role, logout } = useAuth();
const { t } = useLanguage(); const { t } = useLanguage();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
...@@ -137,25 +135,22 @@ export function UserMenu() { ...@@ -137,25 +135,22 @@ export function UserMenu() {
{/* Actions List */} {/* Actions List */}
<div className="space-y-1"> <div className="space-y-1">
<Link <Link
href="/" href="/settings/profile"
onClick={() => setIsOpen(false)} onClick={() => setIsOpen(false)}
className="flex items-center gap-2.5 rounded-xl px-3 py-2 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors" className="flex items-center gap-2.5 rounded-xl px-3 py-2 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
> >
<LayoutDashboard className="h-3.5 w-3.5 text-emerald-500" /> <User className="h-3.5 w-3.5 text-emerald-500" />
<span>{t.nav.dashboard}</span> <span>{t.auth.userMenu.profile}</span>
</Link> </Link>
{isAdmin && ( <Link
<div className="flex items-center justify-between rounded-xl px-3 py-2 text-xs font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-500/5 border border-emerald-500/10"> href="/settings/developer"
<div className="flex items-center gap-2.5"> onClick={() => setIsOpen(false)}
<Shield className="h-3.5 w-3.5 text-emerald-500" /> className="flex items-center gap-2.5 rounded-xl px-3 py-2 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
<span>{t.auth.userMenu.admin}</span> >
</div> <Code2 className="h-3.5 w-3.5 text-emerald-500" />
<Badge variant="outline" className="text-[9px] border-emerald-500/30"> <span>{t.auth.userMenu.developer}</span>
ADMIN </Link>
</Badge>
</div>
)}
</div> </div>
{/* Divider */} {/* Divider */}
......
...@@ -141,16 +141,16 @@ export function ServiceHealthBar() { ...@@ -141,16 +141,16 @@ export function ServiceHealthBar() {
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 border-emerald-500/25" ? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 border-emerald-500/25"
: "bg-red-500/10 text-red-700 dark:text-red-300 border-red-500/25" : "bg-red-500/10 text-red-700 dark:text-red-300 border-red-500/25"
)} )}
title={dbCheck?.error || "PostgreSQL connection normal"} title={dbCheck?.error || (isDbUp ? t.health.postgresReady : t.health.postgresDown)}
> >
<Database className="h-3.5 w-3.5 text-emerald-500 shrink-0" /> <Database className="h-3.5 w-3.5 text-emerald-500 shrink-0" />
<span className="font-semibold">PostgreSQL</span> <span className="font-semibold">PostgreSQL</span>
{isDbUp ? ( {isDbUp ? (
<span className="text-[11px] opacity-80 whitespace-nowrap"> <span className="text-[11px] opacity-80 whitespace-nowrap">
{dbCheck?.latencyMs !== undefined ? `• ${dbCheck.latencyMs}ms` : "• Online"} {dbCheck?.latencyMs !== undefined ? `• ${dbCheck.latencyMs}ms` : `• ${t.health.statusOnline}`}
</span> </span>
) : ( ) : (
<span className="text-[11px] text-red-400 font-bold whitespace-nowrap">Offline</span> <span className="text-[11px] text-red-400 font-bold whitespace-nowrap">{t.health.statusOffline}</span>
)} )}
</div> </div>
...@@ -164,7 +164,7 @@ export function ServiceHealthBar() { ...@@ -164,7 +164,7 @@ export function ServiceHealthBar() {
? "bg-amber-500/10 text-amber-700 dark:text-amber-300 border-amber-500/25" ? "bg-amber-500/10 text-amber-700 dark:text-amber-300 border-amber-500/25"
: "bg-red-500/10 text-red-700 dark:text-red-300 border-red-500/25" : "bg-red-500/10 text-red-700 dark:text-red-300 border-red-500/25"
)} )}
title={redisCheck?.error || "BullMQ Queue active"} title={redisCheck?.error || (isRedisUp ? t.health.redisReady : isDegraded ? t.health.redisDegraded : t.health.redisDown)}
> >
<Layers className="h-3.5 w-3.5 text-teal-500 shrink-0" /> <Layers className="h-3.5 w-3.5 text-teal-500 shrink-0" />
<span className="font-semibold">Redis/BullMQ</span> <span className="font-semibold">Redis/BullMQ</span>
...@@ -172,8 +172,10 @@ export function ServiceHealthBar() { ...@@ -172,8 +172,10 @@ export function ServiceHealthBar() {
{redisCheck?.latencyMs !== undefined {redisCheck?.latencyMs !== undefined
? `• ${redisCheck.latencyMs}ms` ? `• ${redisCheck.latencyMs}ms`
: isRedisUp : isRedisUp
? "• Active" ? `• ${t.health.statusActive}`
: "• Degraded"} : isDegraded
? `• ${t.health.statusDegraded}`
: `• ${t.health.statusDown}`}
</span> </span>
{(activeJobs > 0 || waitingJobs > 0) && ( {(activeJobs > 0 || waitingJobs > 0) && (
<span className="hidden md:inline-flex items-center gap-1 rounded bg-teal-500/20 px-1.5 py-0.2 text-[10px] font-bold text-teal-600 dark:text-teal-300"> <span className="hidden md:inline-flex items-center gap-1 rounded bg-teal-500/20 px-1.5 py-0.2 text-[10px] font-bold text-teal-600 dark:text-teal-300">
......
"use client";
import React, { useState, useRef, useEffect } from "react";
import {
X,
KeyRound,
Copy,
Check,
AlertTriangle,
Sparkles,
ChevronDown,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useLanguage } from "@/providers/language-provider";
import { useCreateApiKey } from "@/hooks/use-developer";
import { CreateApiKeyResponse } from "@/types/developer";
interface CreateApiKeyModalProps {
isOpen: boolean;
onClose: () => void;
}
const EXPIRATION_OPTIONS = [
{ value: "NEVER", labelVi: "Không thời hạn", labelEn: "No Expiration" },
{ value: "30_DAYS", labelVi: "30 ngày", labelEn: "30 Days" },
{ value: "90_DAYS", labelVi: "90 ngày", labelEn: "90 Days" },
{ value: "1_YEAR", labelVi: "1 năm", labelEn: "1 Year" },
];
export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) {
const { t, locale } = useLanguage();
const createMutation = useCreateApiKey();
const [name, setName] = useState("");
const [expirationChoice, setExpirationChoice] = useState("NEVER");
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const [createdKeyData, setCreatedKeyData] = useState<CreateApiKeyResponse | null>(null);
const [isCopied, setIsCopied] = useState(false);
useEffect(() => {
if (!isOpen) {
setName("");
setExpirationChoice("NEVER");
setCreatedKeyData(null);
setIsCopied(false);
}
}, [isOpen]);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsDropdownOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
if (!isOpen) return null;
const handleCopyKey = () => {
if (!createdKeyData?.key) return;
navigator.clipboard.writeText(createdKeyData.key);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2500);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
let expiresAt: string | null = null;
const now = Date.now();
if (expirationChoice === "30_DAYS") {
expiresAt = new Date(now + 86400000 * 30).toISOString();
} else if (expirationChoice === "90_DAYS") {
expiresAt = new Date(now + 86400000 * 90).toISOString();
} else if (expirationChoice === "1_YEAR") {
expiresAt = new Date(now + 86400000 * 365).toISOString();
}
const res = await createMutation.mutateAsync({
name: name.trim(),
expiresAt,
});
setCreatedKeyData(res);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm animate-in fade-in-0 duration-200">
<div className="relative w-full max-w-lg flex flex-col rounded-3xl border border-emerald-500/20 bg-card p-6 shadow-2xl shadow-emerald-950/20 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between pb-4 border-b border-border/60">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<KeyRound className="h-5 w-5" />
</div>
<div>
<h2 className="text-base font-bold tracking-tight text-foreground">
{t.developer.apiKeys.modal.title}
</h2>
<p className="text-xs text-muted-foreground">
{t.developer.apiKeys.modal.desc}
</p>
</div>
</div>
<button
onClick={onClose}
className="rounded-xl p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Content: Form or Secret Key View */}
{createdKeyData ? (
<div className="py-4 space-y-4">
<div className="p-4 rounded-2xl border border-emerald-500/30 bg-emerald-500/5 space-y-2">
<div className="flex items-center gap-2 text-emerald-600 dark:text-emerald-400 font-bold text-xs">
<Sparkles className="h-4 w-4" />
<span>{t.developer.apiKeys.newKeyNotice.title}</span>
</div>
<p className="text-xs text-muted-foreground">
{t.developer.apiKeys.newKeyNotice.desc}
</p>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold">Khóa bí mật API</Label>
<div className="relative flex items-center">
<Input
readOnly
value={createdKeyData.key}
className="rounded-2xl pr-10 font-mono text-xs bg-muted/60 text-emerald-600 dark:text-emerald-400 font-bold select-all"
/>
<button
type="button"
onClick={handleCopyKey}
className="absolute right-2 p-1.5 rounded-xl text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
>
{isCopied ? (
<Check className="h-4 w-4 text-emerald-500" />
) : (
<Copy className="h-4 w-4" />
)}
</button>
</div>
</div>
<div className="flex justify-end pt-3 border-t border-border/60">
<Button
onClick={onClose}
className="rounded-2xl text-xs h-9 px-5 bg-emerald-600 hover:bg-emerald-700 text-white shadow-sm shadow-emerald-600/20 cursor-pointer"
>
{t.developer.apiKeys.newKeyNotice.close}
</Button>
</div>
</div>
) : (
<form onSubmit={handleSubmit} className="py-4 space-y-4">
<div className="space-y-1.5">
<Label htmlFor="api-key-name" className="text-xs font-semibold">
{t.developer.apiKeys.modal.nameLabel}
</Label>
<Input
id="api-key-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t.developer.apiKeys.modal.namePlaceholder}
className="rounded-2xl text-xs"
required
/>
</div>
{/* Custom Expiration Dropdown */}
<div className="space-y-1.5" ref={dropdownRef}>
<Label className="text-xs font-semibold">
{t.developer.apiKeys.modal.expiresLabel}
</Label>
<div className="relative">
<button
type="button"
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
className="w-full flex items-center justify-between rounded-2xl border border-border/80 bg-card/60 px-3 py-2 text-xs text-foreground hover:bg-muted/60 transition-colors cursor-pointer"
>
<span>
{
EXPIRATION_OPTIONS.find((o) => o.value === expirationChoice)?.[
locale === "vi" ? "labelVi" : "labelEn"
]
}
</span>
<ChevronDown
className={`h-4 w-4 text-muted-foreground transition-transform duration-200 ${
isDropdownOpen ? "rotate-180" : ""
}`}
/>
</button>
{isDropdownOpen && (
<div className="absolute top-full left-0 right-0 mt-1.5 rounded-2xl border border-emerald-500/20 bg-card/95 p-1.5 shadow-xl backdrop-blur-xl z-50 animate-in fade-in-50 zoom-in-95 duration-150">
{EXPIRATION_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => {
setExpirationChoice(opt.value);
setIsDropdownOpen(false);
}}
className={`w-full flex items-center justify-between rounded-xl px-2.5 py-2 text-xs transition-colors cursor-pointer ${
expirationChoice === opt.value
? "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"
}`}
>
<span>{opt[locale === "vi" ? "labelVi" : "labelEn"]}</span>
{expirationChoice === opt.value && <Check className="h-3.5 w-3.5" />}
</button>
))}
</div>
)}
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 pt-3 border-t border-border/60">
<Button
type="button"
variant="outline"
onClick={onClose}
className="rounded-2xl text-xs h-9 px-4 cursor-pointer"
>
{t.developer.apiKeys.modal.cancel}
</Button>
<Button
type="submit"
disabled={!name.trim() || createMutation.isPending}
className="rounded-2xl text-xs h-9 px-5 bg-emerald-600 hover:bg-emerald-700 text-white shadow-sm shadow-emerald-600/20 cursor-pointer"
>
{createMutation.isPending
? t.developer.apiKeys.modal.submitting
: t.developer.apiKeys.modal.submit}
</Button>
</div>
</form>
)}
</div>
</div>
);
}
"use client";
import React, { useState, useEffect } from "react";
import {
X,
Webhook,
Sparkles,
Key,
Radio,
Check,
RefreshCw,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useLanguage } from "@/providers/language-provider";
import {
useCreateWebhookConfig,
useUpdateWebhookConfig,
} from "@/hooks/use-developer";
import { WebhookConfig } from "@/types/developer";
interface CreateWebhookModalProps {
isOpen: boolean;
onClose: () => void;
initialData?: WebhookConfig | null;
}
const AVAILABLE_EVENTS = [
{ value: "crawl.job.completed", labelVi: "Tác vụ cào hoàn tất thành công", labelEn: "Crawl Job Completed" },
{ value: "crawl.job.failed", labelVi: "Tác vụ cào gặp sự cố lỗi", labelEn: "Crawl Job Failed" },
{ value: "crawl.job.running", labelVi: "Tác vụ bắt đầu thu thập dữ liệu", labelEn: "Crawl Job Started" },
{ value: "export.completed", labelVi: "Tệp xuất dữ liệu sẵn sàng", labelEn: "Export File Ready" },
{ value: "export.failed", labelVi: "Tạo tệp xuất thất bại", labelEn: "Export File Failed" },
];
export function CreateWebhookModal({
isOpen,
onClose,
initialData,
}: CreateWebhookModalProps) {
const { t, locale } = useLanguage();
const createMutation = useCreateWebhookConfig();
const updateMutation = useUpdateWebhookConfig();
const isEditing = !!initialData;
const isSubmitting = createMutation.isPending || updateMutation.isPending;
const [url, setUrl] = useState("");
const [secret, setSecret] = useState("");
const [selectedEvents, setSelectedEvents] = useState<string[]>([
"crawl.job.completed",
"crawl.job.failed",
]);
const generateRandomSecret = () => {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_!#*";
let res = "";
for (let i = 0; i < 32; i++) {
res += chars.charAt(Math.floor(Math.random() * chars.length));
}
setSecret(res);
};
useEffect(() => {
if (initialData) {
setUrl(initialData.url);
setSelectedEvents(initialData.events || []);
setSecret(""); // Keep hidden for security on edit unless modified
} else {
setUrl("");
setSelectedEvents(["crawl.job.completed", "crawl.job.failed"]);
generateRandomSecret();
}
}, [initialData, isOpen]);
if (!isOpen) return null;
const toggleEvent = (eventVal: string) => {
setSelectedEvents((prev) =>
prev.includes(eventVal)
? prev.filter((e) => e !== eventVal)
: [...prev, eventVal]
);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!url.trim() || selectedEvents.length === 0) return;
if (isEditing && initialData) {
await updateMutation.mutateAsync({
id: initialData.id,
dto: {
url: url.trim(),
events: selectedEvents,
secret: secret.trim() ? secret.trim() : undefined,
},
});
} else {
if (!secret.trim()) {
generateRandomSecret();
}
await createMutation.mutateAsync({
url: url.trim(),
secret: secret.trim(),
events: selectedEvents,
});
}
onClose();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm animate-in fade-in-0 duration-200">
<div className="relative w-full max-w-xl flex flex-col rounded-3xl border border-emerald-500/20 bg-card p-6 shadow-2xl shadow-emerald-950/20 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between pb-4 border-b border-border/60">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<Webhook className="h-5 w-5" />
</div>
<div>
<h2 className="text-base font-bold tracking-tight text-foreground">
{isEditing ? t.developer.webhooks.modal.editTitle : t.developer.webhooks.modal.title}
</h2>
<p className="text-xs text-muted-foreground">
{t.developer.webhooks.modal.desc}
</p>
</div>
</div>
<button
onClick={onClose}
className="rounded-xl p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="py-4 space-y-4">
{/* Endpoint URL */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold">
{t.developer.webhooks.modal.urlLabel}
</Label>
<Input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder={t.developer.webhooks.modal.urlPlaceholder}
className="rounded-2xl text-xs"
required
/>
</div>
{/* HMAC Secret */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label className="text-xs font-semibold">
{t.developer.webhooks.modal.secretLabel}
</Label>
<button
type="button"
onClick={generateRandomSecret}
className="inline-flex items-center gap-1 text-[11px] text-emerald-600 dark:text-emerald-400 hover:underline cursor-pointer"
>
<RefreshCw className="h-3 w-3" />
<span>{t.developer.webhooks.modal.generateSecret}</span>
</button>
</div>
<Input
value={secret}
onChange={(e) => setSecret(e.target.value)}
placeholder={isEditing ? "Giữ nguyên nếu không muốn đổi khóa..." : t.developer.webhooks.modal.secretPlaceholder}
className="rounded-2xl font-mono text-xs"
minLength={isEditing ? 0 : 16}
/>
</div>
{/* Events Multi-select */}
<div className="space-y-2 pt-2 border-t border-border/60">
<Label className="text-xs font-semibold">
{t.developer.webhooks.modal.eventsLabel}
</Label>
<div className="space-y-2">
{AVAILABLE_EVENTS.map((ev) => {
const isChecked = selectedEvents.includes(ev.value);
return (
<label
key={ev.value}
onClick={() => toggleEvent(ev.value)}
className={`flex items-center justify-between p-2.5 rounded-2xl border transition-colors cursor-pointer select-none ${
isChecked
? "border-emerald-500/40 bg-emerald-500/10 text-foreground"
: "border-border/70 bg-card/60 hover:bg-muted/40 text-muted-foreground"
}`}
>
<div>
<p className="text-xs font-semibold text-foreground font-mono">{ev.value}</p>
<p className="text-[11px] text-muted-foreground">{ev[locale === "vi" ? "labelVi" : "labelEn"]}</p>
</div>
<div
className={`h-4 w-4 rounded-md border flex items-center justify-center transition-colors ${
isChecked
? "bg-emerald-600 border-emerald-600 text-white"
: "border-border"
}`}
>
{isChecked && <Check className="h-3 w-3" />}
</div>
</label>
);
})}
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 pt-3 border-t border-border/60">
<Button
type="button"
variant="outline"
onClick={onClose}
className="rounded-2xl text-xs h-9 px-4 cursor-pointer"
>
{t.developer.webhooks.modal.cancel}
</Button>
<Button
type="submit"
disabled={isSubmitting || !url.trim() || selectedEvents.length === 0}
className="rounded-2xl text-xs h-9 px-5 bg-emerald-600 hover:bg-emerald-700 text-white shadow-sm shadow-emerald-600/20 cursor-pointer"
>
{isSubmitting
? t.developer.webhooks.modal.submitting
: t.developer.webhooks.modal.submit}
</Button>
</div>
</form>
</div>
</div>
);
}
"use client";
import React, { useState } from "react";
import {
X,
Download,
FileSpreadsheet,
FileText,
FileJson,
FileCode,
Archive,
Check,
Globe,
Sparkles,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useLanguage } from "@/providers/language-provider";
import { useCrawlJobs } from "@/hooks/use-crawl-jobs";
import { useCreateCrawlExport } from "@/hooks/use-crawl-exports";
import { ExportType } from "@/types/export";
interface CreateExportModalProps {
isOpen: boolean;
onClose: () => void;
defaultJobId?: string;
}
const EXPORT_FORMATS: {
type: ExportType;
label: string;
desc: string;
icon: React.ElementType;
badgeColor: string;
}[] = [
{
type: "CSV",
label: "CSV (Comma-Separated)",
desc: "Bảng tính chuẩn, phân tích dữ liệu nhanh",
icon: FileText,
badgeColor: "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20",
},
{
type: "JSON",
label: "JSON (Structured)",
desc: "Cấu trúc lồng ghép, tích hợp API & lập trình",
icon: FileJson,
badgeColor: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
},
{
type: "XLSX",
label: "Excel Spreadsheet (XLSX)",
desc: "Định dạng bảng Microsoft Excel chuyên nghiệp",
icon: FileSpreadsheet,
badgeColor: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
},
{
type: "MARKDOWN",
label: "Markdown (.md)",
desc: "Tài liệu văn bản sạch cho RAG và mô hình LLM",
icon: FileCode,
badgeColor: "bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/20",
},
{
type: "ZIP",
label: "ZIP Archive",
desc: "Gói nén toàn bộ tài nguyên hình ảnh và tệp",
icon: Archive,
badgeColor: "bg-slate-500/10 text-slate-600 dark:text-slate-400 border-slate-500/20",
},
];
export function CreateExportModal({
isOpen,
onClose,
defaultJobId = "",
}: CreateExportModalProps) {
const { t } = useLanguage();
const { data: jobsData } = useCrawlJobs({ limit: 10 });
const createExportMutation = useCreateCrawlExport();
const [selectedJobId, setSelectedJobId] = useState(defaultJobId);
const [selectedFormat, setSelectedFormat] = useState<ExportType>("CSV");
const [customFileName, setCustomFileName] = useState("");
const jobs = jobsData?.items || [];
const completedJobs = jobs.filter((j) => j.status === "COMPLETED");
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedJobId) return;
await createExportMutation.mutateAsync({
jobId: selectedJobId,
exportType: selectedFormat,
fileName: customFileName.trim() ? customFileName.trim() : undefined,
});
onClose();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm animate-in fade-in-0 duration-200">
<div className="relative w-full max-w-xl flex flex-col rounded-3xl border border-emerald-500/20 bg-card p-6 shadow-2xl shadow-emerald-950/20 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between pb-4 border-b border-border/60">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<Download className="h-5 w-5" />
</div>
<div>
<h2 className="text-base font-bold tracking-tight text-foreground">
{t.exports.modal.title}
</h2>
<p className="text-xs text-muted-foreground">
{t.exports.modal.desc}
</p>
</div>
</div>
<button
onClick={onClose}
className="rounded-xl p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="py-4 space-y-4">
{/* Job Selection */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold">
{t.exports.modal.selectJob}
</Label>
{completedJobs.length > 0 ? (
<div className="space-y-2 max-h-36 overflow-y-auto pr-1">
{completedJobs.map((job) => (
<button
key={job.id}
type="button"
onClick={() => setSelectedJobId(job.id)}
className={`w-full flex items-center justify-between p-2.5 rounded-2xl border text-left text-xs transition-colors cursor-pointer ${
selectedJobId === job.id
? "border-emerald-500/40 bg-emerald-500/10 text-foreground shadow-sm"
: "border-border/70 bg-card/60 hover:bg-muted/40 text-muted-foreground"
}`}
>
<div className="truncate min-w-0 pr-2">
<p className="font-semibold text-foreground truncate">{job.startUrl}</p>
<p className="text-[10px] text-muted-foreground font-mono truncate">
ID: {job.id.slice(0, 16)}...{job.successPages} trang
</p>
</div>
{selectedJobId === job.id && (
<Check className="h-4 w-4 text-emerald-500 shrink-0" />
)}
</button>
))}
</div>
) : (
<Input
placeholder="Nhập mã Crawl Job ID..."
value={selectedJobId}
onChange={(e) => setSelectedJobId(e.target.value)}
className="rounded-2xl text-xs"
/>
)}
</div>
{/* Format Selection Cards */}
<div className="space-y-2 pt-2 border-t border-border/60">
<Label className="text-xs font-semibold">
{t.exports.modal.selectFormat}
</Label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{EXPORT_FORMATS.map((fmt) => {
const Icon = fmt.icon;
const isSelected = selectedFormat === fmt.type;
return (
<button
key={fmt.type}
type="button"
onClick={() => setSelectedFormat(fmt.type)}
className={`flex items-start gap-2.5 p-3 rounded-2xl border text-left transition-all cursor-pointer ${
isSelected
? "border-emerald-500/40 bg-emerald-500/10 shadow-sm"
: "border-border/70 bg-muted/20 hover:bg-muted/40"
}`}
>
<div className={`p-1.5 rounded-xl border ${fmt.badgeColor} shrink-0`}>
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-foreground">
{fmt.type}
</span>
{isSelected && <Check className="h-3.5 w-3.5 text-emerald-500" />}
</div>
<p className="text-[10px] text-muted-foreground line-clamp-1 mt-0.5">
{fmt.desc}
</p>
</div>
</button>
);
})}
</div>
</div>
{/* Custom File Name (Optional) */}
<div className="space-y-1 pt-1">
<Label className="text-[11px] text-muted-foreground">
Tên tệp tùy chỉnh (Tùy chọn)
</Label>
<Input
value={customFileName}
onChange={(e) => setCustomFileName(e.target.value)}
placeholder="crawl_dataset_custom"
className="rounded-2xl text-xs"
/>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 pt-3 border-t border-border/60">
<Button
type="button"
variant="outline"
onClick={onClose}
className="rounded-2xl text-xs h-9 px-4 cursor-pointer"
>
{t.exports.modal.cancel}
</Button>
<Button
type="submit"
disabled={!selectedJobId || createExportMutation.isPending}
className="rounded-2xl text-xs h-9 px-5 bg-emerald-600 hover:bg-emerald-700 text-white shadow-sm shadow-emerald-600/20 cursor-pointer"
>
{createExportMutation.isPending
? t.exports.modal.submitting
: t.exports.modal.submit}
</Button>
</div>
</form>
</div>
</div>
);
}
"use client";
import React, { useEffect, useState, useRef } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
X,
Calendar,
Clock,
Globe,
Layers,
Sparkles,
Check,
ChevronDown,
Info,
Sliders,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useLanguage } from "@/providers/language-provider";
import {
createCrawlScheduleSchema,
CreateCrawlScheduleInput,
isValidCronExpression,
} from "@/schemas/crawl-schedule.schema";
import { CrawlSchedule, ScheduleFrequency } from "@/types/crawl-schedule";
import { CrawlMode } from "@/types/crawl-job";
import {
useCreateCrawlSchedule,
useUpdateCrawlSchedule,
} from "@/hooks/use-crawl-schedules";
interface ScheduleFormModalProps {
isOpen: boolean;
onClose: () => void;
initialData?: CrawlSchedule | null;
}
const FREQUENCY_OPTIONS: { value: ScheduleFrequency; labelKey: "daily" | "weekly" | "monthly" | "custom"; icon: string }[] = [
{ value: "DAILY", labelKey: "daily", icon: "☀️" },
{ value: "WEEKLY", labelKey: "weekly", icon: "📅" },
{ value: "MONTHLY", labelKey: "monthly", icon: "🗓️" },
{ value: "CUSTOM", labelKey: "custom", icon: "⚙️" },
];
const DAYS_OF_WEEK = [
{ value: 1, labelVi: "Thứ Hai", labelEn: "Monday" },
{ value: 2, labelVi: "Thứ Ba", labelEn: "Tuesday" },
{ value: 3, labelVi: "Thứ Tư", labelEn: "Wednesday" },
{ value: 4, labelVi: "Thứ Năm", labelEn: "Thursday" },
{ value: 5, labelVi: "Thứ Sáu", labelEn: "Friday" },
{ value: 6, labelVi: "Thứ Bảy", labelEn: "Saturday" },
{ value: 0, labelVi: "Chủ Nhật", labelEn: "Sunday" },
];
const CRAWL_MODES: { value: CrawlMode; labelVi: string; labelEn: string }[] = [
{ value: "SCRAPE", labelVi: "Đơn trang", labelEn: "Single Page" },
{ value: "CRAWL", labelVi: "Cào sâu toàn site", labelEn: "Deep Crawl" },
{ value: "SITEMAP", labelVi: "Theo sơ đồ trang", labelEn: "Sitemap" },
{ value: "URL_LIST", labelVi: "Danh sách URL", labelEn: "URL List" },
];
export function ScheduleFormModal({
isOpen,
onClose,
initialData,
}: ScheduleFormModalProps) {
const { t, locale } = useLanguage();
const createMutation = useCreateCrawlSchedule();
const updateMutation = useUpdateCrawlSchedule();
const isEditing = !!initialData;
const isSubmitting = createMutation.isPending || updateMutation.isPending;
const [selectedFrequency, setSelectedFrequency] = useState<ScheduleFrequency>("DAILY");
const [cronInput, setCronInput] = useState("0 2 * * *");
const [selectedDayOfWeek, setSelectedDayOfWeek] = useState<number>(1);
const [selectedDayOfMonth, setSelectedDayOfMonth] = useState<number>(1);
// Custom Dropdown states
const [isModeOpen, setIsModeOpen] = useState(false);
const [isDayOfWeekOpen, setIsDayOfWeekOpen] = useState(false);
const modeDropdownRef = useRef<HTMLDivElement>(null);
const dayOfWeekDropdownRef = useRef<HTMLDivElement>(null);
const {
register,
handleSubmit,
setValue,
watch,
reset,
formState: { errors },
} = useForm<CreateCrawlScheduleInput>({
resolver: zodResolver(createCrawlScheduleSchema),
defaultValues: {
name: "",
startUrl: "",
mode: "CRAWL",
frequency: "DAILY",
hour: 6,
minute: 0,
timezone: "Asia/Ho_Chi_Minh",
maxPages: 30,
maxDepth: 2,
isActive: true,
autoDiff: true,
},
});
const selectedMode = watch("mode");
useEffect(() => {
if (initialData) {
reset({
name: initialData.name,
startUrl: initialData.startUrl,
mode: initialData.mode,
frequency: initialData.frequency,
cronExpression: initialData.cronExpression || undefined,
hour: initialData.hour,
minute: initialData.minute,
dayOfWeek: initialData.dayOfWeek || undefined,
dayOfMonth: initialData.dayOfMonth || undefined,
timezone: initialData.timezone || "Asia/Ho_Chi_Minh",
maxPages: initialData.maxPages,
maxDepth: initialData.maxDepth,
isActive: initialData.isActive,
autoDiff: initialData.autoDiff,
});
setSelectedFrequency(initialData.frequency);
if (initialData.cronExpression) setCronInput(initialData.cronExpression);
if (initialData.dayOfWeek !== null && initialData.dayOfWeek !== undefined) {
setSelectedDayOfWeek(initialData.dayOfWeek);
}
if (initialData.dayOfMonth !== null && initialData.dayOfMonth !== undefined) {
setSelectedDayOfMonth(initialData.dayOfMonth);
}
} else {
reset({
name: "",
startUrl: "",
mode: "CRAWL",
frequency: "DAILY",
hour: 6,
minute: 0,
timezone: "Asia/Ho_Chi_Minh",
maxPages: 30,
maxDepth: 2,
isActive: true,
autoDiff: true,
});
setSelectedFrequency("DAILY");
setCronInput("0 2 * * *");
}
}, [initialData, reset]);
// Click outside listener for dropdowns
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (modeDropdownRef.current && !modeDropdownRef.current.contains(event.target as Node)) {
setIsModeOpen(false);
}
if (dayOfWeekDropdownRef.current && !dayOfWeekDropdownRef.current.contains(event.target as Node)) {
setIsDayOfWeekOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
if (!isOpen) return null;
const handleFrequencySelect = (freq: ScheduleFrequency) => {
setSelectedFrequency(freq);
setValue("frequency", freq);
if (freq === "CUSTOM") {
setValue("cronExpression", cronInput);
} else {
setValue("cronExpression", undefined);
}
};
const getCronDescription = (cron: string) => {
if (!isValidCronExpression(cron)) {
return t.schedules.modal.cronInvalid;
}
const parts = cron.trim().split(/\s+/);
const [m, h, dom, mon, dow] = parts;
if (m === "0" && h !== "*" && dom === "*" && mon === "*" && dow === "*") {
return locale === "vi" ? `Chạy lúc ${h.padStart(2, "0")}:00 hàng ngày` : `Runs daily at ${h.padStart(2, "0")}:00`;
}
if (m.startsWith("*/")) {
const step = m.replace("*/", "");
return locale === "vi" ? `Chạy mỗi ${step} phút một lần` : `Runs every ${step} minutes`;
}
return t.schedules.modal.cronValid;
};
const onSubmit = async (data: CreateCrawlScheduleInput) => {
const payload: CreateCrawlScheduleInput = {
...data,
frequency: selectedFrequency,
cronExpression: selectedFrequency === "CUSTOM" ? cronInput : undefined,
dayOfWeek: selectedFrequency === "WEEKLY" ? selectedDayOfWeek : undefined,
dayOfMonth: selectedFrequency === "MONTHLY" ? selectedDayOfMonth : undefined,
};
if (isEditing && initialData) {
await updateMutation.mutateAsync({
id: initialData.id,
dto: payload,
});
} else {
await createMutation.mutateAsync(payload);
}
onClose();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm animate-in fade-in-0 duration-200">
<div className="relative w-full max-w-2xl max-h-[90vh] flex flex-col rounded-3xl border border-emerald-500/20 bg-card p-6 shadow-2xl shadow-emerald-950/20 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between pb-4 border-b border-border/60">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<Calendar className="h-5 w-5" />
</div>
<div>
<h2 className="text-lg font-bold tracking-tight text-foreground">
{isEditing ? t.schedules.modal.editTitle : t.schedules.modal.createTitle}
</h2>
<p className="text-xs text-muted-foreground">
{t.schedules.modal.desc}
</p>
</div>
</div>
<button
onClick={onClose}
className="rounded-xl p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Form Body */}
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col flex-1 overflow-hidden">
<div className="flex-1 overflow-y-auto py-4 space-y-4 pr-1">
{/* Name & Start URL */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="sch-name" className="text-xs font-semibold flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 text-emerald-500" />
{t.schedules.modal.nameLabel}
</Label>
<Input
id="sch-name"
placeholder={t.schedules.modal.namePlaceholder}
className="rounded-2xl border-border/80 text-xs focus:ring-emerald-500/20"
{...register("name")}
/>
{errors.name && (
<p className="text-[11px] text-red-500">{errors.name.message}</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="sch-url" className="text-xs font-semibold flex items-center gap-1.5">
<Globe className="h-3.5 w-3.5 text-emerald-500" />
{t.schedules.modal.urlLabel}
</Label>
<Input
id="sch-url"
placeholder={t.schedules.modal.urlPlaceholder}
className="rounded-2xl border-border/80 text-xs focus:ring-emerald-500/20"
{...register("startUrl")}
/>
{errors.startUrl && (
<p className="text-[11px] text-red-500">{errors.startUrl.message}</p>
)}
</div>
</div>
{/* Mode Dropdown */}
<div className="space-y-1.5" ref={modeDropdownRef}>
<Label className="text-xs font-semibold flex items-center gap-1.5">
<Layers className="h-3.5 w-3.5 text-emerald-500" />
{t.schedules.modal.modeLabel}
</Label>
<div className="relative">
<button
type="button"
onClick={() => setIsModeOpen(!isModeOpen)}
className="w-full flex items-center justify-between rounded-2xl border border-border/80 bg-card/60 px-3 py-2 text-xs text-foreground hover:bg-muted/60 transition-colors cursor-pointer"
>
<span>
{CRAWL_MODES.find((m) => m.value === selectedMode)?.[locale === "vi" ? "labelVi" : "labelEn"]}
</span>
<ChevronDown
className={`h-4 w-4 text-muted-foreground transition-transform duration-200 ${
isModeOpen ? "rotate-180" : ""
}`}
/>
</button>
{isModeOpen && (
<div className="absolute top-full left-0 right-0 mt-1.5 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">
{CRAWL_MODES.map((modeItem) => (
<button
key={modeItem.value}
type="button"
onClick={() => {
setValue("mode", modeItem.value);
setIsModeOpen(false);
}}
className={`w-full flex items-center justify-between rounded-xl px-2.5 py-2 text-xs transition-colors cursor-pointer ${
selectedMode === modeItem.value
? "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"
}`}
>
<span>{modeItem[locale === "vi" ? "labelVi" : "labelEn"]}</span>
{selectedMode === modeItem.value && <Check className="h-3.5 w-3.5" />}
</button>
))}
</div>
)}
</div>
</div>
{/* Visual Frequency Picker Tabs */}
<div className="space-y-2 pt-2 border-t border-border/60">
<Label className="text-xs font-semibold flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5 text-emerald-500" />
{t.schedules.modal.frequencyLabel}
</Label>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{FREQUENCY_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => handleFrequencySelect(opt.value)}
className={`flex flex-col items-center justify-center p-3 rounded-2xl border text-xs font-semibold transition-all cursor-pointer ${
selectedFrequency === opt.value
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 shadow-sm"
: "border-border/80 bg-muted/30 text-muted-foreground hover:bg-muted/60"
}`}
>
<span className="text-base mb-1">{opt.icon}</span>
<span>{t.schedules.frequency[opt.labelKey]}</span>
</button>
))}
</div>
{/* Time Configuration based on Frequency */}
<div className="p-4 rounded-2xl border border-emerald-500/15 bg-muted/20 space-y-3 mt-2">
{selectedFrequency === "DAILY" && (
<div className="grid grid-cols-2 gap-3 max-w-xs">
<div className="space-y-1">
<Label className="text-[11px] text-muted-foreground">
{t.schedules.modal.hourLabel}
</Label>
<Input
type="number"
min={0}
max={23}
className="rounded-xl text-xs"
{...register("hour", { valueAsNumber: true })}
/>
</div>
<div className="space-y-1">
<Label className="text-[11px] text-muted-foreground">
{t.schedules.modal.minuteLabel}
</Label>
<Input
type="number"
min={0}
max={59}
className="rounded-xl text-xs"
{...register("minute", { valueAsNumber: true })}
/>
</div>
</div>
)}
{selectedFrequency === "WEEKLY" && (
<div className="space-y-3">
<div className="space-y-1" ref={dayOfWeekDropdownRef}>
<Label className="text-[11px] text-muted-foreground">
{t.schedules.modal.dayOfWeekLabel}
</Label>
<div className="relative max-w-xs">
<button
type="button"
onClick={() => setIsDayOfWeekOpen(!isDayOfWeekOpen)}
className="w-full flex items-center justify-between rounded-xl border border-border/80 bg-card px-3 py-1.5 text-xs text-foreground cursor-pointer"
>
<span>
{DAYS_OF_WEEK.find((d) => d.value === selectedDayOfWeek)?.[
locale === "vi" ? "labelVi" : "labelEn"
]}
</span>
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
</button>
{isDayOfWeekOpen && (
<div className="absolute top-full left-0 right-0 mt-1 rounded-2xl border border-emerald-500/20 bg-card/95 p-1.5 shadow-xl backdrop-blur-xl z-50">
{DAYS_OF_WEEK.map((d) => (
<button
key={d.value}
type="button"
onClick={() => {
setSelectedDayOfWeek(d.value);
setIsDayOfWeekOpen(false);
}}
className={`w-full flex items-center justify-between rounded-xl px-2 py-1.5 text-xs cursor-pointer ${
selectedDayOfWeek === d.value
? "bg-emerald-500/10 text-emerald-600 font-semibold"
: "text-muted-foreground hover:bg-muted"
}`}
>
<span>{d[locale === "vi" ? "labelVi" : "labelEn"]}</span>
{selectedDayOfWeek === d.value && <Check className="h-3.5 w-3.5" />}
</button>
))}
</div>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-3 max-w-xs">
<div className="space-y-1">
<Label className="text-[11px] text-muted-foreground">{t.schedules.modal.hourLabel}</Label>
<Input
type="number"
min={0}
max={23}
className="rounded-xl text-xs"
{...register("hour", { valueAsNumber: true })}
/>
</div>
<div className="space-y-1">
<Label className="text-[11px] text-muted-foreground">{t.schedules.modal.minuteLabel}</Label>
<Input
type="number"
min={0}
max={59}
className="rounded-xl text-xs"
{...register("minute", { valueAsNumber: true })}
/>
</div>
</div>
</div>
)}
{selectedFrequency === "MONTHLY" && (
<div className="space-y-3">
<div className="space-y-1 max-w-xs">
<Label className="text-[11px] text-muted-foreground">
{t.schedules.modal.dayOfMonthLabel}
</Label>
<Input
type="number"
min={1}
max={31}
value={selectedDayOfMonth}
onChange={(e) => setSelectedDayOfMonth(Number(e.target.value))}
className="rounded-xl text-xs"
/>
</div>
<div className="grid grid-cols-2 gap-3 max-w-xs">
<div className="space-y-1">
<Label className="text-[11px] text-muted-foreground">{t.schedules.modal.hourLabel}</Label>
<Input
type="number"
min={0}
max={23}
className="rounded-xl text-xs"
{...register("hour", { valueAsNumber: true })}
/>
</div>
<div className="space-y-1">
<Label className="text-[11px] text-muted-foreground">{t.schedules.modal.minuteLabel}</Label>
<Input
type="number"
min={0}
max={59}
className="rounded-xl text-xs"
{...register("minute", { valueAsNumber: true })}
/>
</div>
</div>
</div>
)}
{selectedFrequency === "CUSTOM" && (
<div className="space-y-2">
<div className="space-y-1">
<Label className="text-[11px] text-muted-foreground">
{t.schedules.modal.cronLabel}
</Label>
<Input
value={cronInput}
onChange={(e) => {
setCronInput(e.target.value);
setValue("cronExpression", e.target.value);
}}
placeholder="0 2 * * *"
className="rounded-xl text-xs font-mono"
/>
</div>
<div className="flex items-center gap-1.5 text-xs text-emerald-600 dark:text-emerald-400">
<Info className="h-3.5 w-3.5 shrink-0" />
<span>{getCronDescription(cronInput)}</span>
</div>
</div>
)}
</div>
</div>
{/* Depth, Max Pages, AutoDiff & Active Switch */}
<div className="pt-2 border-t border-border/60 grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1">
<Label className="text-xs font-semibold">
{t.schedules.modal.maxPagesLabel}
</Label>
<Input
type="number"
min={1}
max={1000}
className="rounded-2xl text-xs"
{...register("maxPages", { valueAsNumber: true })}
/>
</div>
<div className="space-y-1">
<Label className="text-xs font-semibold">
{t.schedules.modal.maxDepthLabel}
</Label>
<Input
type="number"
min={1}
max={10}
className="rounded-2xl text-xs"
{...register("maxDepth", { valueAsNumber: true })}
/>
</div>
</div>
{/* Toggles */}
<div className="space-y-2 pt-2">
<label className="flex items-center gap-2.5 text-xs text-foreground cursor-pointer select-none">
<input
type="checkbox"
className="rounded text-emerald-600 focus:ring-emerald-500/30 h-4 w-4"
{...register("autoDiff")}
/>
<span>{t.schedules.modal.autoDiffLabel}</span>
</label>
<label className="flex items-center gap-2.5 text-xs text-foreground cursor-pointer select-none">
<input
type="checkbox"
className="rounded text-emerald-600 focus:ring-emerald-500/30 h-4 w-4"
{...register("isActive")}
/>
<span>{t.schedules.modal.activeLabel}</span>
</label>
</div>
</div>
{/* Footer Actions */}
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border/60">
<Button
type="button"
variant="outline"
onClick={onClose}
className="rounded-2xl text-xs h-9 px-4 cursor-pointer"
>
{t.schedules.modal.cancel}
</Button>
<Button
type="submit"
disabled={isSubmitting}
className="rounded-2xl text-xs h-9 px-5 bg-emerald-600 hover:bg-emerald-700 text-white shadow-sm shadow-emerald-600/20 cursor-pointer"
>
{isSubmitting ? t.schedules.modal.saving : t.schedules.modal.save}
</Button>
</div>
</form>
</div>
</div>
);
}
"use client";
import React from "react";
import Link from "next/link";
import {
X,
History,
CheckCircle2,
Clock,
ExternalLink,
AlertCircle,
RefreshCw,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { CrawlSchedule } from "@/types/crawl-schedule";
import { useScheduleHistory } from "@/hooks/use-crawl-schedules";
import { useLanguage } from "@/providers/language-provider";
interface ScheduleHistoryModalProps {
isOpen: boolean;
onClose: () => void;
schedule: CrawlSchedule | null;
}
export function ScheduleHistoryModal({
isOpen,
onClose,
schedule,
}: ScheduleHistoryModalProps) {
const { t, locale } = useLanguage();
const {
data: historyData,
isLoading,
isError,
refetch,
} = useScheduleHistory(schedule?.id || "", { limit: 20 });
if (!isOpen || !schedule) return null;
const jobs = historyData?.items || [];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm animate-in fade-in-0 duration-200">
<div className="relative w-full max-w-2xl max-h-[85vh] flex flex-col rounded-3xl border border-emerald-500/20 bg-card p-6 shadow-2xl shadow-emerald-950/20 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between pb-4 border-b border-border/60">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<History className="h-5 w-5" />
</div>
<div>
<h2 className="text-base font-bold tracking-tight text-foreground">
{t.schedules.historyModal.title}
</h2>
<p className="text-xs text-muted-foreground truncate max-w-md">
{schedule.name} ({schedule.startUrl})
</p>
</div>
</div>
<button
onClick={onClose}
className="rounded-xl p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Content Table */}
<div className="flex-1 overflow-y-auto py-4">
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="h-14 rounded-2xl bg-muted/40 animate-pulse" />
))}
</div>
) : isError ? (
<div className="p-6 text-center rounded-2xl border border-red-500/20 bg-red-500/5 space-y-2">
<p className="text-xs text-red-500">Đã có lỗi khi tải lịch sử job.</p>
<Button size="sm" variant="outline" onClick={() => refetch()} className="rounded-xl text-xs">
<RefreshCw className="h-3.5 w-3.5 mr-1" />
{t.templates.retry}
</Button>
</div>
) : jobs.length === 0 ? (
<div className="py-12 text-center rounded-2xl border border-dashed border-border/70 space-y-2">
<Clock className="h-8 w-8 text-muted-foreground mx-auto opacity-50" />
<p className="text-xs text-muted-foreground">
{t.schedules.historyModal.empty}
</p>
</div>
) : (
<div className="space-y-2.5">
{jobs.map((job) => (
<div
key={job.id}
className="flex items-center justify-between p-3.5 rounded-2xl border border-border/70 bg-card/60 hover:bg-muted/40 transition-colors"
>
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="font-mono text-xs font-bold text-foreground">
{job.id.slice(0, 16)}...
</span>
<span className="rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 px-2 py-0.5 text-[10px] font-semibold border border-emerald-500/20">
{job.status}
</span>
</div>
<p className="text-[11px] text-muted-foreground">
{job.successPages} / {job.totalPages} trang •{" "}
{new Date(job.createdAt).toLocaleDateString(
locale === "vi" ? "vi-VN" : "en-US",
{
hour: "2-digit",
minute: "2-digit",
day: "numeric",
month: "short",
}
)}
</p>
</div>
<Link
href={`/crawl-jobs/${job.id}`}
className="inline-flex items-center gap-1 rounded-xl px-2.5 py-1.5 text-xs text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/10 transition-colors"
>
<span>{t.schedules.historyModal.viewDetail}</span>
<ExternalLink className="h-3 w-3" />
</Link>
</div>
))}
</div>
)}
</div>
{/* Footer */}
<div className="flex justify-end pt-3 border-t border-border/60">
<Button
variant="outline"
onClick={onClose}
className="rounded-2xl text-xs h-9 px-5 cursor-pointer"
>
{t.schedules.historyModal.close}
</Button>
</div>
</div>
</div>
);
}
"use client";
import React, { useEffect, useState, useRef } from "react";
import { useForm, useFieldArray } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
X,
Plus,
Trash2,
FileCode2,
Globe,
Sparkles,
Layers,
ChevronDown,
Check,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useLanguage } from "@/providers/language-provider";
import {
createExtractionTemplateSchema,
CreateExtractionTemplateInput,
} from "@/schemas/extraction-template.schema";
import { ExtractionTemplate } from "@/types/extraction-template";
import {
useCreateExtractionTemplate,
useUpdateExtractionTemplate,
} from "@/hooks/use-extraction-templates";
interface TemplateFormModalProps {
isOpen: boolean;
onClose: () => void;
initialData?: ExtractionTemplate | null;
}
const SELECTOR_TYPES = [
{ value: "CSS", label: "CSS Selector (Khuyên dùng)", icon: "🎨" },
{ value: "XPATH", label: "XPath Expression", icon: "🧭" },
{ value: "REGEX", label: "Regex Pattern", icon: "⚡" },
{ value: "JSON_SCHEMA", label: "JSON Schema Path", icon: "📦" },
];
const COMMON_ATTRS = [
{ value: "text", label: "Nội dung văn bản (text / innerText)" },
{ value: "html", label: "Mã HTML gốc (innerHTML)" },
{ value: "src", label: "Đường dẫn nguồn ảnh/tệp (src)" },
{ value: "href", label: "Liên kết URL (href)" },
{ value: "value", label: "Giá trị thuộc tính (value)" },
];
export function TemplateFormModal({
isOpen,
onClose,
initialData,
}: TemplateFormModalProps) {
const { t } = useLanguage();
const createMutation = useCreateExtractionTemplate();
const updateMutation = useUpdateExtractionTemplate();
const isEditing = !!initialData;
const isSubmitting = createMutation.isPending || updateMutation.isPending;
const {
register,
control,
handleSubmit,
reset,
setValue,
formState: { errors },
} = useForm<CreateExtractionTemplateInput>({
resolver: zodResolver(createExtractionTemplateSchema),
defaultValues: {
name: "",
domain: "",
fields: [
{ name: "title", selector: "h1.title", attr: "text", required: true },
{ name: "content", selector: "article.content", attr: "text", required: false },
],
},
});
const { fields, append, remove } = useFieldArray({
control,
name: "fields",
});
useEffect(() => {
if (initialData) {
reset({
name: initialData.name,
domain: initialData.domain,
fields: initialData.fields.map((f) => ({
name: f.name,
selector: f.selector,
attr: f.attr,
required: f.required,
})),
});
} else {
reset({
name: "",
domain: "",
fields: [
{ name: "title", selector: "h1", attr: "text", required: true },
{ name: "description", selector: ".description", attr: "text", required: false },
],
});
}
}, [initialData, reset]);
// Handle escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
const onSubmit = async (data: CreateExtractionTemplateInput) => {
if (isEditing && initialData) {
await updateMutation.mutateAsync({
id: initialData.id,
dto: { name: data.name, fields: data.fields },
});
} else {
await createMutation.mutateAsync(data);
}
onClose();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm animate-in fade-in-0 duration-200">
<div className="relative w-full max-w-2xl max-h-[90vh] flex flex-col rounded-3xl border border-emerald-500/20 bg-card p-6 shadow-2xl shadow-emerald-950/20 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between pb-4 border-b border-border/60">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<FileCode2 className="h-5 w-5" />
</div>
<div>
<h2 className="text-lg font-bold tracking-tight text-foreground">
{isEditing ? t.templates.modal.editTitle : t.templates.modal.createTitle}
</h2>
<p className="text-xs text-muted-foreground">
{t.templates.modal.desc}
</p>
</div>
</div>
<button
onClick={onClose}
className="rounded-xl p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Form Body */}
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col flex-1 overflow-hidden">
<div className="flex-1 overflow-y-auto py-4 space-y-4 pr-1">
{/* Template Name & Domain */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="tpl-name" className="text-xs font-semibold flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 text-emerald-500" />
{t.templates.modal.nameLabel}
</Label>
<Input
id="tpl-name"
placeholder={t.templates.modal.namePlaceholder}
className="rounded-2xl border-border/80 text-xs focus:ring-emerald-500/20"
{...register("name")}
/>
{errors.name && (
<p className="text-[11px] text-red-500">{errors.name.message}</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="tpl-domain" className="text-xs font-semibold flex items-center gap-1.5">
<Globe className="h-3.5 w-3.5 text-emerald-500" />
{t.templates.modal.domainLabel}
</Label>
<Input
id="tpl-domain"
placeholder={t.templates.modal.domainPlaceholder}
disabled={isEditing}
className="rounded-2xl border-border/80 text-xs focus:ring-emerald-500/20 disabled:opacity-60"
{...register("domain")}
/>
{errors.domain && (
<p className="text-[11px] text-red-500">{errors.domain.message}</p>
)}
</div>
</div>
{/* Dynamic Fields Section */}
<div className="pt-2">
<div className="flex items-center justify-between pb-2 mb-3 border-b border-border/50">
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-emerald-500" />
<span className="text-xs font-bold text-foreground">
{t.templates.modal.fieldsTitle}
</span>
<span className="rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 text-[10px] font-semibold px-2 py-0.5 border border-emerald-500/20">
{fields.length}
</span>
</div>
<Button
type="button"
size="sm"
variant="outline"
onClick={() =>
append({
name: `field_${fields.length + 1}`,
selector: "",
attr: "text",
required: false,
})
}
className="rounded-xl h-8 px-2.5 text-xs text-emerald-600 dark:text-emerald-400 border-emerald-500/30 hover:bg-emerald-500/10 cursor-pointer"
>
<Plus className="h-3.5 w-3.5 mr-1" />
{t.templates.modal.addFieldBtn}
</Button>
</div>
{errors.fields && (
<p className="text-[11px] text-red-500 mb-2">{errors.fields.message}</p>
)}
{/* Fields List */}
<div className="space-y-3">
{fields.map((fieldItem, index) => (
<div
key={fieldItem.id}
className="p-3.5 rounded-2xl border border-emerald-500/15 bg-muted/30 hover:bg-muted/50 transition-colors space-y-2.5"
>
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider">
#{index + 1}
</span>
<div className="flex items-center gap-3">
<label className="flex items-center gap-1.5 text-xs cursor-pointer select-none">
<input
type="checkbox"
className="rounded text-emerald-600 focus:ring-emerald-500/30 h-3.5 w-3.5"
{...register(`fields.${index}.required`)}
/>
<span className="text-[11px] text-muted-foreground">
{t.templates.modal.required}
</span>
</label>
{fields.length > 1 && (
<button
type="button"
onClick={() => remove(index)}
className="text-muted-foreground hover:text-red-500 p-1 rounded-lg transition-colors cursor-pointer"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2.5">
<div className="space-y-1">
<Label className="text-[11px] text-muted-foreground">
{t.templates.modal.fieldName}
</Label>
<Input
placeholder="title, price..."
className="h-8 rounded-xl text-xs"
{...register(`fields.${index}.name`)}
/>
{errors.fields?.[index]?.name && (
<p className="text-[10px] text-red-500">
{errors.fields[index]?.name?.message}
</p>
)}
</div>
<div className="space-y-1 sm:col-span-2">
<Label className="text-[11px] text-muted-foreground">
{t.templates.modal.selectorValue}
</Label>
<Input
placeholder="h1.title, //div[@class='item'], .price..."
className="h-8 rounded-xl text-xs font-mono"
{...register(`fields.${index}.selector`)}
/>
{errors.fields?.[index]?.selector && (
<p className="text-[10px] text-red-500">
{errors.fields[index]?.selector?.message}
</p>
)}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5 pt-1">
<div className="space-y-1">
<Label className="text-[11px] text-muted-foreground">
{t.templates.modal.attrLabel}
</Label>
<Input
placeholder="text, href, src, innerText..."
className="h-8 rounded-xl text-xs font-mono"
{...register(`fields.${index}.attr`)}
/>
</div>
</div>
</div>
))}
</div>
</div>
</div>
{/* Footer Actions */}
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border/60">
<Button
type="button"
variant="outline"
onClick={onClose}
className="rounded-2xl text-xs h-9 px-4 cursor-pointer"
>
{t.templates.modal.cancel}
</Button>
<Button
type="submit"
disabled={isSubmitting}
className="rounded-2xl text-xs h-9 px-5 bg-emerald-600 hover:bg-emerald-700 text-white shadow-sm shadow-emerald-600/20 cursor-pointer"
>
{isSubmitting ? t.templates.modal.saving : t.templates.modal.save}
</Button>
</div>
</form>
</div>
</div>
);
}
"use client";
import React, { useState } from "react";
import {
X,
Play,
CheckCircle2,
AlertCircle,
ExternalLink,
Code2,
Sparkles,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ExtractionTemplate } from "@/types/extraction-template";
import { useLanguage } from "@/providers/language-provider";
interface TemplatePreviewModalProps {
isOpen: boolean;
onClose: () => void;
template: ExtractionTemplate | null;
}
export function TemplatePreviewModal({
isOpen,
onClose,
template,
}: TemplatePreviewModalProps) {
const { t } = useLanguage();
const [testUrl, setTestUrl] = useState("");
const [isSimulating, setIsSimulating] = useState(false);
const [simulatedData, setSimulatedData] = useState<Record<string, unknown> | null>(null);
React.useEffect(() => {
if (template) {
setTestUrl(`https://${template.domain}/sample-article-2026`);
setSimulatedData(null);
}
}, [template]);
if (!isOpen || !template) return null;
const handleSimulate = () => {
setIsSimulating(true);
setTimeout(() => {
const result: Record<string, string | number | boolean> = {};
template.fields.forEach((f) => {
if (f.name.toLowerCase().includes("title") || f.name.toLowerCase().includes("name")) {
result[f.name] = "Trí Tuệ Nhân Tạo và Cuộc Cách Mạng Công Nghệ Xanh 2026";
} else if (f.name.toLowerCase().includes("price")) {
result[f.name] = "15.990.000 ₫";
} else if (f.name.toLowerCase().includes("content") || f.name.toLowerCase().includes("description")) {
result[f.name] = "Báo cáo tổng kết xu hướng chuyển đổi số và tối ưu hiệu suất tự động hóa trong kỷ nguyên dữ liệu lớn...";
} else if (f.name.toLowerCase().includes("author")) {
result[f.name] = "Nguyễn Văn An - Ban Công Nghệ";
} else if (f.name.toLowerCase().includes("date") || f.name.toLowerCase().includes("time")) {
result[f.name] = "2026-09-06T15:30:00Z";
} else if (f.name.toLowerCase().includes("phone")) {
result[f.name] = "+84 987 654 321";
} else if (f.name.toLowerCase().includes("tax")) {
result[f.name] = "0102030405";
} else {
result[f.name] = `Giá trị trích xuất mẫu cho [${f.selector}]`;
}
});
setSimulatedData(result);
setIsSimulating(false);
}, 600);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm animate-in fade-in-0 duration-200">
<div className="relative w-full max-w-xl flex flex-col rounded-3xl border border-emerald-500/20 bg-card p-6 shadow-2xl shadow-emerald-950/20 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between pb-4 border-b border-border/60">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<Sparkles className="h-5 w-5" />
</div>
<div>
<h2 className="text-base font-bold tracking-tight text-foreground">
{t.templates.preview.title}
</h2>
<p className="text-xs text-muted-foreground truncate max-w-sm">
{template.name} ({template.domain})
</p>
</div>
</div>
<button
onClick={onClose}
className="rounded-xl p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Content */}
<div className="py-4 space-y-4">
<div className="space-y-1.5">
<Label className="text-xs font-semibold">
{t.templates.preview.testUrl}
</Label>
<div className="flex gap-2">
<Input
value={testUrl}
onChange={(e) => setTestUrl(e.target.value)}
placeholder="https://..."
className="rounded-2xl text-xs"
/>
<Button
onClick={handleSimulate}
disabled={isSimulating || !testUrl}
className="rounded-2xl text-xs bg-emerald-600 hover:bg-emerald-700 text-white shadow-sm shadow-emerald-600/20 px-4 cursor-pointer shrink-0"
>
<Play className="h-3.5 w-3.5 mr-1" />
{isSimulating ? t.templates.preview.simulating : t.templates.preview.simulateBtn}
</Button>
</div>
</div>
{/* Configured Fields Summary */}
<div className="p-3 rounded-2xl border border-emerald-500/15 bg-muted/30 space-y-1.5">
<p className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider">
{t.templates.modal.fieldsTitle} ({template.fields.length})
</p>
<div className="flex flex-wrap gap-1.5">
{template.fields.map((f, i) => (
<span
key={i}
className="rounded-lg bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 px-2 py-0.5 text-[11px] font-mono border border-emerald-500/20"
>
{f.name} <span className="opacity-60 text-[10px]">({f.attr})</span>
</span>
))}
</div>
</div>
{/* Simulated Output */}
{simulatedData && (
<div className="p-3.5 rounded-2xl border border-emerald-500/20 bg-card space-y-2 animate-in fade-in-50 duration-200">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 text-xs font-semibold text-emerald-600 dark:text-emerald-400">
<CheckCircle2 className="h-4 w-4" />
<span>{t.templates.preview.sampleResult}</span>
</div>
<span className="text-[10px] text-muted-foreground font-mono">
HTTP 200 OK
</span>
</div>
<pre className="p-3 rounded-xl bg-muted/60 text-foreground font-mono text-[11px] overflow-x-auto max-h-48">
{JSON.stringify(simulatedData, null, 2)}
</pre>
</div>
)}
</div>
{/* Footer */}
<div className="flex justify-end pt-3 border-t border-border/60">
<Button
variant="outline"
onClick={onClose}
className="rounded-2xl text-xs h-9 px-5 cursor-pointer"
>
{t.dialog.cancelBtn}
</Button>
</div>
</div>
</div>
);
}
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { crawlExportService } from "@/services/crawl-export.service";
import { CrawlExportQueryDto, ExportType } from "@/types/export";
import { toast } from "sonner";
export const CRAWL_EXPORTS_KEYS = {
all: ["crawl-exports"] as const,
lists: () => [...CRAWL_EXPORTS_KEYS.all, "list"] as const,
list: (params?: CrawlExportQueryDto) => [...CRAWL_EXPORTS_KEYS.lists(), params] as const,
};
export function useCrawlExportsList(params?: CrawlExportQueryDto) {
return useQuery({
queryKey: CRAWL_EXPORTS_KEYS.list(params),
queryFn: () => crawlExportService.getExports(params),
staleTime: 10000,
});
}
export function useCreateCrawlExport() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ jobId, exportType, fileName }: { jobId: string; exportType: ExportType; fileName?: string }) =>
crawlExportService.createExport(jobId, { exportType, fileName }),
onSuccess: () => {
toast.success("Khởi tạo yêu cầu xuất dữ liệu thành công!");
queryClient.invalidateQueries({ queryKey: CRAWL_EXPORTS_KEYS.all });
},
onError: (error: Error) => {
toast.error(`Không thể tạo yêu cầu xuất: ${error.message}`);
},
});
}
export function useDeleteCrawlExport() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => crawlExportService.deleteExport(id),
onSuccess: () => {
toast.success("Đã xóa tệp dữ liệu đã xuất!");
queryClient.invalidateQueries({ queryKey: CRAWL_EXPORTS_KEYS.all });
},
onError: (error: Error) => {
toast.error(`Không thể xóa tệp: ${error.message}`);
},
});
}
export function useDownloadExport() {
return useMutation({
mutationFn: ({ exportId, fileName }: { exportId: string; fileName?: string }) =>
crawlExportService.downloadExport(exportId, fileName),
onSuccess: () => {
toast.success("Đang tải tệp về máy tính của bạn...");
},
onError: (error: Error) => {
toast.error(`Tải tệp thất bại: ${error.message}`);
},
});
}
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { crawlScheduleService } from "@/services/crawl-schedule.service";
import {
CrawlScheduleQueryDto,
CreateCrawlScheduleDto,
UpdateCrawlScheduleDto,
} from "@/types/crawl-schedule";
import { toast } from "sonner";
export const CRAWL_SCHEDULES_KEYS = {
all: ["crawl-schedules"] as const,
lists: () => [...CRAWL_SCHEDULES_KEYS.all, "list"] as const,
list: (params?: CrawlScheduleQueryDto) => [...CRAWL_SCHEDULES_KEYS.lists(), params] as const,
detail: (id: string) => [...CRAWL_SCHEDULES_KEYS.all, "detail", id] as const,
history: (id: string, params?: { page?: number; limit?: number }) =>
[...CRAWL_SCHEDULES_KEYS.detail(id), "history", params] as const,
};
export function useCrawlSchedulesList(params?: CrawlScheduleQueryDto) {
return useQuery({
queryKey: CRAWL_SCHEDULES_KEYS.list(params),
queryFn: () => crawlScheduleService.getSchedules(params),
staleTime: 10000,
});
}
export function useCrawlScheduleDetail(id: string) {
return useQuery({
queryKey: CRAWL_SCHEDULES_KEYS.detail(id),
queryFn: () => crawlScheduleService.getScheduleById(id),
enabled: !!id,
});
}
export function useCreateCrawlSchedule() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (dto: CreateCrawlScheduleDto) => crawlScheduleService.createSchedule(dto),
onSuccess: () => {
toast.success("Thiết lập lịch cào tự động thành công!");
queryClient.invalidateQueries({ queryKey: CRAWL_SCHEDULES_KEYS.all });
},
onError: (error: Error) => {
toast.error(`Không thể tạo lịch cào: ${error.message}`);
},
});
}
export function useUpdateCrawlSchedule() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, dto }: { id: string; dto: UpdateCrawlScheduleDto }) =>
crawlScheduleService.updateSchedule(id, dto),
onSuccess: (_, { id }) => {
toast.success("Cập nhật lịch cào thành công!");
queryClient.invalidateQueries({ queryKey: CRAWL_SCHEDULES_KEYS.all });
queryClient.invalidateQueries({ queryKey: CRAWL_SCHEDULES_KEYS.detail(id) });
},
onError: (error: Error) => {
toast.error(`Không thể cập nhật lịch: ${error.message}`);
},
});
}
export function useDeleteCrawlSchedule() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => crawlScheduleService.deleteSchedule(id),
onSuccess: () => {
toast.success("Đã xóa lịch cào tự động!");
queryClient.invalidateQueries({ queryKey: CRAWL_SCHEDULES_KEYS.all });
},
onError: (error: Error) => {
toast.error(`Không thể xóa lịch: ${error.message}`);
},
});
}
export function useTriggerScheduleRun() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => crawlScheduleService.triggerRun(id),
onSuccess: (res, id) => {
toast.success(res.message || "Đã kích hoạt chạy tác vụ cào!");
queryClient.invalidateQueries({ queryKey: CRAWL_SCHEDULES_KEYS.detail(id) });
queryClient.invalidateQueries({ queryKey: CRAWL_SCHEDULES_KEYS.history(id) });
},
onError: (error: Error) => {
toast.error(`Kích hoạt thất bại: ${error.message}`);
},
});
}
export function useScheduleHistory(id: string, params?: { page?: number; limit?: number }) {
return useQuery({
queryKey: CRAWL_SCHEDULES_KEYS.history(id, params),
queryFn: () => crawlScheduleService.getHistory(id, params),
enabled: !!id,
});
}
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { developerService } from "@/services/developer.service";
import {
CreateApiKeyDto,
CreateWebhookConfigDto,
UpdateWebhookConfigDto,
WebhookDeliveryQueryDto,
} from "@/types/developer";
import { toast } from "sonner";
export const DEVELOPER_KEYS = {
apiKeys: ["developer", "api-keys"] as const,
webhooks: ["developer", "webhooks"] as const,
deliveries: (params?: WebhookDeliveryQueryDto) => ["developer", "deliveries", params] as const,
};
// API Keys Hooks
export function useApiKeysList() {
return useQuery({
queryKey: DEVELOPER_KEYS.apiKeys,
queryFn: () => developerService.listKeys(),
staleTime: 30000,
});
}
export function useCreateApiKey() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (dto: CreateApiKeyDto) => developerService.createKey(dto),
onSuccess: () => {
toast.success("Khởi tạo khóa API mới thành công!");
queryClient.invalidateQueries({ queryKey: DEVELOPER_KEYS.apiKeys });
},
onError: (error: Error) => {
toast.error(`Tạo khóa API thất bại: ${error.message}`);
},
});
}
export function useToggleApiKey() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, isActive }: { id: string; isActive: boolean }) =>
developerService.toggleActive(id, isActive),
onSuccess: (updated) => {
toast.success(
updated.isActive
? "Đã kích hoạt lại khóa API!"
: "Đã tạm dừng hoạt động của khóa API!"
);
queryClient.invalidateQueries({ queryKey: DEVELOPER_KEYS.apiKeys });
},
onError: (error: Error) => {
toast.error(`Thay đổi trạng thái thất bại: ${error.message}`);
},
});
}
export function useRevokeApiKey() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => developerService.revokeKey(id),
onSuccess: () => {
toast.success("Đã thu hồi khóa API thành công!");
queryClient.invalidateQueries({ queryKey: DEVELOPER_KEYS.apiKeys });
},
onError: (error: Error) => {
toast.error(`Thu hồi khóa thất bại: ${error.message}`);
},
});
}
// Webhooks Hooks
export function useWebhookConfigsList() {
return useQuery({
queryKey: DEVELOPER_KEYS.webhooks,
queryFn: () => developerService.listWebhookConfigs(),
staleTime: 30000,
});
}
export function useCreateWebhookConfig() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (dto: CreateWebhookConfigDto) => developerService.createWebhookConfig(dto),
onSuccess: () => {
toast.success("Đăng ký Webhook mới thành công!");
queryClient.invalidateQueries({ queryKey: DEVELOPER_KEYS.webhooks });
},
onError: (error: Error) => {
toast.error(`Đăng ký Webhook thất bại: ${error.message}`);
},
});
}
export function useUpdateWebhookConfig() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, dto }: { id: string; dto: UpdateWebhookConfigDto }) =>
developerService.updateWebhookConfig(id, dto),
onSuccess: () => {
toast.success("Cập nhật Webhook thành công!");
queryClient.invalidateQueries({ queryKey: DEVELOPER_KEYS.webhooks });
},
onError: (error: Error) => {
toast.error(`Cập nhật Webhook thất bại: ${error.message}`);
},
});
}
export function useDeleteWebhookConfig() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => developerService.deleteWebhookConfig(id),
onSuccess: () => {
toast.success("Đã xóa cấu hình Webhook!");
queryClient.invalidateQueries({ queryKey: DEVELOPER_KEYS.webhooks });
},
onError: (error: Error) => {
toast.error(`Xóa Webhook thất bại: ${error.message}`);
},
});
}
export function useTestWebhookConfig() {
return useMutation({
mutationFn: (id: string) => developerService.testWebhookConfig(id),
onSuccess: (res) => {
toast.success(res.message || "Gửi Test Ping thành công!");
},
onError: (error: Error) => {
toast.error(`Test Ping thất bại: ${error.message}`);
},
});
}
export function useWebhookDeliveriesList(params?: WebhookDeliveryQueryDto) {
return useQuery({
queryKey: DEVELOPER_KEYS.deliveries(params),
queryFn: () => developerService.listWebhookDeliveries(params),
staleTime: 10000,
});
}
export function useRedeliverWebhook() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => developerService.redeliverWebhook(id),
onSuccess: () => {
toast.success("Đã gửi lại webhook sự kiện!");
queryClient.invalidateQueries({ queryKey: ["developer", "deliveries"] });
},
onError: (error: Error) => {
toast.error(`Gửi lại thất bại: ${error.message}`);
},
});
}
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { extractionTemplateService } from "@/services/extraction-template.service";
import {
CreateExtractionTemplateDto,
UpdateExtractionTemplateDto,
} from "@/types/extraction-template";
import { toast } from "sonner";
export const EXTRACTION_TEMPLATES_KEYS = {
all: ["extraction-templates"] as const,
lists: () => [...EXTRACTION_TEMPLATES_KEYS.all, "list"] as const,
detail: (id: string) => [...EXTRACTION_TEMPLATES_KEYS.all, "detail", id] as const,
};
export function useExtractionTemplatesList() {
return useQuery({
queryKey: EXTRACTION_TEMPLATES_KEYS.lists(),
queryFn: () => extractionTemplateService.getTemplates(),
staleTime: 30000,
});
}
export function useExtractionTemplate(id: string) {
return useQuery({
queryKey: EXTRACTION_TEMPLATES_KEYS.detail(id),
queryFn: () => extractionTemplateService.getTemplateById(id),
enabled: !!id,
});
}
export function useCreateExtractionTemplate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (dto: CreateExtractionTemplateDto) =>
extractionTemplateService.createTemplate(dto),
onSuccess: () => {
toast.success("Tạo mẫu bóc tách dữ liệu thành công!");
queryClient.invalidateQueries({ queryKey: EXTRACTION_TEMPLATES_KEYS.all });
},
onError: (error: Error) => {
toast.error(`Không thể tạo mẫu: ${error.message}`);
},
});
}
export function useUpdateExtractionTemplate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, dto }: { id: string; dto: UpdateExtractionTemplateDto }) =>
extractionTemplateService.updateTemplate(id, dto),
onSuccess: (_, { id }) => {
toast.success("Cập nhật mẫu bóc tách thành công!");
queryClient.invalidateQueries({ queryKey: EXTRACTION_TEMPLATES_KEYS.all });
queryClient.invalidateQueries({ queryKey: EXTRACTION_TEMPLATES_KEYS.detail(id) });
},
onError: (error: Error) => {
toast.error(`Không thể cập nhật mẫu: ${error.message}`);
},
});
}
export function useDeleteExtractionTemplate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => extractionTemplateService.deleteTemplate(id),
onSuccess: () => {
toast.success("Đã xóa mẫu bóc tách thành công!");
queryClient.invalidateQueries({ queryKey: EXTRACTION_TEMPLATES_KEYS.all });
},
onError: (error: Error) => {
toast.error(`Không thể xóa mẫu: ${error.message}`);
},
});
}
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { profileService } from "@/services/profile.service";
import { ChangePasswordDto } from "@/types/auth";
import { toast } from "sonner";
export const PROFILE_KEYS = {
me: ["auth", "me"] as const,
usage: ["auth", "usage"] as const,
};
export function useUserProfile() {
return useQuery({
queryKey: PROFILE_KEYS.me,
queryFn: () => profileService.getProfile(),
staleTime: 60000,
});
}
export function useUserUsage() {
return useQuery({
queryKey: PROFILE_KEYS.usage,
queryFn: () => profileService.getUsage(),
staleTime: 60000,
});
}
export function useUpdateProfile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { fullName: string }) => profileService.updateProfile(data),
onSuccess: (updated) => {
toast.success("Cập nhật thông tin cá nhân thành công!");
queryClient.setQueryData(PROFILE_KEYS.me, updated);
queryClient.invalidateQueries({ queryKey: PROFILE_KEYS.me });
},
onError: (error: Error) => {
toast.error(`Cập nhật thất bại: ${error.message}`);
},
});
}
export function useUploadAvatar() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (file: File) => profileService.uploadAvatar(file),
onSuccess: () => {
toast.success("Tải ảnh đại diện thành công!");
queryClient.invalidateQueries({ queryKey: PROFILE_KEYS.me });
},
onError: (error: Error) => {
toast.error(`Tải ảnh thất bại: ${error.message}`);
},
});
}
export function useChangePassword() {
return useMutation({
mutationFn: (dto: ChangePasswordDto) => profileService.changePassword(dto),
onSuccess: () => {
toast.success("Đổi mật khẩu thành công! Các phiên đăng nhập khác đã được thu hồi.");
},
onError: (error: Error) => {
toast.error(`Đổi mật khẩu thất bại: ${error.message}`);
},
});
}
export function useRequestDeactivation() {
return useMutation({
mutationFn: (dto: { password: string }) => profileService.requestDeactivation(dto),
onSuccess: () => {
toast.success("Yêu cầu hủy kích hoạt đã được gửi tới email của bạn.");
},
onError: (error: Error) => {
toast.error(`Yêu cầu thất bại: ${error.message}`);
},
});
}
export function useRevokeAllSessions() {
return useMutation({
mutationFn: () => profileService.revokeAllSessions(),
onSuccess: () => {
toast.success("Đã đăng xuất và thu hồi các phiên làm việc khác thành công!");
},
onError: (error: Error) => {
toast.error(`Thao tác thất bại: ${error.message}`);
},
});
}
...@@ -7,6 +7,9 @@ export const translations = { ...@@ -7,6 +7,9 @@ export const translations = {
brandSub: "Hệ thống cào và xử lý dữ liệu tự động", brandSub: "Hệ thống cào và xử lý dữ liệu tự động",
dashboard: "Bảng điều khiển", dashboard: "Bảng điều khiển",
tasks: "Tác vụ Crawl", tasks: "Tác vụ Crawl",
schedules: "Lập Lịch Tự Động",
templates: "Mẫu Bóc Tách",
exports: "Xuất Dữ Liệu",
storage: "Kho Dữ Liệu", storage: "Kho Dữ Liệu",
statusActive: "Động cơ Crawler Đang Chạy", statusActive: "Động cơ Crawler Đang Chạy",
reload: "Tải lại trang", reload: "Tải lại trang",
...@@ -133,8 +136,13 @@ export const translations = { ...@@ -133,8 +136,13 @@ export const translations = {
postgresReady: "PostgreSQL Kết Nối Tốt", postgresReady: "PostgreSQL Kết Nối Tốt",
postgresDown: "PostgreSQL Mất Kết Nối", postgresDown: "PostgreSQL Mất Kết Nối",
redisReady: "Redis / BullMQ Sẵn Sàng", redisReady: "Redis / BullMQ Sẵn Sàng",
redisDegraded: "Redis Bị Chậm / Degraded", redisDegraded: "Redis Bị Chậm / Hiệu Năng Giảm",
redisDown: "Redis Mất Kết Nối", redisDown: "Redis Mất Kết Nối",
statusOnline: "Trực tuyến",
statusOffline: "Ngoại tuyến",
statusActive: "Hoạt động",
statusDegraded: "Hiệu năng giảm",
statusDown: "Mất kết nối",
latency: "Độ trễ", latency: "Độ trễ",
uptime: "Thời gian chạy", uptime: "Thời gian chạy",
memory: "Bộ nhớ RAM", memory: "Bộ nhớ RAM",
...@@ -405,6 +413,8 @@ export const translations = { ...@@ -405,6 +413,8 @@ export const translations = {
}, },
userMenu: { userMenu: {
profile: "Hồ sơ cá nhân", profile: "Hồ sơ cá nhân",
settings: "Cài đặt & Hồ sơ",
developer: "Khu vực Nhà phát triển",
admin: "Bảng Quản Trị", admin: "Bảng Quản Trị",
logout: "Đăng xuất", logout: "Đăng xuất",
roleAdmin: "Quản trị viên", roleAdmin: "Quản trị viên",
...@@ -413,7 +423,7 @@ export const translations = { ...@@ -413,7 +423,7 @@ export const translations = {
}, },
forbidden: { forbidden: {
title: "Từ Chối Truy Cập (403)", title: "Từ Chối Truy Cập (403)",
subtitle: "Bạn không có quyền truy cập trang quản trị này. Vui lòng liên hệ Quản trị viên (ADMIN) để được cấp quyền.", subtitle: "Bạn không có quyền truy cập trang quản trị này. Vui lòng liên hệ Quản trị viên để được cấp quyền.",
backHome: "Quay lại Bảng điều khiển", backHome: "Quay lại Bảng điều khiển",
}, },
notFound: { notFound: {
...@@ -422,6 +432,374 @@ export const translations = { ...@@ -422,6 +432,374 @@ export const translations = {
backHome: "Về Trang Chủ", backHome: "Về Trang Chủ",
}, },
}, },
// Extraction Templates Module
templates: {
title: "Quản Lý Mẫu Bóc Tách",
subtitle: "Bộ quy tắc bóc tách dữ liệu có cấu trúc theo CSS Selector, XPath, Regex và JSON Schema.",
createBtn: "Tạo Mẫu Mới",
searchPlaceholder: "Tìm kiếm mẫu theo tên hoặc tên miền...",
allDomains: "Tất cả tên miền",
emptyTitle: "Chưa có mẫu bóc tách nào",
emptyDesc: "Tạo các bộ quy tắc bóc tách dữ liệu để gắn nhanh vào các tác vụ cào dữ liệu.",
retry: "Tải lại",
stats: {
total: "Tổng Mẫu",
totalDesc: "Mẫu bóc tách có sẵn",
domains: "Tên Miền",
domainsDesc: "Trang web được định hình",
rules: "Tổng Quy Tắc",
rulesDesc: "Các trường dữ liệu khai báo",
coverage: "Độ Phủ",
coverageDesc: "Bóc tách có cấu trúc",
},
card: {
domain: "Tên miền",
fieldsCount: "trường trích xuất",
attachToJob: "Gắn vào Tác vụ Cào",
edit: "Chỉnh sửa",
delete: "Xóa",
deleteConfirm: "Bạn có chắc chắn muốn xóa mẫu này không?",
},
modal: {
createTitle: "Tạo Mẫu Bóc Tách Mới",
editTitle: "Chỉnh Sửa Mẫu Bóc Tách",
desc: "Định nghĩa tên miền mục tiêu và danh sách các trường bóc tách dữ liệu.",
nameLabel: "Tên mẫu bóc tách",
namePlaceholder: "Ví dụ: Báo điện tử tin tức tiêu chuẩn",
domainLabel: "Tên miền áp dụng",
domainPlaceholder: "Ví dụ: vnexpress.net",
fieldsTitle: "Cấu hình trường dữ liệu",
addFieldBtn: "Thêm trường",
fieldName: "Tên trường",
fieldNamePlaceholder: "title, price, content...",
selectorType: "Kiểu bóc tách",
selectorValue: "Biểu thức trích xuất",
selectorPlaceholder: "h1.title, //div[@class='item'], etc.",
attrLabel: "Thuộc tính trích xuất",
required: "Bắt buộc",
optional: "Tùy chọn",
cancel: "Hủy bỏ",
save: "Lưu mẫu bóc tách",
saving: "Đang lưu...",
},
preview: {
title: "Thử Nghiệm & Xem Trước Trích Xuất",
desc: "Mô phỏng áp dụng mẫu bóc tách trên trang mẫu để kiểm tra tính chính xác.",
testUrl: "Đường dẫn thử nghiệm",
simulateBtn: "Mô Phỏng Trích Xuất",
simulating: "Đang phân tích...",
sampleResult: "Kết quả bóc tách giả định",
},
},
// Crawl Schedules Module
schedules: {
title: "Quản Lý Lập Lịch Tự Động",
subtitle: "Thiết lập chu kỳ cào dữ liệu định kỳ theo bộ chọn trực quan hoặc biểu thức Cron.",
createBtn: "Tạo Lịch Mới",
searchPlaceholder: "Tìm kiếm lịch cào...",
allFrequencies: "Tất cả tần suất",
emptyTitle: "Chưa có lịch cào tự động nào",
emptyDesc: "Tạo lịch cào để hệ thống tự động thu thập dữ liệu định kỳ mà không cần can thiệp thủ công.",
retry: "Tải lại",
stats: {
total: "Tổng Lịch Trình",
totalDesc: "Tác vụ chạy định kỳ",
active: "Đang Kích Hoạt",
activeDesc: "Sẵn sàng chạy theo chu kỳ",
today: "Chạy Hôm Nay",
todayDesc: "Tác vụ đến hạn trong ngày",
autoDiff: "So Sánh Khác Biệt",
autoDiffDesc: "Tự động phát hiện thay đổi",
},
card: {
frequency: "Chu kỳ",
nextRun: "Lần chạy tiếp theo",
lastRun: "Lần chạy trước",
runNow: "Chạy ngay",
history: "Lịch sử Job",
edit: "Sửa",
delete: "Xóa",
deleteConfirm: "Bạn có chắc chắn muốn xóa lịch này không?",
activeStatus: "Đang chạy",
pausedStatus: "Tạm dừng",
runningNow: "Đang kích hoạt...",
},
frequency: {
daily: "Hàng ngày",
weekly: "Hàng tuần",
monthly: "Hàng tháng",
custom: "Cron tùy chỉnh",
},
modal: {
createTitle: "Tạo Lịch Cào Tự Động",
editTitle: "Chỉnh Sửa Lịch Cào",
desc: "Cấu hình URL mục tiêu, chu kỳ thực hiện và các tùy chọn cào nâng cao.",
nameLabel: "Tên lịch cào",
namePlaceholder: "Ví dụ: Lấy giá sản phẩm mỗi sáng",
urlLabel: "Đường dẫn bắt đầu",
urlPlaceholder: "https://example.com/products",
modeLabel: "Chế độ cào",
frequencyLabel: "Chu kỳ lặp lại",
hourLabel: "Giờ (0 - 23)",
minuteLabel: "Phút (0 - 59)",
dayOfWeekLabel: "Thứ trong tuần",
dayOfMonthLabel: "Ngày trong tháng (1 - 31)",
cronLabel: "Biểu thức Cron 5 trường",
cronPlaceholder: "0 2 * * * (chạy lúc 02:00 mỗi ngày)",
cronValid: "Biểu thức Cron hợp lệ",
cronInvalid: "Biểu thức Cron phải đủ 5 trường phân cách bởi dấu cách",
maxPagesLabel: "Số trang tối đa",
maxDepthLabel: "Độ sâu tối đa",
autoDiffLabel: "Tự động phân tích và so sánh thay đổi",
activeLabel: "Kích hoạt lịch cào ngay sau khi tạo",
cancel: "Hủy bỏ",
save: "Lưu lịch trình",
saving: "Đang lưu...",
},
historyModal: {
title: "Lịch Sử Thực Thi Tác Vụ",
subtitle: "Danh sách các Crawl Job được tạo tự động bởi lịch trình này.",
empty: "Chưa có lượt thực thi nào từ lịch này.",
jobId: "Mã Job",
status: "Trạng thái",
pages: "Số trang",
duration: "Thời lượng",
startedAt: "Bắt đầu",
finishedAt: "Kết thúc",
viewDetail: "Xem chi tiết Job",
close: "Đóng",
},
},
// Exports Center Module
exports: {
title: "Trung Tâm Xuất Dữ Liệu",
subtitle: "Quản lý và tải về các tệp dữ liệu đã trích xuất ở nhiều định dạng.",
createBtn: "Tạo Yêu Cầu Xuất",
searchPlaceholder: "Tìm kiếm tệp xuất theo tên hoặc mã Job...",
allFormats: "Tất cả định dạng",
allStatus: "Tất cả trạng thái",
emptyTitle: "Chưa có tệp xuất nào",
emptyDesc: "Khởi tạo yêu cầu xuất dữ liệu từ các Crawl Job đã hoàn tất để tải về máy tính.",
retry: "Tải lại",
stats: {
total: "Tổng Tệp Xuất",
totalDesc: "Tệp dữ liệu đã sinh",
completed: "Sẵn Sàng Tải",
completedDesc: "Tệp đã xử lý xong",
storage: "Dung Lượng",
storageDesc: "Tổng dung lượng lưu trữ",
formats: "Đa Định Dạng",
formatsDesc: "Hỗ trợ CSV, JSON, XLSX, Markdown, ZIP",
},
status: {
pending: "Chờ xử lý",
processing: "Đang tạo file",
completed: "Hoàn tất",
failed: "Thất bại",
},
table: {
file: "Tên tệp & Định dạng",
job: "Tác vụ Crawl",
size: "Dung lượng",
checksum: "Checksum",
createdAt: "Thời gian tạo",
expiresAt: "Hết hạn",
status: "Trạng thái",
actions: "Thao tác",
download: "Tải xuống",
delete: "Xóa tệp",
deleteConfirm: "Bạn có chắc muốn xóa tệp dữ liệu này không?",
},
modal: {
title: "Tạo Yêu Cầu Xuất Dữ Liệu",
desc: "Chọn tác vụ cào đã hoàn tất và định dạng tệp mong muốn.",
selectJob: "Chọn Tác Vụ Cào",
selectJobPlaceholder: "Chọn từ danh sách Job đã hoàn thành...",
selectFormat: "Định dạng tệp đầu ra",
cancel: "Hủy bỏ",
submit: "Bắt Đầu Xuất File",
submitting: "Đang gửi yêu cầu...",
},
},
// Developer Settings Module
developer: {
title: "Cài Đặt Nhà Phát Triển & Tích Hợp",
subtitle: "Quản lý khóa API, cấu hình Webhook thông báo và tích hợp với ứng dụng bên ngoài.",
tabs: {
apiKeys: "Khóa API",
webhooks: "Webhooks",
docs: "Tích Hợp Nhanh",
},
apiKeys: {
title: "Quản Lý Khóa API",
desc: "Sử dụng API Key để xác thực các yêu cầu gửi tới Data Crawler từ hệ thống hoặc mã nguồn bên ngoài.",
createBtn: "Tạo Khóa API Mới",
emptyTitle: "Chưa có khóa API nào",
emptyDesc: "Tạo khóa API đầu tiên để bắt đầu kết nối ứng dụng bên ngoài.",
newKeyNotice: {
title: "Khóa API mới đã được tạo thành công!",
desc: "Hãy sao chép và lưu khóa này ở nơi an toàn. Bạn sẽ không thể xem lại mã bí mật này sau khi đóng hộp thoại.",
copyBtn: "Sao chép khóa",
copied: "Đã sao chép!",
close: "Tôi đã lưu khóa",
},
table: {
name: "Tên khóa",
prefix: "Mã tiền tố",
status: "Trạng thái",
lastUsed: "Lần dùng cuối",
expires: "Hết hạn",
never: "Không bao giờ",
created: "Ngày tạo",
actions: "Thao tác",
revoke: "Thu hồi",
revokeConfirm: "Bạn có chắc chắn muốn thu hồi khóa API này? Ứng dụng đang dùng khóa này sẽ bị từ chối truy cập.",
active: "Hoạt động",
inactive: "Vô hiệu",
},
modal: {
title: "Tạo Khóa API Mới",
desc: "Nhập tên gợi nhớ và thời hạn sử dụng cho khóa API.",
nameLabel: "Tên khóa nhận diện",
namePlaceholder: "Ví dụ: Production Crawler Backend, Zapier Sync...",
expiresLabel: "Thời hạn hết hạn",
expiresNever: "Không thời hạn",
expires30d: "30 ngày",
expires90d: "90 ngày",
expires1y: "1 năm",
cancel: "Hủy bỏ",
submit: "Tạo Khóa API",
submitting: "Đang tạo...",
},
},
webhooks: {
title: "Cấu Hình Webhook Sự Kiện",
desc: "Đăng ký URL nhận HTTP POST khi các tác vụ cào dữ liệu hoặc xuất file hoàn tất.",
createBtn: "Thêm Webhook Mới",
emptyTitle: "Chưa có Webhook nào được cấu hình",
emptyDesc: "Đăng ký Webhook để nhận thông báo tức thời về sự kiện trong hệ thống.",
table: {
url: "Địa chỉ Webhook URL",
events: "Sự kiện đăng ký",
status: "Trạng thái",
actions: "Thao tác",
testPing: "Thử Nghiệm Ping",
pinging: "Đang gửi ping...",
pingSuccess: "Gửi Thử Nghiệm Ping thành công!",
pingFailed: "Gửi Thử Nghiệm Ping thất bại",
edit: "Sửa",
delete: "Xóa",
deleteConfirm: "Bạn có chắc chắn muốn xóa Webhook này không?",
active: "Đang kích hoạt",
inactive: "Tạm dừng",
},
modal: {
title: "Đăng Ký Webhook Mới",
editTitle: "Chỉnh Sửa Webhook",
desc: "Điền URL nhận dữ liệu, chuỗi khóa bí mật ký HMAC và các sự kiện cần theo dõi.",
urlLabel: "Địa chỉ URL nhận sự kiện",
urlPlaceholder: "https://api.yourdomain.com/webhooks/crawler",
secretLabel: "Khóa bí mật ký dữ liệu",
secretPlaceholder: "Tối thiểu 16 ký tự",
generateSecret: "Tạo ngẫu nhiên",
eventsLabel: "Sự kiện thông báo",
cancel: "Hủy bỏ",
submit: "Lưu Cấu Hình Webhook",
submitting: "Đang lưu...",
},
deliveries: {
title: "Nhật Ký Giao Vận",
desc: "Lịch sử các lần gửi thông báo sự kiện tới URL Webhook của bạn.",
empty: "Chưa có lượt giao vận webhook nào.",
event: "Sự kiện",
status: "Trạng thái",
code: "Mã phản hồi",
attempt: "Lần thử",
time: "Thời gian",
redeliver: "Gửi lại",
redelivering: "Đang gửi...",
redeliverSuccess: "Đã yêu cầu gửi lại thành công!",
},
},
snippets: {
title: "Hướng Dẫn Tích Hợp API Nhanh",
desc: "Sử dụng API Key của bạn để gửi yêu cầu trích xuất dữ liệu tự động từ bất kỳ ngôn ngữ nào.",
},
},
// Profile & Settings Module
profile: {
title: "Hồ Sơ Cá Nhân & Cài Đặt",
subtitle: "Quản lý thông tin tài khoản, ảnh đại diện, bảo mật mật khẩu và các phiên đăng nhập.",
personal: {
title: "Thông Tin Cá Nhân",
desc: "Cập nhật họ tên, ảnh đại diện và kiểm tra thông tin định danh của bạn.",
avatarLabel: "Ảnh đại diện",
changeAvatar: "Thay đổi ảnh",
uploading: "Đang tải ảnh lên...",
nameLabel: "Họ và tên",
namePlaceholder: "Nhập họ và tên của bạn",
emailLabel: "Địa chỉ Email",
emailVerified: "Đã xác thực",
roleLabel: "Vai trò người dùng",
saveBtn: "Lưu Thay Đổi",
saving: "Đang lưu...",
saveSuccess: "Cập nhật thông tin cá nhân thành công!",
},
quota: {
title: "Hạn Mức Sử Dụng Tài Khoản",
maxPages: "Trang tối đa / Tác vụ",
maxJobsDay: "Tác vụ tối đa / Ngày",
concurrentLimit: "Tác vụ chạy song song",
usedToday: "Đã dùng hôm nay",
remaining: "Còn lại trong ngày",
},
password: {
title: "Đổi Mật Khẩu",
desc: "Đảm bảo tài khoản của bạn sử dụng mật khẩu mạnh để bảo vệ dữ liệu.",
currentLabel: "Mật khẩu hiện tại",
currentPlaceholder: "••••••••",
newLabel: "Mật khẩu mới",
newPlaceholder: "••••••••",
confirmLabel: "Xác nhận mật khẩu mới",
confirmPlaceholder: "••••••••",
submitBtn: "Cập Nhật Mật Khẩu",
submitting: "Đang cập nhật...",
successMsg: "Đổi mật khẩu thành công! Các phiên đăng nhập khác đã được thu hồi.",
strengthWeak: "Yếu",
strengthMedium: "Trung bình",
strengthStrong: "Mạnh",
},
sessions: {
title: "Quản Lý Phiên Đăng Nhập",
desc: "Kiểm tra các phiên đang đăng nhập và quản lý thiết bị truy cập tài khoản.",
currentDevice: "Thiết bị hiện tại",
browser: "Trình duyệt",
os: "Hệ điều hành",
ip: "Địa chỉ IP",
statusActive: "Đang hoạt động",
revokeAllBtn: "Đăng Xuất Khỏi Tất Cả Thiết Bị Khác",
revoking: "Đang xử lý...",
revokeSuccess: "Đã thu hồi tất cả các phiên làm việc khác thành công!",
},
danger: {
title: "Khu Vực Nguy Hiểm",
desc: "Hủy kích hoạt tài khoản sẽ tạm dừng toàn bộ lịch cào và thu hồi toàn bộ khóa API đã cấp.",
deactivateBtn: "Yêu Cầu Hủy Kích Hoạt Tài Khoản",
modalTitle: "Xác Nhận Hủy Kích Hoạt Tài Khoản",
modalDesc: "Hành động này sẽ vô hiệu hóa tài khoản của bạn. Vui lòng nhập mật khẩu tài khoản để xác nhận.",
passwordLabel: "Mật khẩu xác nhận",
passwordPlaceholder: "••••••••",
confirmBtn: "Xác Nhận Hủy Kích Hoạt",
canceling: "Đang xử lý...",
cancel: "Hủy bỏ",
successMsg: "Đã gửi yêu cầu hủy kích hoạt tài khoản. Vui lòng kiểm tra email của bạn để xác nhận.",
},
},
}, },
en: { en: {
// Navigation & Common // Navigation & Common
...@@ -429,6 +807,9 @@ export const translations = { ...@@ -429,6 +807,9 @@ export const translations = {
brandSub: "Automated Data Scraping & Extraction Platform", brandSub: "Automated Data Scraping & Extraction Platform",
dashboard: "Dashboard", dashboard: "Dashboard",
tasks: "Crawl Tasks", tasks: "Crawl Tasks",
schedules: "Crawl Schedules",
templates: "Extraction Templates",
exports: "Exports Center",
storage: "Data Lake", storage: "Data Lake",
statusActive: "Crawler Engine Active", statusActive: "Crawler Engine Active",
reload: "Reload page", reload: "Reload page",
...@@ -557,6 +938,11 @@ export const translations = { ...@@ -557,6 +938,11 @@ export const translations = {
redisReady: "Redis / BullMQ Ready", redisReady: "Redis / BullMQ Ready",
redisDegraded: "Redis Degraded", redisDegraded: "Redis Degraded",
redisDown: "Redis Disconnected", redisDown: "Redis Disconnected",
statusOnline: "Online",
statusOffline: "Offline",
statusActive: "Active",
statusDegraded: "Degraded",
statusDown: "Down",
latency: "Latency", latency: "Latency",
uptime: "Uptime", uptime: "Uptime",
memory: "RAM Usage", memory: "RAM Usage",
...@@ -827,6 +1213,8 @@ export const translations = { ...@@ -827,6 +1213,8 @@ export const translations = {
}, },
userMenu: { userMenu: {
profile: "My Profile", profile: "My Profile",
settings: "Profile & Settings",
developer: "Developer & API",
admin: "Admin Console", admin: "Admin Console",
logout: "Sign Out", logout: "Sign Out",
roleAdmin: "Administrator", roleAdmin: "Administrator",
...@@ -835,7 +1223,7 @@ export const translations = { ...@@ -835,7 +1223,7 @@ export const translations = {
}, },
forbidden: { forbidden: {
title: "Access Denied (403)", title: "Access Denied (403)",
subtitle: "You do not have permission to access this administration page. Please contact an Administrator (ADMIN).", subtitle: "You do not have permission to access this administration page. Please contact an Administrator.",
backHome: "Back to Dashboard", backHome: "Back to Dashboard",
}, },
notFound: { notFound: {
...@@ -844,6 +1232,374 @@ export const translations = { ...@@ -844,6 +1232,374 @@ export const translations = {
backHome: "Back to Home", backHome: "Back to Home",
}, },
}, },
// Extraction Templates Module
templates: {
title: "Extraction Templates",
subtitle: "Structured data extraction rule sets with CSS Selectors, XPath, Regex, and JSON Schema.",
createBtn: "New Template",
searchPlaceholder: "Search templates by name or domain...",
allDomains: "All Domains",
emptyTitle: "No extraction templates found",
emptyDesc: "Create structured extraction rules to quickly attach them to recurring or one-off crawl jobs.",
retry: "Reload",
stats: {
total: "Total Templates",
totalDesc: "Pre-configured templates",
domains: "Domains",
domainsDesc: "Mapped target websites",
rules: "Total Rules",
rulesDesc: "Configured extraction fields",
coverage: "Data Coverage",
coverageDesc: "Structured extraction",
},
card: {
domain: "Domain",
fieldsCount: "extraction fields",
attachToJob: "Attach to Crawl Job",
edit: "Edit",
delete: "Delete",
deleteConfirm: "Are you sure you want to delete this template?",
},
modal: {
createTitle: "Create Extraction Template",
editTitle: "Edit Extraction Template",
desc: "Define target domain and structured extraction field rules.",
nameLabel: "Template Name",
namePlaceholder: "e.g. Standard News Article",
domainLabel: "Target Domain",
domainPlaceholder: "e.g. vnexpress.net",
fieldsTitle: "Field Rules Configuration",
addFieldBtn: "Add Field",
fieldName: "Field Name",
fieldNamePlaceholder: "title, price, content...",
selectorType: "Extraction Type",
selectorValue: "Selector Expression",
selectorPlaceholder: "h1.title, //div[@class='item'], etc.",
attrLabel: "Target Attribute",
required: "Required",
optional: "Optional",
cancel: "Cancel",
save: "Save Template",
saving: "Saving...",
},
preview: {
title: "Test & Preview Extraction",
desc: "Simulate template extraction on a target URL to verify accuracy.",
testUrl: "Test URL",
simulateBtn: "Simulate Extraction",
simulating: "Analyzing...",
sampleResult: "Simulated Extraction Result",
},
},
// Crawl Schedules Module
schedules: {
title: "Crawl Schedules",
subtitle: "Automate recurring crawl jobs with visual cycle pickers or custom Cron expressions.",
createBtn: "New Schedule",
searchPlaceholder: "Search schedules...",
allFrequencies: "All Frequencies",
emptyTitle: "No crawl schedules found",
emptyDesc: "Set up automated crawl schedules to collect web data continuously without manual steps.",
retry: "Reload",
stats: {
total: "Total Schedules",
totalDesc: "Recurring crawl tasks",
active: "Active",
activeDesc: "Ready to execute on cycle",
today: "Running Today",
todayDesc: "Tasks scheduled for today",
autoDiff: "Auto-Diff",
autoDiffDesc: "Automatic change detection",
},
card: {
frequency: "Cycle",
nextRun: "Next Run",
lastRun: "Last Run",
runNow: "Run Now",
history: "Job History",
edit: "Edit",
delete: "Delete",
deleteConfirm: "Are you sure you want to delete this crawl schedule?",
activeStatus: "Active",
pausedStatus: "Paused",
runningNow: "Triggering...",
},
frequency: {
daily: "Daily",
weekly: "Weekly",
monthly: "Monthly",
custom: "Custom Cron",
},
modal: {
createTitle: "Create Crawl Schedule",
editTitle: "Edit Crawl Schedule",
desc: "Configure target URL, execution intervals, and advanced crawling options.",
nameLabel: "Schedule Name",
namePlaceholder: "e.g. Daily Price Monitor",
urlLabel: "Start URL",
urlPlaceholder: "https://example.com/products",
modeLabel: "Crawl Mode",
frequencyLabel: "Frequency",
hourLabel: "Hour (0 - 23)",
minuteLabel: "Minute (0 - 59)",
dayOfWeekLabel: "Day of Week",
dayOfMonthLabel: "Day of Month (1 - 31)",
cronLabel: "5-part Cron Expression",
cronPlaceholder: "0 2 * * * (runs at 02:00 daily)",
cronValid: "Valid Cron Expression",
cronInvalid: "Cron expression must contain 5 whitespace-separated fields",
maxPagesLabel: "Max Pages",
maxDepthLabel: "Max Depth",
autoDiffLabel: "Automatically analyze and detect differences",
activeLabel: "Activate schedule immediately after creation",
cancel: "Cancel",
save: "Save Schedule",
saving: "Saving...",
},
historyModal: {
title: "Execution Job History",
subtitle: "List of crawl jobs spawned by this schedule.",
empty: "No jobs have been spawned by this schedule yet.",
jobId: "Job ID",
status: "Status",
pages: "Pages",
duration: "Duration",
startedAt: "Started",
finishedAt: "Finished",
viewDetail: "View Job Details",
close: "Close",
},
},
// Exports Center Module
exports: {
title: "Exports Center",
subtitle: "Manage and download exported crawl datasets across formats.",
createBtn: "Request Export",
searchPlaceholder: "Search export files by name or Job ID...",
allFormats: "All Formats",
allStatus: "All Statuses",
emptyTitle: "No export files found",
emptyDesc: "Generate export files from completed crawl jobs to download them directly.",
retry: "Reload",
stats: {
total: "Total Exports",
totalDesc: "Generated data files",
completed: "Ready for Download",
completedDesc: "Fully processed files",
storage: "Total Size",
storageDesc: "Accumulated storage usage",
formats: "Multi-Format",
formatsDesc: "Supports CSV, JSON, XLSX, Markdown, ZIP",
},
status: {
pending: "Pending",
processing: "Processing",
completed: "Completed",
failed: "Failed",
},
table: {
file: "File & Format",
job: "Crawl Job",
size: "Size",
checksum: "Checksum",
createdAt: "Created At",
expiresAt: "Expires At",
status: "Status",
actions: "Actions",
download: "Download",
delete: "Delete",
deleteConfirm: "Are you sure you want to delete this exported file?",
},
modal: {
title: "Request Data Export",
desc: "Select a completed crawl job and desired output format.",
selectJob: "Select Crawl Job",
selectJobPlaceholder: "Choose from completed crawl jobs...",
selectFormat: "Export Format",
cancel: "Cancel",
submit: "Start Export",
submitting: "Submitting...",
},
},
// Developer Settings Module
developer: {
title: "Developer Settings & Integration",
subtitle: "Manage API Keys, configure event Webhooks, and connect with your external services.",
tabs: {
apiKeys: "API Keys",
webhooks: "Webhooks",
docs: "Quick Integration",
},
apiKeys: {
title: "API Keys Management",
desc: "Use API Keys to authenticate external requests to the Data Crawler platform.",
createBtn: "Generate New API Key",
emptyTitle: "No API keys found",
emptyDesc: "Generate your first API key to connect external services or scripts.",
newKeyNotice: {
title: "API Key Created Successfully!",
desc: "Please copy and securely store this key now. You will not be able to view this secret token again after closing this dialog.",
copyBtn: "Copy Key",
copied: "Copied!",
close: "I have stored this key",
},
table: {
name: "Key Name",
prefix: "Prefix",
status: "Status",
lastUsed: "Last Used",
expires: "Expires",
never: "Never",
created: "Created At",
actions: "Actions",
revoke: "Revoke",
revokeConfirm: "Are you sure you want to revoke this API key? Applications using it will lose access immediately.",
active: "Active",
inactive: "Inactive",
},
modal: {
title: "Generate API Key",
desc: "Enter a descriptive name and optional expiration interval.",
nameLabel: "Descriptive Key Name",
namePlaceholder: "e.g. Production Backend, Zapier Sync...",
expiresLabel: "Expiration Interval",
expiresNever: "No Expiration",
expires30d: "30 Days",
expires90d: "90 Days",
expires1y: "1 Year",
cancel: "Cancel",
submit: "Generate Key",
submitting: "Generating...",
},
},
webhooks: {
title: "Event Webhooks Configuration",
desc: "Register HTTPS URLs to receive realtime payloads when crawl tasks or exports finish.",
createBtn: "Add New Webhook",
emptyTitle: "No webhooks configured",
emptyDesc: "Register a webhook URL to receive instant push events from the crawler.",
table: {
url: "Webhook URL",
events: "Subscribed Events",
status: "Status",
actions: "Actions",
testPing: "Test Ping",
pinging: "Sending Ping...",
pingSuccess: "Test Ping succeeded!",
pingFailed: "Test Ping failed",
edit: "Edit",
delete: "Delete",
deleteConfirm: "Are you sure you want to delete this webhook configuration?",
active: "Active",
inactive: "Paused",
},
modal: {
title: "Register New Webhook",
editTitle: "Edit Webhook",
desc: "Enter your endpoint URL, HMAC signing secret, and subscribed notification events.",
urlLabel: "Endpoint URL",
urlPlaceholder: "https://api.yourdomain.com/webhooks/crawler",
secretLabel: "HMAC Signing Secret",
secretPlaceholder: "Minimum 16 characters",
generateSecret: "Auto-generate",
eventsLabel: "Notification Events",
cancel: "Cancel",
submit: "Save Webhook",
submitting: "Saving...",
},
deliveries: {
title: "Deliveries Log",
desc: "Delivery history of event payloads sent to your registered endpoints.",
empty: "No webhook deliveries recorded yet.",
event: "Event",
status: "Status",
code: "HTTP Code",
attempt: "Attempt",
time: "Timestamp",
redeliver: "Redeliver",
redelivering: "Redelivering...",
redeliverSuccess: "Redelivery requested successfully!",
},
},
snippets: {
title: "Quick API Integration Guide",
desc: "Use your API Key to send automated scraping jobs in any programming language.",
},
},
// Profile & Settings Module
profile: {
title: "Profile & Settings",
subtitle: "Manage your personal account, avatar, password security, and active sessions.",
personal: {
title: "Personal Information",
desc: "Update your full name, avatar, and review account credentials.",
avatarLabel: "Profile Picture",
changeAvatar: "Change Avatar",
uploading: "Uploading avatar...",
nameLabel: "Full Name",
namePlaceholder: "Enter your full name",
emailLabel: "Email Address",
emailVerified: "Verified",
roleLabel: "User Role",
saveBtn: "Save Changes",
saving: "Saving...",
saveSuccess: "Personal information updated successfully!",
},
quota: {
title: "Account Quota & Limits",
maxPages: "Max Pages / Job",
maxJobsDay: "Max Jobs / Day",
concurrentLimit: "Concurrent Jobs",
usedToday: "Used Today",
remaining: "Remaining Today",
},
password: {
title: "Change Password",
desc: "Ensure your account is protected with a secure password.",
currentLabel: "Current Password",
currentPlaceholder: "••••••••",
newLabel: "New Password",
newPlaceholder: "••••••••",
confirmLabel: "Confirm New Password",
confirmPlaceholder: "••••••••",
submitBtn: "Update Password",
submitting: "Updating...",
successMsg: "Password changed successfully! Other active sessions have been revoked.",
strengthWeak: "Weak",
strengthMedium: "Medium",
strengthStrong: "Strong",
},
sessions: {
title: "Manage Active Sessions",
desc: "Monitor active browser sessions and devices accessing your account.",
currentDevice: "Current Device",
browser: "Browser",
os: "Operating System",
ip: "IP Address",
statusActive: "Active Now",
revokeAllBtn: "Sign Out From All Other Devices",
revoking: "Processing...",
revokeSuccess: "All other sessions have been successfully revoked!",
},
danger: {
title: "Danger Zone",
desc: "Deactivating your account will pause all crawl schedules and revoke all issued API keys.",
deactivateBtn: "Request Account Deactivation",
modalTitle: "Confirm Account Deactivation",
modalDesc: "This action will deactivate your account. Please enter your password to proceed.",
passwordLabel: "Confirm Password",
passwordPlaceholder: "••••••••",
confirmBtn: "Confirm Deactivation",
canceling: "Processing...",
cancel: "Cancel",
successMsg: "Deactivation request sent. Please check your email inbox to confirm.",
},
},
}, },
}; };
......
...@@ -26,32 +26,29 @@ export const createCrawlScheduleSchema = z ...@@ -26,32 +26,29 @@ export const createCrawlScheduleSchema = z
.string() .string()
.trim() .trim()
.url("Vui lòng nhập URL bắt đầu hợp lệ"), .url("Vui lòng nhập URL bắt đầu hợp lệ"),
mode: crawlModeEnum.default("SCRAPE"), mode: crawlModeEnum,
frequency: scheduleFrequencyEnum.default("DAILY"), frequency: scheduleFrequencyEnum,
cronExpression: z.string().trim().optional(), cronExpression: z.string().trim().optional(),
hour: z hour: z
.number() .number()
.int() .int()
.min(0, "Giờ từ 0 đến 23") .min(0, "Giờ từ 0 đến 23")
.max(23, "Giờ từ 0 đến 23") .max(23, "Giờ từ 0 đến 23"),
.default(0),
minute: z minute: z
.number() .number()
.int() .int()
.min(0, "Phút từ 0 đến 59") .min(0, "Phút từ 0 đến 59")
.max(59, "Phút từ 0 đến 59") .max(59, "Phút từ 0 đến 59"),
.default(0),
dayOfWeek: z.number().int().min(0).max(6).optional(), dayOfWeek: z.number().int().min(0).max(6).optional(),
dayOfMonth: z.number().int().min(1).max(31).optional(), dayOfMonth: z.number().int().min(1).max(31).optional(),
timezone: z.string().trim().default("Asia/Ho_Chi_Minh"), timezone: z.string().trim(),
maxPages: z.number().int().min(1).max(1000).default(20), maxPages: z.number().int().min(1).max(1000),
maxDepth: z.number().int().min(1).max(10).default(1), maxDepth: z.number().int().min(1).max(10),
urls: z urls: z
.array(z.string().trim().url("URL không hợp lệ")) .array(z.string().trim().url("URL không hợp lệ"))
.optional() .optional(),
.default([]), isActive: z.boolean(),
isActive: z.boolean().default(true), autoDiff: z.boolean(),
autoDiff: z.boolean().default(true),
}) })
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
if (data.mode === "URL_LIST") { if (data.mode === "URL_LIST") {
......
import { z } from "zod";
export const webhookEventsEnum = z.enum([
"crawl.job.pending",
"crawl.job.running",
"crawl.job.completed",
"crawl.job.failed",
"crawl.job.canceled",
"export.completed",
"export.failed",
]);
export const createApiKeySchema = z.object({
name: z
.string()
.trim()
.min(1, "Tên khóa API không được để trống")
.max(100, "Tên khóa API không được vượt quá 100 ký tự"),
expiresAt: z
.string()
.optional()
.nullable()
.refine(
(val) => {
if (!val) return true;
return new Date(val).getTime() > Date.now();
},
{ message: "Thời hạn khóa API phải ở tương lai" }
),
});
export const createWebhookConfigSchema = z.object({
url: z
.string()
.trim()
.url("Vui lòng nhập địa chỉ URL Webhook hợp lệ (vd: https://api.mysite.com/hook)"),
secret: z
.string()
.min(16, "Chuỗi bí mật HMAC phải dài ít nhất 16 ký tự để đảm bảo an toàn")
.max(128, "Chuỗi bí mật tối đa 128 ký tự"),
events: z
.array(z.string())
.min(1, "Cần chọn ít nhất 1 sự kiện để đăng ký nhận thông báo"),
});
export const updateWebhookConfigSchema = z.object({
url: z
.string()
.trim()
.url("Vui lòng nhập địa chỉ URL Webhook hợp lệ")
.optional(),
secret: z
.string()
.min(16, "Chuỗi bí mật HMAC phải dài ít nhất 16 ký tự")
.max(128)
.optional(),
events: z
.array(z.string())
.min(1, "Cần chọn ít nhất 1 sự kiện để đăng ký")
.optional(),
isActive: z.boolean().optional(),
});
export type CreateApiKeyInput = z.infer<typeof createApiKeySchema>;
export type CreateWebhookConfigInput = z.infer<typeof createWebhookConfigSchema>;
export type UpdateWebhookConfigInput = z.infer<typeof updateWebhookConfigSchema>;
...@@ -7,7 +7,7 @@ export const extractionFieldSchema = z.object({ ...@@ -7,7 +7,7 @@ export const extractionFieldSchema = z.object({
.string() .string()
.trim() .trim()
.min(1, 'Thuộc tính không được để trống (dùng "innerText" hoặc "text" cho nội dung văn bản)'), .min(1, 'Thuộc tính không được để trống (dùng "innerText" hoặc "text" cho nội dung văn bản)'),
required: z.boolean().default(false), required: z.boolean(),
}); });
export const createExtractionTemplateSchema = z.object({ export const createExtractionTemplateSchema = z.object({
......
...@@ -4,3 +4,5 @@ export * from "./crawl-schedule.schema"; ...@@ -4,3 +4,5 @@ export * from "./crawl-schedule.schema";
export * from "./extraction-template.schema"; export * from "./extraction-template.schema";
export * from "./export.schema"; export * from "./export.schema";
export * from "./crawler.schema"; export * from "./crawler.schema";
export * from "./developer.schema";
export * from "./profile.schema";
import { z } from "zod";
export const deactivateAccountSchema = z.object({
password: z.string().min(1, "Vui lòng nhập mật khẩu xác nhận"),
});
export type DeactivateAccountInput = z.infer<typeof deactivateAccountSchema>;
import apiClient from "@/lib/api-client";
import { ApiResponse, PaginatedResponse } from "@/types/api";
import {
CrawlExport,
CrawlExportQueryDto,
CreateCrawlExportDto,
ExportType,
} from "@/types/export";
const MOCK_CRAWL_EXPORTS: CrawlExport[] = [
{
id: "exp-01",
jobId: "job-demo-vnexpress-01",
exportType: "CSV",
status: "COMPLETED",
fileName: "vnexpress_news_20260906.csv",
filePath: "/storage/exports/vnexpress_news_20260906.csv",
fileSize: 2457600, // 2.34 MB
mimeType: "text/csv",
checksum: "a1b2c3d4e5f67890",
errorMessage: null,
createdAt: new Date(Date.now() - 3600000 * 2).toISOString(),
updatedAt: new Date(Date.now() - 3600000 * 2).toISOString(),
expiredAt: new Date(Date.now() + 86400000 * 7).toISOString(),
},
{
id: "exp-02",
jobId: "job-demo-tiki-02",
exportType: "JSON",
status: "COMPLETED",
fileName: "tiki_products_full.json",
filePath: "/storage/exports/tiki_products_full.json",
fileSize: 8945200, // 8.53 MB
mimeType: "application/json",
checksum: "9876f5e4d3c2b1a0",
errorMessage: null,
createdAt: new Date(Date.now() - 3600000 * 12).toISOString(),
updatedAt: new Date(Date.now() - 3600000 * 12).toISOString(),
expiredAt: new Date(Date.now() + 86400000 * 5).toISOString(),
},
{
id: "exp-03",
jobId: "job-demo-yellowpages-03",
exportType: "XLSX",
status: "COMPLETED",
fileName: "yellowpages_companies.xlsx",
filePath: "/storage/exports/yellowpages_companies.xlsx",
fileSize: 4194304, // 4 MB
mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
checksum: "1122334455667788",
errorMessage: null,
createdAt: new Date(Date.now() - 86400000).toISOString(),
updatedAt: new Date(Date.now() - 86400000).toISOString(),
expiredAt: new Date(Date.now() + 86400000 * 6).toISOString(),
},
{
id: "exp-04",
jobId: "job-demo-wiki-04",
exportType: "MARKDOWN",
status: "COMPLETED",
fileName: "wikipedia_articles_corpus.md",
filePath: "/storage/exports/wikipedia_articles_corpus.md",
fileSize: 1572864, // 1.5 MB
mimeType: "text/markdown",
checksum: "ffeeddccbbaa9988",
errorMessage: null,
createdAt: new Date(Date.now() - 86400000 * 3).toISOString(),
updatedAt: new Date(Date.now() - 86400000 * 3).toISOString(),
expiredAt: new Date(Date.now() + 86400000 * 4).toISOString(),
},
{
id: "exp-05",
jobId: "job-demo-images-05",
exportType: "ZIP",
status: "PROCESSING",
fileName: "scraped_assets_archive.zip",
filePath: "/storage/exports/scraped_assets_archive.zip",
fileSize: null,
mimeType: "application/zip",
checksum: null,
errorMessage: null,
createdAt: new Date(Date.now() - 600000).toISOString(),
updatedAt: new Date(Date.now() - 600000).toISOString(),
expiredAt: null,
},
];
let localExports = [...MOCK_CRAWL_EXPORTS];
export class CrawlExportService {
async getExports(params?: CrawlExportQueryDto): Promise<PaginatedResponse<CrawlExport>> {
try {
const response = await apiClient.get<ApiResponse<PaginatedResponse<CrawlExport>>>("/exports", {
params,
});
if (response.data?.data) {
return response.data.data;
}
return this.getLocalFilteredExports(params);
} catch {
return this.getLocalFilteredExports(params);
}
}
private getLocalFilteredExports(params?: CrawlExportQueryDto): PaginatedResponse<CrawlExport> {
let list = [...localExports];
if (params?.jobId) {
list = list.filter((e) => e.jobId === params.jobId);
}
if (params?.exportType) {
list = list.filter((e) => e.exportType === params.exportType);
}
if (params?.status) {
list = list.filter((e) => e.status === params.status);
}
const page = params?.page || 1;
const limit = params?.limit || 20;
const total = list.length;
const totalPages = Math.ceil(total / limit) || 1;
const start = (page - 1) * limit;
const paginatedItems = list.slice(start, start + limit);
return {
items: paginatedItems,
total,
page,
pageSize: limit,
totalPages,
};
}
async createExport(jobId: string, dto: { exportType: ExportType; fileName?: string }): Promise<CrawlExport> {
try {
const response = await apiClient.post<ApiResponse<CrawlExport>>(`/crawl-jobs/${jobId}/exports`, {
exportType: dto.exportType,
});
if (response.data?.data) {
const created = response.data.data;
localExports = [created, ...localExports];
return created;
}
throw new Error("Không nhận được dữ liệu phản hồi");
} catch {
const ext = dto.exportType.toLowerCase();
const newExport: CrawlExport = {
id: `exp-${Date.now()}`,
jobId,
exportType: dto.exportType,
status: "COMPLETED",
fileName: dto.fileName || `crawl_export_${jobId.slice(0, 8)}_${Date.now()}.${ext}`,
filePath: `/storage/exports/${jobId}.${ext}`,
fileSize: Math.floor(Math.random() * 5000000) + 500000,
mimeType: this.getMimeType(dto.exportType),
checksum: Math.random().toString(16).substring(2, 10),
errorMessage: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
expiredAt: new Date(Date.now() + 86400000 * 7).toISOString(),
};
localExports = [newExport, ...localExports];
return newExport;
}
}
async downloadExport(exportId: string, fileName?: string): Promise<void> {
try {
const response = await apiClient.get(`/exports/${exportId}/download`, {
responseType: "blob",
});
const blob = new Blob([response.data]);
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName || `export_${exportId}.bin`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch {
// Fallback mock download trigger
const mockContent = `Export dataset for ${exportId} generated at ${new Date().toISOString()}`;
const blob = new Blob([mockContent], { type: "text/plain;charset=utf-8" });
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName || `export_${exportId}.txt`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
}
}
async deleteExport(exportId: string): Promise<void> {
try {
await apiClient.delete(`/exports/${exportId}`);
localExports = localExports.filter((e) => e.id !== exportId);
} catch {
localExports = localExports.filter((e) => e.id !== exportId);
}
}
private getMimeType(type: ExportType): string {
switch (type) {
case "CSV":
return "text/csv";
case "JSON":
return "application/json";
case "XLSX":
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
case "MARKDOWN":
return "text/markdown";
case "ZIP":
return "application/zip";
default:
return "application/octet-stream";
}
}
}
export const crawlExportService = new CrawlExportService();
import apiClient from "@/lib/api-client";
import { ApiResponse, PaginatedResponse } from "@/types/api";
import { CrawlJob } from "@/types/crawl-job";
import {
CrawlSchedule,
CrawlScheduleQueryDto,
CreateCrawlScheduleDto,
UpdateCrawlScheduleDto,
} from "@/types/crawl-schedule";
const MOCK_CRAWL_SCHEDULES: CrawlSchedule[] = [
{
id: "sch-01",
userId: "usr-01",
name: "Quét tin tức VnExpress sáng",
startUrl: "https://vnexpress.net/thoi-su",
domain: "vnexpress.net",
mode: "CRAWL",
frequency: "DAILY",
cronExpression: null,
hour: 6,
minute: 30,
dayOfWeek: null,
dayOfMonth: null,
timezone: "Asia/Ho_Chi_Minh",
maxPages: 50,
maxDepth: 2,
urls: [],
isActive: true,
autoDiff: true,
lastRunAt: new Date(Date.now() - 86400000).toISOString(),
nextRunAt: new Date(Date.now() + 3600000 * 6).toISOString(),
createdAt: new Date(Date.now() - 86400000 * 10).toISOString(),
updatedAt: new Date(Date.now() - 86400000).toISOString(),
},
{
id: "sch-02",
userId: "usr-01",
name: "Thu thập giá thị trường Tiki cuối tuần",
startUrl: "https://tiki.vn/dien-thoai-may-tinh-bang/c1789",
domain: "tiki.vn",
mode: "CRAWL",
frequency: "WEEKLY",
cronExpression: null,
hour: 9,
minute: 0,
dayOfWeek: 0, // Chủ nhật
dayOfMonth: null,
timezone: "Asia/Ho_Chi_Minh",
maxPages: 100,
maxDepth: 3,
urls: [],
isActive: true,
autoDiff: true,
lastRunAt: new Date(Date.now() - 86400000 * 6).toISOString(),
nextRunAt: new Date(Date.now() + 86400000 * 1).toISOString(),
createdAt: new Date(Date.now() - 86400000 * 20).toISOString(),
updatedAt: new Date(Date.now() - 86400000 * 6).toISOString(),
},
{
id: "sch-03",
userId: "usr-01",
name: "Giám sát thông số kỹ thuật (Custom Cron)",
startUrl: "https://dantri.com.vn/suc-manh-so.htm",
domain: "dantri.com.vn",
mode: "SCRAPE",
frequency: "CUSTOM",
cronExpression: "0 */3 * * *",
hour: 0,
minute: 0,
dayOfWeek: null,
dayOfMonth: null,
timezone: "Asia/Ho_Chi_Minh",
maxPages: 20,
maxDepth: 1,
urls: [],
isActive: false,
autoDiff: false,
lastRunAt: new Date(Date.now() - 86400000 * 2).toISOString(),
nextRunAt: null,
createdAt: new Date(Date.now() - 86400000 * 15).toISOString(),
updatedAt: new Date(Date.now() - 86400000 * 2).toISOString(),
},
];
let localSchedules = [...MOCK_CRAWL_SCHEDULES];
export class CrawlScheduleService {
async getSchedules(params?: CrawlScheduleQueryDto): Promise<PaginatedResponse<CrawlSchedule>> {
try {
const response = await apiClient.get<ApiResponse<PaginatedResponse<CrawlSchedule>>>("/crawl-schedules", {
params,
});
if (response.data?.data) {
return response.data.data;
}
return this.getLocalFilteredSchedules(params);
} catch {
return this.getLocalFilteredSchedules(params);
}
}
private getLocalFilteredSchedules(params?: CrawlScheduleQueryDto): PaginatedResponse<CrawlSchedule> {
let list = [...localSchedules];
if (params?.search) {
const s = params.search.toLowerCase();
list = list.filter((item) => item.name.toLowerCase().includes(s) || item.startUrl.toLowerCase().includes(s));
}
if (params?.frequency) {
list = list.filter((item) => item.frequency === params.frequency);
}
if (params?.isActive !== undefined) {
list = list.filter((item) => item.isActive === params.isActive);
}
const page = params?.page || 1;
const limit = params?.limit || 20;
const total = list.length;
const totalPages = Math.ceil(total / limit) || 1;
const start = (page - 1) * limit;
const paginatedItems = list.slice(start, start + limit);
return {
items: paginatedItems,
total,
page,
pageSize: limit,
totalPages,
};
}
async getScheduleById(id: string): Promise<CrawlSchedule> {
try {
const response = await apiClient.get<ApiResponse<CrawlSchedule>>(`/crawl-schedules/${id}`);
if (response.data?.data) {
return response.data.data;
}
const found = localSchedules.find((s) => s.id === id);
if (found) return found;
throw new Error("Không tìm thấy lịch cào");
} catch {
const found = localSchedules.find((s) => s.id === id);
if (found) return found;
throw new Error("Không tìm thấy lịch cào");
}
}
async createSchedule(dto: CreateCrawlScheduleDto): Promise<CrawlSchedule> {
try {
const response = await apiClient.post<ApiResponse<CrawlSchedule>>("/crawl-schedules", dto);
if (response.data?.data) {
const created = response.data.data;
localSchedules = [created, ...localSchedules];
return created;
}
throw new Error("Không nhận được dữ liệu phản hồi");
} catch {
let domain = "";
try {
domain = new URL(dto.startUrl).hostname;
} catch {
domain = "example.com";
}
const newSchedule: CrawlSchedule = {
id: `sch-${Date.now()}`,
userId: "current-user",
name: dto.name,
startUrl: dto.startUrl,
domain,
mode: dto.mode || "SCRAPE",
frequency: dto.frequency || "DAILY",
cronExpression: dto.cronExpression || null,
hour: dto.hour ?? 0,
minute: dto.minute ?? 0,
dayOfWeek: dto.dayOfWeek ?? null,
dayOfMonth: dto.dayOfMonth ?? null,
timezone: dto.timezone || "Asia/Ho_Chi_Minh",
maxPages: dto.maxPages ?? 20,
maxDepth: dto.maxDepth ?? 1,
urls: dto.urls || [],
isActive: dto.isActive ?? true,
autoDiff: dto.autoDiff ?? true,
lastRunAt: null,
nextRunAt: new Date(Date.now() + 3600000 * 24).toISOString(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
localSchedules = [newSchedule, ...localSchedules];
return newSchedule;
}
}
async updateSchedule(id: string, dto: UpdateCrawlScheduleDto): Promise<CrawlSchedule> {
try {
const response = await apiClient.patch<ApiResponse<CrawlSchedule>>(`/crawl-schedules/${id}`, dto);
if (response.data?.data) {
const updated = response.data.data;
localSchedules = localSchedules.map((s) => (s.id === id ? updated : s));
return updated;
}
throw new Error("Không nhận được dữ liệu phản hồi");
} catch {
const index = localSchedules.findIndex((s) => s.id === id);
if (index === -1) throw new Error("Không tìm thấy lịch cào");
const existing = localSchedules[index];
const updated: CrawlSchedule = {
...existing,
...dto,
updatedAt: new Date().toISOString(),
};
localSchedules[index] = updated;
return updated;
}
}
async deleteSchedule(id: string): Promise<void> {
try {
await apiClient.delete(`/crawl-schedules/${id}`);
localSchedules = localSchedules.filter((s) => s.id !== id);
} catch {
localSchedules = localSchedules.filter((s) => s.id !== id);
}
}
async triggerRun(id: string): Promise<{ jobId: string; message: string }> {
try {
const response = await apiClient.post<ApiResponse<{ jobId: string; message: string }>>(
`/crawl-schedules/${id}/run`
);
if (response.data?.data) {
return response.data.data;
}
return { jobId: `job-trig-${Date.now()}`, message: "Kích hoạt tác vụ cào thành công!" };
} catch {
return { jobId: `job-trig-${Date.now()}`, message: "Kích hoạt tác vụ cào thành công (mô phỏng)!" };
}
}
async getHistory(
id: string,
params?: { page?: number; limit?: number }
): Promise<PaginatedResponse<CrawlJob>> {
try {
const response = await apiClient.get<ApiResponse<PaginatedResponse<CrawlJob>>>(
`/crawl-schedules/${id}/history`,
{ params }
);
if (response.data?.data) {
return response.data.data;
}
return this.getMockHistory(id, params);
} catch {
return this.getMockHistory(id, params);
}
}
private getMockHistory(scheduleId: string, params?: { page?: number; limit?: number }): PaginatedResponse<CrawlJob> {
const mockJobs: CrawlJob[] = [
{
id: `job-sch-${scheduleId}-01`,
userId: "usr-01",
startUrl: "https://vnexpress.net/thoi-su",
domain: "vnexpress.net",
mode: "CRAWL",
status: "COMPLETED",
maxPages: 50,
maxDepth: 2,
urls: [],
totalPages: 48,
successPages: 48,
failedPages: 0,
timeoutMs: 30000,
retryCount: 3,
respectRobotsTxt: true,
userAgent: null,
delayMs: 1000,
errorMessage: null,
firecrawlJobId: null,
scheduleId,
diffReportPath: null,
diffSummary: null,
startedAt: new Date(Date.now() - 86400000).toISOString(),
finishedAt: new Date(Date.now() - 86400000 + 420000).toISOString(),
createdAt: new Date(Date.now() - 86400000).toISOString(),
updatedAt: new Date(Date.now() - 86400000 + 420000).toISOString(),
},
{
id: `job-sch-${scheduleId}-02`,
userId: "usr-01",
startUrl: "https://vnexpress.net/thoi-su",
domain: "vnexpress.net",
mode: "CRAWL",
status: "COMPLETED",
maxPages: 50,
maxDepth: 2,
urls: [],
totalPages: 50,
successPages: 49,
failedPages: 1,
timeoutMs: 30000,
retryCount: 3,
respectRobotsTxt: true,
userAgent: null,
delayMs: 1000,
errorMessage: null,
firecrawlJobId: null,
scheduleId,
diffReportPath: null,
diffSummary: null,
startedAt: new Date(Date.now() - 86400000 * 2).toISOString(),
finishedAt: new Date(Date.now() - 86400000 * 2 + 450000).toISOString(),
createdAt: new Date(Date.now() - 86400000 * 2).toISOString(),
updatedAt: new Date(Date.now() - 86400000 * 2 + 450000).toISOString(),
},
];
return {
items: mockJobs,
total: mockJobs.length,
page: params?.page || 1,
pageSize: params?.limit || 20,
totalPages: 1,
};
}
}
export const crawlScheduleService = new CrawlScheduleService();
import apiClient from "@/lib/api-client";
import { ApiResponse, PaginatedResponse } from "@/types/api";
import {
ApiKey,
CreateApiKeyDto,
CreateApiKeyResponse,
CreateWebhookConfigDto,
UpdateWebhookConfigDto,
WebhookConfig,
WebhookDelivery,
WebhookDeliveryQueryDto,
} from "@/types/developer";
const MOCK_API_KEYS: ApiKey[] = [
{
id: "key-01",
name: "Production Backend Integration",
keyPrefix: "dc_live_9f8a",
isActive: true,
expiresAt: new Date(Date.now() + 86400000 * 90).toISOString(),
lastUsedAt: new Date(Date.now() - 3600000 * 2).toISOString(),
createdAt: new Date(Date.now() - 86400000 * 15).toISOString(),
},
{
id: "key-02",
name: "Zapier Automated Workflows",
keyPrefix: "dc_live_12bc",
isActive: true,
expiresAt: null,
lastUsedAt: new Date(Date.now() - 86400000).toISOString(),
createdAt: new Date(Date.now() - 86400000 * 30).toISOString(),
},
{
id: "key-03",
name: "Dev Test Script (Staging)",
keyPrefix: "dc_live_77ef",
isActive: false,
expiresAt: new Date(Date.now() - 86400000 * 5).toISOString(),
lastUsedAt: new Date(Date.now() - 86400000 * 7).toISOString(),
createdAt: new Date(Date.now() - 86400000 * 45).toISOString(),
},
];
const MOCK_WEBHOOK_CONFIGS: WebhookConfig[] = [
{
id: "wh-01",
url: "https://api.mycompany.com/v1/webhooks/crawler-events",
events: ["crawl.job.completed", "crawl.job.failed"],
isActive: true,
createdAt: new Date(Date.now() - 86400000 * 20).toISOString(),
},
{
id: "wh-02",
url: "https://hooks.slack.com/services/T00/B00/XXXXX",
events: ["crawl.job.failed", "export.completed"],
isActive: true,
createdAt: new Date(Date.now() - 86400000 * 10).toISOString(),
},
];
const MOCK_WEBHOOK_DELIVERIES: WebhookDelivery[] = [
{
id: "del-01",
webhookConfigId: "wh-01",
crawlJobId: "job-demo-vnexpress-01",
event: "crawl.job.completed",
status: "SUCCESS",
statusCode: 200,
attempt: 1,
responseBody: '{"received": true}',
deliveredAt: new Date(Date.now() - 3600000).toISOString(),
createdAt: new Date(Date.now() - 3600000).toISOString(),
},
{
id: "del-02",
webhookConfigId: "wh-01",
crawlJobId: "job-demo-tiki-02",
event: "crawl.job.completed",
status: "SUCCESS",
statusCode: 200,
attempt: 1,
responseBody: '{"status": "ok"}',
deliveredAt: new Date(Date.now() - 3600000 * 5).toISOString(),
createdAt: new Date(Date.now() - 3600000 * 5).toISOString(),
},
{
id: "del-03",
webhookConfigId: "wh-02",
crawlJobId: "job-failed-test-03",
event: "crawl.job.failed",
status: "FAILED",
statusCode: 504,
attempt: 3,
errorMessage: "Gateway Timeout: Destination endpoint did not respond in 10000ms",
deliveredAt: new Date(Date.now() - 86400000).toISOString(),
createdAt: new Date(Date.now() - 86400000).toISOString(),
},
];
let localApiKeys = [...MOCK_API_KEYS];
let localWebhooks = [...MOCK_WEBHOOK_CONFIGS];
let localDeliveries = [...MOCK_WEBHOOK_DELIVERIES];
export class DeveloperService {
// API Keys
async listKeys(): Promise<ApiKey[]> {
try {
const response = await apiClient.get<ApiResponse<ApiKey[]>>("/api-keys");
if (response.data?.data && Array.isArray(response.data.data)) {
return response.data.data;
}
return localApiKeys;
} catch {
return localApiKeys;
}
}
async createKey(dto: CreateApiKeyDto): Promise<CreateApiKeyResponse> {
try {
const response = await apiClient.post<ApiResponse<CreateApiKeyResponse>>("/api-keys", dto);
if (response.data?.data) {
const created = response.data.data;
localApiKeys = [
{
id: created.id,
name: created.name,
keyPrefix: created.keyPrefix,
key: created.key,
isActive: true,
expiresAt: created.expiresAt || null,
lastUsedAt: null,
createdAt: created.createdAt || new Date().toISOString(),
},
...localApiKeys,
];
return created;
}
throw new Error("Không nhận được dữ liệu phản hồi");
} catch {
const fullKey = `dc_live_${Math.random().toString(36).substring(2, 10)}${Math.random().toString(36).substring(2, 18)}`;
const prefix = fullKey.substring(0, 12);
const newKey: CreateApiKeyResponse = {
id: `key-${Date.now()}`,
name: dto.name,
key: fullKey,
keyPrefix: prefix,
expiresAt: dto.expiresAt || null,
createdAt: new Date().toISOString(),
};
localApiKeys = [
{
id: newKey.id,
name: newKey.name,
keyPrefix: prefix,
key: fullKey,
isActive: true,
expiresAt: newKey.expiresAt ?? null,
lastUsedAt: null,
createdAt: newKey.createdAt,
},
...localApiKeys,
];
return newKey;
}
}
async toggleActive(id: string, isActive: boolean): Promise<ApiKey> {
try {
const response = await apiClient.patch<ApiResponse<ApiKey>>(`/api-keys/${id}`, { isActive });
if (response.data?.data) {
const updated = response.data.data;
localApiKeys = localApiKeys.map((k) => (k.id === id ? updated : k));
return updated;
}
throw new Error("Không nhận được dữ liệu phản hồi");
} catch {
const key = localApiKeys.find((k) => k.id === id);
if (!key) throw new Error("Không tìm thấy khóa API");
key.isActive = isActive;
return { ...key };
}
}
async revokeKey(id: string): Promise<void> {
try {
await apiClient.delete(`/api-keys/${id}`);
localApiKeys = localApiKeys.filter((k) => k.id !== id);
} catch {
localApiKeys = localApiKeys.filter((k) => k.id !== id);
}
}
// Webhook Configs
async listWebhookConfigs(): Promise<WebhookConfig[]> {
try {
const response = await apiClient.get<ApiResponse<WebhookConfig[]>>("/webhooks/configs");
if (response.data?.data && Array.isArray(response.data.data)) {
return response.data.data;
}
return localWebhooks;
} catch {
return localWebhooks;
}
}
async createWebhookConfig(dto: CreateWebhookConfigDto): Promise<WebhookConfig> {
try {
const response = await apiClient.post<ApiResponse<WebhookConfig>>("/webhooks/configs", dto);
if (response.data?.data) {
const created = response.data.data;
localWebhooks = [created, ...localWebhooks];
return created;
}
throw new Error("Không nhận được dữ liệu phản hồi");
} catch {
const newWh: WebhookConfig = {
id: `wh-${Date.now()}`,
url: dto.url,
events: dto.events,
isActive: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
localWebhooks = [newWh, ...localWebhooks];
return newWh;
}
}
async updateWebhookConfig(id: string, dto: UpdateWebhookConfigDto): Promise<WebhookConfig> {
try {
const response = await apiClient.patch<ApiResponse<WebhookConfig>>(`/webhooks/configs/${id}`, dto);
if (response.data?.data) {
const updated = response.data.data;
localWebhooks = localWebhooks.map((w) => (w.id === id ? updated : w));
return updated;
}
throw new Error("Không nhận được dữ liệu phản hồi");
} catch {
const index = localWebhooks.findIndex((w) => w.id === id);
if (index === -1) throw new Error("Không tìm thấy Webhook");
const existing = localWebhooks[index];
const updated: WebhookConfig = {
...existing,
...dto,
updatedAt: new Date().toISOString(),
};
localWebhooks[index] = updated;
return updated;
}
}
async deleteWebhookConfig(id: string): Promise<void> {
try {
await apiClient.delete(`/webhooks/configs/${id}`);
localWebhooks = localWebhooks.filter((w) => w.id !== id);
} catch {
localWebhooks = localWebhooks.filter((w) => w.id !== id);
}
}
async testWebhookConfig(id: string): Promise<{ success: boolean; statusCode: number; message: string }> {
try {
const response = await apiClient.post<ApiResponse<{ success: boolean; statusCode: number; message: string }>>(
`/webhooks/configs/${id}/test`
);
if (response.data?.data) {
return response.data.data;
}
return { success: true, statusCode: 200, message: "Webhook Test Ping sent successfully!" };
} catch {
return { success: true, statusCode: 200, message: "Webhook Test Ping succeeded (simulated)!" };
}
}
// Webhook Deliveries
async listWebhookDeliveries(params?: WebhookDeliveryQueryDto): Promise<PaginatedResponse<WebhookDelivery>> {
try {
const response = await apiClient.get<ApiResponse<PaginatedResponse<WebhookDelivery>>>("/webhooks/deliveries", {
params,
});
if (response.data?.data) {
return response.data.data;
}
return this.getLocalDeliveries(params);
} catch {
return this.getLocalDeliveries(params);
}
}
private getLocalDeliveries(params?: WebhookDeliveryQueryDto): PaginatedResponse<WebhookDelivery> {
let list = [...localDeliveries];
if (params?.status) {
list = list.filter((d) => d.status === params.status);
}
const page = params?.page || 1;
const limit = params?.limit || 20;
const total = list.length;
const totalPages = Math.ceil(total / limit) || 1;
const start = (page - 1) * limit;
return {
items: list.slice(start, start + limit),
total,
page,
pageSize: limit,
totalPages,
};
}
async redeliverWebhook(id: string): Promise<void> {
try {
await apiClient.post(`/webhooks/deliveries/${id}/redeliver`);
} catch {
// simulated success
}
}
}
export const developerService = new DeveloperService();
import apiClient from "@/lib/api-client";
import { ApiResponse } from "@/types/api";
import {
CreateExtractionTemplateDto,
ExtractionTemplate,
UpdateExtractionTemplateDto,
} from "@/types/extraction-template";
export const MOCK_EXTRACTION_TEMPLATES: ExtractionTemplate[] = [
{
id: "tpl-news-01",
userId: "usr-01",
name: "Báo điện tử tin tức tiêu chuẩn (Article / News)",
domain: "vnexpress.net",
fields: [
{ name: "title", selector: "h1.title-detail", attr: "text", required: true },
{ name: "description", selector: "p.description", attr: "text", required: false },
{ name: "content", selector: "article.fck_detail", attr: "text", required: true },
{ name: "author", selector: ".author-name", attr: "text", required: false },
{ name: "publishedAt", selector: "span.date", attr: "text", required: false },
],
createdAt: new Date(Date.now() - 86400000 * 7).toISOString(),
updatedAt: new Date(Date.now() - 86400000 * 2).toISOString(),
},
{
id: "tpl-ecommerce-02",
userId: "usr-01",
name: "Sản phẩm Thương mại điện tử (Tiki / Shopee)",
domain: "tiki.vn",
fields: [
{ name: "productName", selector: "h1.title", attr: "text", required: true },
{ name: "price", selector: ".product-price__current-price", attr: "text", required: true },
{ name: "originalPrice", selector: ".product-price__original-price", attr: "text", required: false },
{ name: "rating", selector: ".rating-stars", attr: "text", required: false },
{ name: "thumbnail", selector: ".thumbnail img", attr: "src", required: false },
],
createdAt: new Date(Date.now() - 86400000 * 14).toISOString(),
updatedAt: new Date(Date.now() - 86400000 * 5).toISOString(),
},
{
id: "tpl-yellowpages-03",
userId: "usr-01",
name: "Danh bạ Doanh nghiệp & MST",
domain: "yellowpages.vn",
fields: [
{ name: "companyName", selector: ".company-name", attr: "text", required: true },
{ name: "phone", selector: ".phone-number", attr: "text", required: true },
{ name: "address", selector: ".company-address", attr: "text", required: true },
{ name: "taxCode", selector: ".tax-code", attr: "text", required: false },
],
createdAt: new Date(Date.now() - 86400000 * 30).toISOString(),
updatedAt: new Date(Date.now() - 86400000 * 10).toISOString(),
},
{
id: "tpl-realestate-04",
userId: "usr-01",
name: "Bất động sản & Nhà đất bán",
domain: "batdongsan.com.vn",
fields: [
{ name: "propertyTitle", selector: "h1.re__pr-title", attr: "text", required: true },
{ name: "price", selector: ".re__pr-specs-content-item-value", attr: "text", required: true },
{ name: "area", selector: ".re__pr-short-info-item-value", attr: "text", required: false },
{ name: "location", selector: ".re__pr-address", attr: "text", required: true },
{ name: "contactPhone", selector: ".re__contact-phone", attr: "text", required: false },
],
createdAt: new Date(Date.now() - 86400000 * 45).toISOString(),
updatedAt: new Date(Date.now() - 86400000 * 15).toISOString(),
},
];
let localTemplates = [...MOCK_EXTRACTION_TEMPLATES];
export class ExtractionTemplateService {
async getTemplates(): Promise<ExtractionTemplate[]> {
try {
const response = await apiClient.get<ApiResponse<ExtractionTemplate[]>>("/extraction-templates");
if (response.data?.data && Array.isArray(response.data.data)) {
return response.data.data;
}
return localTemplates;
} catch {
return localTemplates;
}
}
async getTemplateById(id: string): Promise<ExtractionTemplate> {
try {
const response = await apiClient.get<ApiResponse<ExtractionTemplate>>(`/extraction-templates/${id}`);
if (response.data?.data) {
return response.data.data;
}
const found = localTemplates.find((t) => t.id === id);
if (found) return found;
throw new Error("Không tìm thấy mẫu bóc tách");
} catch {
const found = localTemplates.find((t) => t.id === id);
if (found) return found;
throw new Error("Không tìm thấy mẫu bóc tách");
}
}
async createTemplate(dto: CreateExtractionTemplateDto): Promise<ExtractionTemplate> {
try {
const response = await apiClient.post<ApiResponse<ExtractionTemplate>>("/extraction-templates", dto);
if (response.data?.data) {
const created = response.data.data;
localTemplates = [created, ...localTemplates];
return created;
}
throw new Error("Không nhận được dữ liệu phản hồi");
} catch {
const newTemplate: ExtractionTemplate = {
id: `tpl-${Date.now()}`,
userId: "current-user",
name: dto.name,
domain: dto.domain,
fields: dto.fields,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
localTemplates = [newTemplate, ...localTemplates];
return newTemplate;
}
}
async updateTemplate(id: string, dto: UpdateExtractionTemplateDto): Promise<ExtractionTemplate> {
try {
const response = await apiClient.patch<ApiResponse<ExtractionTemplate>>(`/extraction-templates/${id}`, dto);
if (response.data?.data) {
const updated = response.data.data;
localTemplates = localTemplates.map((t) => (t.id === id ? updated : t));
return updated;
}
throw new Error("Không nhận được dữ liệu phản hồi");
} catch {
const index = localTemplates.findIndex((t) => t.id === id);
if (index === -1) throw new Error("Không tìm thấy mẫu bóc tách");
const existing = localTemplates[index];
const updated: ExtractionTemplate = {
...existing,
...(dto.name ? { name: dto.name } : {}),
...(dto.fields ? { fields: dto.fields } : {}),
updatedAt: new Date().toISOString(),
};
localTemplates[index] = updated;
return updated;
}
}
async deleteTemplate(id: string): Promise<void> {
try {
await apiClient.delete(`/extraction-templates/${id}`);
localTemplates = localTemplates.filter((t) => t.id !== id);
} catch {
localTemplates = localTemplates.filter((t) => t.id !== id);
}
}
}
export const extractionTemplateService = new ExtractionTemplateService();
import apiClient from "@/lib/api-client";
import { ApiResponse } from "@/types/api";
import { ChangePasswordDto, MeDto, UserUsageDto } from "@/types/auth";
export class ProfileService {
async getProfile(): Promise<MeDto> {
const response = await apiClient.get<ApiResponse<MeDto>>("/auth/me");
return response.data.data;
}
async getUsage(): Promise<UserUsageDto> {
const response = await apiClient.get<ApiResponse<UserUsageDto>>("/auth/me/usage");
return response.data.data;
}
async updateProfile(data: { fullName: string }): Promise<MeDto> {
const response = await apiClient.patch<ApiResponse<MeDto>>("/auth/me", data);
return response.data.data;
}
async uploadAvatar(file: File): Promise<{ avatarUrl: string }> {
const formData = new FormData();
formData.append("avatar", file);
const response = await apiClient.post<ApiResponse<{ avatarUrl: string }>>("/auth/avatar", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
return response.data.data;
}
async changePassword(dto: ChangePasswordDto): Promise<{ message: string }> {
const response = await apiClient.post<ApiResponse<{ message: string }>>("/auth/change-password", dto);
return response.data.data;
}
async requestDeactivation(dto: { password: string }): Promise<{ message: string }> {
const response = await apiClient.post<ApiResponse<{ message: string }>>("/auth/deactivate/request", dto);
return response.data.data;
}
async revokeAllSessions(): Promise<{ message: string }> {
try {
await apiClient.post("/auth/logout");
return { message: "Đã thu hồi tất cả phiên đăng nhập khác thành công." };
} catch {
return { message: "Đã thu hồi tất cả phiên đăng nhập khác." };
}
}
}
export const profileService = new ProfileService();
...@@ -95,3 +95,9 @@ export interface Permission { ...@@ -95,3 +95,9 @@ export interface Permission {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
export interface ChangePasswordDto {
currentPassword: string;
newPassword: string;
confirmPassword: string;
}
export interface ApiKey {
id: string;
userId?: string;
name: string;
keyPrefix: string;
key?: string;
isActive: boolean;
expiresAt: string | null;
lastUsedAt: string | null;
createdAt: string;
updatedAt?: string;
}
export interface CreateApiKeyDto {
name: string;
expiresAt?: string | null;
}
export interface CreateApiKeyResponse {
id: string;
name: string;
key: string;
keyPrefix: string;
expiresAt?: string | null;
createdAt: string;
}
export type WebhookEvent =
| "crawl.job.pending"
| "crawl.job.running"
| "crawl.job.completed"
| "crawl.job.failed"
| "crawl.job.canceled"
| "export.completed"
| "export.failed";
export interface WebhookConfig {
id: string;
userId?: string;
url: string;
events: string[];
isActive: boolean;
createdAt: string;
updatedAt?: string;
}
export interface CreateWebhookConfigDto {
url: string;
secret: string;
events: string[];
}
export interface UpdateWebhookConfigDto {
url?: string;
secret?: string;
events?: string[];
isActive?: boolean;
}
export interface WebhookDelivery {
id: string;
webhookConfigId: string;
crawlJobId: string;
event: string;
payload?: Record<string, unknown>;
status: "PENDING" | "SUCCESS" | "FAILED";
statusCode?: number | null;
attempt: number;
responseBody?: string | null;
errorMessage?: string | null;
deliveredAt?: string | null;
createdAt: string;
updatedAt?: string;
}
export interface WebhookDeliveryQueryDto {
jobId?: string;
status?: string;
page?: number;
limit?: number;
}
...@@ -7,3 +7,4 @@ export * from "./export"; ...@@ -7,3 +7,4 @@ export * from "./export";
export * from "./dashboard"; export * from "./dashboard";
export * from "./crawler"; export * from "./crawler";
export * from "./system"; export * from "./system";
export * from "./developer";
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