Commit cf38fc59 authored by ThinhNC's avatar ThinhNC

feat(fe): add client resilience, data viewers, quota UI & full bilingual i18n

parent c380673d
......@@ -19,6 +19,7 @@
"axios": "^1.20.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dompurify": "^3.4.15",
"lucide-react": "^1.41.0",
"next": "16.3.4",
"next-themes": "^0.4.6",
......@@ -31,6 +32,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/dompurify": "^3.2.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
......
......@@ -38,6 +38,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
dompurify:
specifier: ^3.4.15
version: 3.4.15
lucide-react:
specifier: ^1.41.0
version: 1.41.0(react@19.2.8)
......@@ -69,6 +72,9 @@ importers:
'@tailwindcss/postcss':
specifier: ^4
version: 4.3.3
'@types/dompurify':
specifier: ^3.2.0
version: 3.2.0
'@types/node':
specifier: ^20
version: 20.19.43
......@@ -995,6 +1001,10 @@ packages:
'@tybys/wasm-util@0.10.3':
resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
'@types/dompurify@3.2.0':
resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==}
deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
......@@ -1015,6 +1025,9 @@ packages:
'@types/react@19.2.18':
resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@typescript-eslint/eslint-plugin@8.69.0':
resolution: {integrity: sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
......@@ -1426,6 +1439,9 @@ packages:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
dompurify@3.4.15:
resolution: {integrity: sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
......@@ -3343,6 +3359,10 @@ snapshots:
tslib: 2.8.1
optional: true
'@types/dompurify@3.2.0':
dependencies:
dompurify: 3.4.15
'@types/estree@1.0.9': {}
'@types/json-schema@7.0.15': {}
......@@ -3361,6 +3381,9 @@ snapshots:
dependencies:
csstype: 3.2.3
'@types/trusted-types@2.0.7':
optional: true
'@typescript-eslint/eslint-plugin@8.69.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
......@@ -3779,6 +3802,10 @@ snapshots:
dependencies:
esutils: 2.0.3
dompurify@3.4.15:
optionalDependencies:
'@types/trusted-types': 2.0.7
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
......
"use client";
import React, { useState, useMemo, use } from "react";
import React, { useState, useMemo, use, useRef, useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
......@@ -24,38 +24,38 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { CreateExportModal } from "@/components/exports/create-export-modal";
import { SafeHtmlPreview } from "@/components/common/safe-html-preview";
import { JsonViewer } from "@/components/common/json-viewer";
import { CopyButton } from "@/components/common/copy-button";
import { SseStatusBadge } from "@/components/common/sse-status-badge";
import { NotificationToggleButton } from "@/components/common/notification-toggle-button";
import { notifyJobCompletion } from "@/lib/web-notifications";
import { isConnectionLossError, getLocalizedCrawlErrorInfo } from "@/lib/crawl-error-helper";
import { generateStandardFilename, downloadJsonFile } from "@/lib/download-helper";
import { formatDate, formatNumber } from "@/lib/utils";
import { useLanguage } from "@/providers/language-provider";
import {
Activity,
AlertCircle,
ArrowLeft,
CheckCircle2,
Clock,
Copy,
Download,
ExternalLink,
Eye,
FileCode,
FileText,
Filter,
Globe2,
Layers,
ListOrdered,
Loader2,
Pause,
Play,
Radio,
RefreshCw,
RotateCcw,
Search,
Settings2,
Shield,
ShieldCheck,
Sparkles,
Terminal,
Trash2,
TrendingDown,
TrendingUp,
XCircle,
} from "lucide-react";
import { toast } from "sonner";
......@@ -88,8 +88,9 @@ export default function CrawlJobDetailPage({ params }: PageProps) {
const [previewPage, setPreviewPage] = useState<CrawlPagePreview | null>(null);
const [previewTab, setPreviewTab] = useState<"markdown" | "cleanText" | "rawHtml" | "json">("markdown");
// Diff comparison target state
const [compareJobId, setCompareJobId] = useState<string>("");
const [compareJobId] = useState<string>("");
// Export modal state
const [isExportModalOpen, setIsExportModalOpen] = useState<boolean>(false);
......@@ -110,19 +111,74 @@ export default function CrawlJobDetailPage({ params }: PageProps) {
// Realtime Progress via SSE Hook: GET /api/proxy/crawl-jobs/:id/events
const [liveProgressJob, setLiveProgressJob] = useState<CrawlJob | null>(null);
const { status: sseStatus } = useEventSource<CrawlJob>(
const wasRunningRef = useRef<boolean>(false);
const hasNotifiedRef = useRef<boolean>(false);
const {
status: sseStatus,
retryCount: sseRetryCount,
nextRetryDelayMs: sseNextRetryDelayMs,
reconnect: reconnectSse,
} = useEventSource<CrawlJob>(
jobId ? `/api/proxy/crawl-jobs/${jobId}/events` : null,
{
enabled: Boolean(jobId),
onInitial: (data) => setLiveProgressJob(data),
onProgress: (data) => setLiveProgressJob(data),
onDone: () => refetchJob(),
onProgress: (data) => {
setLiveProgressJob(data);
if (data.status === "RUNNING" || data.status === "PROCESSING_EXPORT") {
wasRunningRef.current = true;
}
},
onDone: () => {
refetchJob();
if (job) {
notifyJobCompletion({
jobId: job.id,
successPages: job.successPages,
totalPages: job.totalPages,
domain: job.domain || undefined,
});
}
},
}
);
// Merged Job State (SSE takes live precedence if available)
const job = liveProgressJob || initialJob;
// Quota Exemption: Jobs failed due to connection loss are not counted towards usage quota
const isConnectionWaived =
job?.status === "FAILED" &&
(job?.successPages === 0 || !job?.successPages) &&
isConnectionLossError(job?.errorMessage);
// Localized error info for friendly bilingual explanation
const errorInfo = job?.errorMessage
? getLocalizedCrawlErrorInfo(job.errorMessage, locale)
: null;
// Background Web Notification when long-running job completes
useEffect(() => {
if (job?.status === "RUNNING" || job?.status === "PROCESSING_EXPORT") {
wasRunningRef.current = true;
} else if (
wasRunningRef.current &&
job?.status === "COMPLETED" &&
!hasNotifiedRef.current
) {
hasNotifiedRef.current = true;
notifyJobCompletion({
jobId: job.id,
successPages: job.successPages,
totalPages: job.totalPages,
domain: job.domain || undefined,
title: t.notifications.taskCompletedTitle,
bodyTemplate: t.notifications.taskCompletedBody,
});
}
}, [job?.status, job?.id, job?.successPages, job?.totalPages, job?.domain, t.notifications]);
// Query Logs (active when on logs tab)
const { data: logsData, isLoading: isLogsLoading, refetch: refetchLogs } = useCrawlJobLogs(
jobId,
......@@ -155,9 +211,10 @@ export default function CrawlJobDetailPage({ params }: PageProps) {
: 0;
// Filtered Logs
const logItems = logsData?.items;
const filteredLogs = useMemo(() => {
if (!logsData?.items) return [];
return logsData.items.filter((log) => {
if (!logItems) return [];
return logItems.filter((log) => {
const matchLevel = logLevelFilter === "ALL" || log.level === logLevelFilter;
const matchQuery =
!logSearchQuery ||
......@@ -165,13 +222,32 @@ export default function CrawlJobDetailPage({ params }: PageProps) {
log.step.toLowerCase().includes(logSearchQuery.toLowerCase());
return matchLevel && matchQuery;
});
}, [logsData?.items, logLevelFilter, logSearchQuery]);
}, [logItems, logLevelFilter, logSearchQuery]);
const copyToClipboard = (text: string, label?: string) => {
navigator.clipboard.writeText(text);
toast.success(label ? `${t.jobDetail.copiedNotice} (${label})` : t.jobDetail.copiedNotice);
};
const handleDownloadDiffReport = () => {
const filename = generateStandardFilename({
category: "diff",
identifier: jobId,
extension: "json",
});
const content = diffData || {
jobId,
exportedAt: new Date().toISOString(),
summary: {
newPages: 0,
modifiedPages: 0,
deletedPages: 0,
},
};
downloadJsonFile(content, filename);
toast.success("Bắt đầu tải xuống báo cáo biến động!");
};
if (isJobLoading && !job) {
return (
<div className="flex h-96 flex-col items-center justify-center gap-3 rounded-3xl border border-border/70 bg-card/40 p-8 text-center backdrop-blur-md">
......@@ -235,31 +311,53 @@ export default function CrawlJobDetailPage({ params }: PageProps) {
{job?.mode || "SCRAPE"}
</Badge>
{/* SSE Live Status Pill */}
<div
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-0.5 text-xs font-semibold border transition-all ${
sseStatus === "OPEN"
? "border-emerald-500/30 bg-emerald-500/15 text-emerald-600 dark:text-emerald-400"
: "border-border/80 bg-muted/60 text-muted-foreground"
}`}
{/* SSE Live Status Badge with 3-state Exponential Backoff: Green / Yellow / Red */}
<SseStatusBadge
status={sseStatus}
retryCount={sseRetryCount}
nextRetryDelayMs={sseNextRetryDelayMs}
onReconnect={reconnectSse}
/>
{/* Quota Exemption Badge if failed due to connection loss */}
{isConnectionWaived && (
<Badge
variant="outline"
className="border-emerald-500/40 bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 text-xs font-semibold px-2.5 py-1 gap-1"
title={t.jobDetail.quotaWaivedDesc}
>
<ShieldCheck className="h-3.5 w-3.5" />
<span>{t.jobDetail.quotaWaivedBadge}</span>
</Badge>
)}
</div>
<div className="flex items-center gap-1 max-w-md">
<a
href={job?.startUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400 hover:underline truncate"
>
<Radio className={`h-3 w-3 ${sseStatus === "OPEN" ? "animate-pulse text-emerald-500" : ""}`} />
<span>{sseStatus === "OPEN" ? t.jobDetail.liveSse : `SSE: ${sseStatus}`}</span>
</div>
<span>{job?.startUrl}</span>
<ExternalLink className="h-3 w-3 shrink-0" />
</a>
{job?.startUrl && (
<CopyButton
text={job.startUrl}
title="Sao chép URL bắt đầu"
showToast={true}
toastMessage="Đã sao chép URL bắt đầu!"
className="p-1 h-5 w-5 text-muted-foreground hover:text-foreground"
/>
)}
</div>
<a
href={job?.startUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400 hover:underline max-w-md truncate"
>
<span>{job?.startUrl}</span>
<ExternalLink className="h-3 w-3 shrink-0" />
</a>
</div>
{/* Action Group */}
<div className="flex flex-wrap items-center gap-2">
{/* Browser Notification Permission Toggle */}
<NotificationToggleButton />
{/* Export Data */}
<Button
size="sm"
......@@ -361,6 +459,61 @@ export default function CrawlJobDetailPage({ params }: PageProps) {
</div>
</div>
{/* Quota Exemption Alert Banner */}
{isConnectionWaived && (
<div className="rounded-3xl border border-emerald-500/30 bg-emerald-500/10 p-4 sm:p-5 text-xs backdrop-blur-md shadow-sm">
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-2xl bg-emerald-500/20 text-emerald-500">
<ShieldCheck className="h-4.5 w-4.5" />
</div>
<div className="space-y-1.5 min-w-0 flex-1">
<div className="font-bold text-foreground text-sm flex items-center gap-2">
<span>{t.jobDetail.quotaWaivedTitle}</span>
</div>
<p className="text-muted-foreground leading-relaxed">
{t.jobDetail.quotaWaivedDesc}
</p>
{job?.errorMessage && (
<div className="font-mono text-[11px] text-muted-foreground bg-background/60 rounded-xl px-3 py-1.5 mt-2 border border-border/50 inline-flex flex-wrap items-center gap-1.5">
<span className="text-rose-500 font-bold">{t.jobDetail.quotaReason}</span>
<span className="break-all">
{locale === "en" && errorInfo?.description
? errorInfo.description
: job.errorMessage}
</span>
</div>
)}
</div>
</div>
</div>
)}
{/* General Failure Alert (when job failed but not waived) */}
{!isConnectionWaived && job?.status === "FAILED" && (
<div className="rounded-3xl border border-rose-500/30 bg-rose-500/10 p-4 sm:p-5 text-xs backdrop-blur-md shadow-sm">
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-2xl bg-rose-500/20 text-rose-500">
<AlertCircle className="h-4.5 w-4.5" />
</div>
<div className="space-y-1.5 min-w-0 flex-1">
<div className="font-bold text-foreground text-sm flex items-center gap-2">
<span>{errorInfo?.title || t.status.failed}</span>
</div>
{job?.errorMessage && (
<div className="font-mono text-[11px] text-muted-foreground bg-background/60 rounded-xl px-3 py-1.5 mt-2 border border-border/50 inline-flex flex-wrap items-center gap-1.5">
<span className="text-rose-500 font-bold">{t.jobDetail.quotaReason}</span>
<span className="break-all">
{locale === "en" && errorInfo?.description
? errorInfo.description
: job.errorMessage}
</span>
</div>
)}
</div>
</div>
</div>
)}
{/* 2. Realtime SSE Progress Card - Biophilic Live Banner */}
<div className="relative overflow-hidden rounded-3xl border border-emerald-500/20 bg-gradient-to-br from-card via-card/95 to-emerald-950/15 p-6 shadow-sm backdrop-blur-md">
<div className="absolute right-0 top-0 -mt-10 -mr-10 h-48 w-48 rounded-full bg-emerald-500/10 blur-2xl pointer-events-none" />
......@@ -812,14 +965,14 @@ export default function CrawlJobDetailPage({ params }: PageProps) {
</div>
<div className="flex items-center gap-2">
<a
href={`/api/proxy/crawl-jobs/${jobId}/diff/download`}
download
className="inline-flex items-center gap-1.5 rounded-2xl bg-emerald-600 hover:bg-emerald-500 text-white px-3.5 py-1.5 text-xs font-bold shadow-sm transition-all"
<button
type="button"
onClick={handleDownloadDiffReport}
className="inline-flex items-center gap-1.5 rounded-2xl bg-emerald-600 hover:bg-emerald-500 text-white px-3.5 py-1.5 text-xs font-bold shadow-sm transition-all cursor-pointer"
>
<Download className="h-3.5 w-3.5" />
<span>{t.jobDetail.diff.downloadBtn}</span>
</a>
</button>
</div>
</div>
......@@ -1044,34 +1197,47 @@ export default function CrawlJobDetailPage({ params }: PageProps) {
)}
{previewTab === "rawHtml" && (
<div className="rounded-2xl bg-[#080d16] p-4 border border-slate-800 font-mono text-xs text-slate-300 overflow-x-auto">
<div className="text-[11px] text-slate-500 mb-2">{t.jobDetail.previewModal.htmlPathLabel}</div>
<div className="text-emerald-400 font-semibold mb-3">
{previewPage.htmlContentPath || t.jobDetail.previewModal.storedInLocal}
</div>
<div className="text-slate-400 italic">
&lt;!DOCTYPE html&gt;&#10;&lt;html&gt;&#10; &lt;head&gt;&lt;title&gt;{previewPage.title}&lt;/title&gt;&lt;/head&gt;&#10; &lt;body&gt;...{t.jobDetail.previewModal.extractedSnippet} {previewPage.wordCount} {t.jobDetail.wordsUnit}...&lt;/body&gt;&#10;&lt;/html&gt;
<div className="space-y-3">
<div className="rounded-2xl bg-card p-3 border border-border/60 flex flex-col sm:flex-row sm:items-center justify-between gap-2 text-xs">
<div className="min-w-0">
<span className="text-[11px] text-muted-foreground block">{t.jobDetail.previewModal.htmlPathLabel}:</span>
<span className="font-mono text-emerald-600 dark:text-emerald-400 font-semibold truncate block">
{previewPage.htmlContentPath || t.jobDetail.previewModal.storedInLocal}
</span>
</div>
</div>
<SafeHtmlPreview
title={`Crawled Page: ${previewPage.title || previewPage.url}`}
maxHeight="380px"
htmlContent={
previewPage.cleanText || previewPage.content
? `<h2>${previewPage.title || previewPage.url}</h2><p style="color:#64748b;font-size:12px;">URL: ${previewPage.url}</p><hr/><div style="margin-top:12px;">${previewPage.cleanText || previewPage.content}</div>`
: `<h3>${previewPage.title || "Crawled Page"}</h3><p>${t.jobDetail.previewModal.extractedSnippet} (${previewPage.wordCount} ${t.jobDetail.wordsUnit})</p>`
}
/>
</div>
)}
{previewTab === "json" && (
<pre className="rounded-2xl bg-[#080d16] p-4 border border-slate-800 font-mono text-xs text-cyan-300 overflow-x-auto whitespace-pre-wrap">
{JSON.stringify(
{
id: previewPage.id,
url: previewPage.url,
title: previewPage.title,
wordCount: previewPage.wordCount,
dataQualityScore: previewPage.dataQualityScore,
contentHash: previewPage.contentHash,
structuredData: previewPage.structuredData,
crawledAt: previewPage.crawledAt,
},
null,
2
)}
</pre>
<JsonViewer
data={{
id: previewPage.id,
url: previewPage.url,
title: previewPage.title,
wordCount: previewPage.wordCount,
dataQualityScore: previewPage.dataQualityScore,
contentHash: previewPage.contentHash,
structuredData: previewPage.structuredData,
crawledAt: previewPage.crawledAt,
}}
title={`Dữ Liệu Trang [${previewPage.title || previewPage.id}]`}
maxHeight="380px"
filename={generateStandardFilename({
category: "preview",
identifier: `page_${previewPage.id}`,
extension: "json",
})}
/>
)}
</div>
</DialogContent>
......
......@@ -11,6 +11,7 @@ import {
} from "@/hooks/use-developer";
import { ApiKey } from "@/types/developer";
import { CreateApiKeyModal } from "@/components/developer/create-api-key-modal";
import { CopyButton } from "@/components/common/copy-button";
export default function ApiKeysPage() {
const { t, locale } = useLanguage();
......@@ -92,8 +93,23 @@ export default function ApiKeysPage() {
<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 className="px-4 py-4">
<div className="flex items-center gap-2 font-mono text-xs">
<span className="text-muted-foreground/80 tracking-widest select-none">
••••••••••••
</span>
<span className="font-semibold text-emerald-600 dark:text-emerald-400">
{(key.keyPrefix || "").slice(-4) || "key"}
</span>
<CopyButton
text={key.key || `dc_live_••••••••••••${(key.keyPrefix || "").slice(-4) || "key"}`}
title={t.developer.apiKeys.table.copiedKey}
showToast={true}
toastMessage={t.developer.apiKeys.table.copiedKey}
className="p-1 rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
iconSize="h-3.5 w-3.5"
/>
</div>
</td>
<td className="px-4 py-4">
<button
......
"use client";
import React, { useState } from "react";
import { Terminal, Code2, Check, Copy } from "lucide-react";
import React from "react";
import { Terminal, Code2 } from "lucide-react";
import { useLanguage } from "@/providers/language-provider";
import { CopyButton } from "@/components/common/copy-button";
export default function DeveloperDocsPage() {
const { t } = useLanguage();
const [copiedSnippet, setCopiedSnippet] = useState<string | null>(null);
const handleCopySnippet = (snippet: string, key: string) => {
navigator.clipboard.writeText(snippet);
setCopiedSnippet(key);
setTimeout(() => setCopiedSnippet(null), 2000);
};
const curlSnippet = `curl -X POST "https://api.datacrawler.io/api/v1/crawl-jobs" \\
-H "X-API-Key: dc_live_your_secret_key_here" \\
......@@ -58,20 +52,17 @@ console.log('Job Created:', response.data);`;
<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>
<CopyButton
variant="badge"
text={curlSnippet}
label="Sao chép cURL"
copiedLabel="Đã sao chép!"
title="Sao chép lệnh cURL"
showToast={true}
toastMessage="Đã sao chép lệnh cURL vào bộ nhớ tạm!"
/>
</div>
<pre className="p-4 rounded-2xl bg-muted/60 font-mono text-xs text-foreground overflow-x-auto">
<pre className="p-4 rounded-2xl bg-[#070e17] border border-slate-800/80 font-mono text-xs text-emerald-400 overflow-x-auto selection:bg-emerald-500/30">
{curlSnippet}
</pre>
</div>
......@@ -83,20 +74,17 @@ console.log('Job Created:', response.data);`;
<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>
<CopyButton
variant="badge"
text={nodeSnippet}
label="Sao chép Node.js"
copiedLabel="Đã sao chép!"
title="Sao chép đoạn mã Node.js"
showToast={true}
toastMessage="Đã sao chép mã nguồn Node.js vào bộ nhớ tạm!"
/>
</div>
<pre className="p-4 rounded-2xl bg-muted/60 font-mono text-xs text-foreground overflow-x-auto">
<pre className="p-4 rounded-2xl bg-[#070e17] border border-slate-800/80 font-mono text-xs text-cyan-300 overflow-x-auto selection:bg-cyan-500/30">
{nodeSnippet}
</pre>
</div>
......
......@@ -8,9 +8,16 @@ import {
Trash2,
Send,
RefreshCw,
Eye,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useLanguage } from "@/providers/language-provider";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
useWebhookConfigsList,
useDeleteWebhookConfig,
......@@ -18,8 +25,10 @@ import {
useWebhookDeliveriesList,
useRedeliverWebhook,
} from "@/hooks/use-developer";
import { WebhookConfig } from "@/types/developer";
import { WebhookConfig, WebhookDelivery } from "@/types/developer";
import { CreateWebhookModal } from "@/components/developer/create-webhook-modal";
import { CopyButton } from "@/components/common/copy-button";
import { JsonViewer } from "@/components/common/json-viewer";
export default function WebhooksPage() {
const { t, locale } = useLanguage();
......@@ -31,6 +40,7 @@ export default function WebhooksPage() {
const [isWebhookModalOpen, setIsWebhookModalOpen] = useState(false);
const [editingWebhook, setEditingWebhook] = useState<WebhookConfig | null>(null);
const [selectedDelivery, setSelectedDelivery] = useState<WebhookDelivery | null>(null);
const deliveries = deliveriesData?.items || [];
......@@ -107,6 +117,13 @@ export default function WebhooksPage() {
<p className="font-mono text-xs font-bold text-foreground truncate">
{wh.url}
</p>
<CopyButton
text={wh.url}
title="Sao chép URL Webhook"
showToast={true}
toastMessage="Đã sao chép URL Webhook!"
className="p-1 h-6 w-6 text-muted-foreground hover:text-foreground"
/>
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold border ${
wh.isActive
......@@ -130,6 +147,28 @@ export default function WebhooksPage() {
</span>
))}
</div>
{/* Secret Masking */}
<div className="flex items-center gap-2 text-xs font-mono pt-1 text-muted-foreground">
<span className="text-[11px] font-sans font-medium text-muted-foreground/90">
{t.developer.webhooks.table.secretLabel}
</span>
<div className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-lg bg-muted/60 border border-border/50 text-xs">
<span className="text-muted-foreground/80 tracking-widest select-none">
••••••••••••
</span>
<span className="font-semibold text-emerald-600 dark:text-emerald-400">
{(wh.secret ? wh.secret.slice(-4) : wh.id.slice(-4))}
</span>
<CopyButton
text={wh.secret || `whsec_${wh.id.replace(/[^a-zA-Z0-9]/g, "")}`}
title={t.developer.webhooks.table.copiedSecret}
showToast={true}
toastMessage={t.developer.webhooks.table.copiedSecret}
className="p-1 h-6 w-6 text-muted-foreground hover:text-foreground ml-1"
/>
</div>
</div>
</div>
<div className="flex items-center gap-2 self-start lg:self-center">
......@@ -233,7 +272,17 @@ export default function WebhooksPage() {
}
)}
</td>
<td className="px-5 py-3.5 text-right">
<td className="px-5 py-3.5 text-right space-x-1">
<Button
size="sm"
variant="ghost"
onClick={() => setSelectedDelivery(del)}
className="rounded-xl h-7 px-2 text-[11px] text-muted-foreground hover:text-foreground hover:bg-muted cursor-pointer"
title="Xem chi tiết dữ liệu Webhook (JSON)"
>
<Eye className="h-3 w-3 mr-1 text-emerald-500" />
<span>Dữ liệu</span>
</Button>
<Button
size="sm"
variant="ghost"
......@@ -252,6 +301,62 @@ export default function WebhooksPage() {
)}
</div>
{/* Webhook Delivery Payload & Response Inspector Dialog */}
{selectedDelivery && (
<Dialog
open={!!selectedDelivery}
onOpenChange={(open) => !open && setSelectedDelivery(null)}
>
<DialogContent className="max-w-2xl bg-card border-emerald-500/25 p-6 rounded-3xl space-y-4">
<DialogHeader className="border-b border-border/60 pb-3">
<div className="flex items-center justify-between">
<DialogTitle className="text-base font-bold text-foreground flex items-center gap-2">
<Webhook className="h-4 w-4 text-emerald-500" />
<span>Chi Tiết Giao Vận: {selectedDelivery.event}</span>
</DialogTitle>
<span
className={`rounded-full px-2.5 py-0.5 text-[10px] font-semibold border ${
selectedDelivery.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"
}`}
>
{selectedDelivery.status} {selectedDelivery.statusCode ? `(${selectedDelivery.statusCode})` : ""}
</span>
</div>
</DialogHeader>
<div className="space-y-4 pt-1 max-h-[70vh] overflow-y-auto pr-1">
{/* Event Payload */}
<div className="space-y-2">
<JsonViewer
data={selectedDelivery.payload || {
event: selectedDelivery.event,
deliveryId: selectedDelivery.id,
crawlJobId: selectedDelivery.crawlJobId,
timestamp: selectedDelivery.createdAt,
status: selectedDelivery.status,
}}
title="Dữ Liệu Sự Kiện (Webhook Payload)"
filename={`webhook_payload_${selectedDelivery.id}.json`}
maxHeight="220px"
/>
</div>
{/* Response Body */}
<div className="space-y-2">
<JsonViewer
data={selectedDelivery.responseBody || { received: true, statusCode: selectedDelivery.statusCode || 200 }}
title="Phản Hồi Máy Chủ Nhận (HTTP Response Body)"
filename={`webhook_response_${selectedDelivery.id}.json`}
maxHeight="200px"
/>
</div>
</div>
</DialogContent>
</Dialog>
)}
{/* Modal */}
<CreateWebhookModal
isOpen={isWebhookModalOpen}
......
......@@ -75,14 +75,23 @@ export default function ProfileSettingsPage() {
// Keep state synced with user profile
React.useEffect(() => {
if (profile) {
if (profile.fullName) setFullName(profile.fullName);
if (profile.fullName !== undefined && profile.fullName !== null) {
setFullName(profile.fullName);
}
if (profile.avatarUrl) setAvatarPreview(profile.avatarUrl);
} else if (user) {
if (user.fullName) setFullName(user.fullName);
if (user.fullName !== undefined && user.fullName !== null) {
setFullName(user.fullName);
}
if (user.avatarUrl) setAvatarPreview(user.avatarUrl);
}
}, [profile, user]);
// Compute if personal profile has changes
const initialFullName = (profile?.fullName ?? user?.fullName ?? "").trim();
const isProfileChanged =
fullName.trim() !== initialFullName && fullName.trim().length > 0;
// Compute Password Strength
const passwordStrength = React.useMemo(() => {
if (!newPassword) return 0;
......@@ -113,10 +122,15 @@ export default function ProfileSettingsPage() {
// Update Profile Name Handler
const handleUpdateProfile = (e: React.FormEvent) => {
e.preventDefault();
if (!fullName.trim()) return;
if (!isProfileChanged || updateProfileMutation.isPending) return;
updateProfileMutation.mutate({ fullName: fullName.trim() });
};
// Revert Profile Name Handler
const handleCancelUpdateProfile = () => {
setFullName(profile?.fullName ?? user?.fullName ?? "");
};
// Change Password Handler
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault();
......@@ -351,11 +365,23 @@ export default function ProfileSettingsPage() {
</div>
</div>
<div className="flex justify-end pt-2">
<div className="flex items-center justify-end gap-2.5 pt-2">
{isProfileChanged && (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleCancelUpdateProfile}
disabled={updateProfileMutation.isPending}
className="rounded-2xl text-xs h-9 px-4 border-border/80 hover:bg-muted cursor-pointer"
>
{t.profile.personal.cancelBtn}
</Button>
)}
<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"
disabled={!isProfileChanged || updateProfileMutation.isPending || isLoadingProfile}
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 disabled:opacity-50 disabled:cursor-not-allowed"
>
{updateProfileMutation.isPending
? t.profile.personal.saving
......
"use client";
import React, { useState, useEffect } from "react";
import React, { useEffect } from "react";
import {
X,
Copy,
Check,
AlertTriangle,
Clock,
User,
Globe,
Monitor,
Shield,
FileCode,
ArrowRight,
Database,
Key,
Layers,
Sparkles,
} from "lucide-react";
import { Button } from "@/components/ui/button";
......@@ -29,8 +23,10 @@ import {
formatDate,
formatRelativeTime,
parseUserAgent,
cn,
} from "@/lib/utils";
import { JsonViewer } from "@/components/common/json-viewer";
import { CopyButton } from "@/components/common/copy-button";
import { generateStandardFilename } from "@/lib/download-helper";
interface AuditLogDetailDrawerProps {
log: AuditLogItem | null;
......@@ -44,9 +40,6 @@ export function AuditLogDetailDrawer({
onClose,
}: AuditLogDetailDrawerProps) {
const { t, locale } = useLanguage();
const [copiedPayload, setCopiedPayload] = useState(false);
const [copiedIp, setCopiedIp] = useState(false);
const [copiedUserId, setCopiedUserId] = useState(false);
// Handle ESC key to close
useEffect(() => {
......@@ -94,29 +87,6 @@ export function AuditLogDetailDrawer({
? details.updatedFields
: null;
// Copy JSON handler
const handleCopyPayload = () => {
navigator.clipboard.writeText(JSON.stringify(details, null, 2));
setCopiedPayload(true);
setTimeout(() => setCopiedPayload(false), 2000);
};
const handleCopyIp = () => {
if (log.ipAddress) {
navigator.clipboard.writeText(log.ipAddress);
setCopiedIp(true);
setTimeout(() => setCopiedIp(false), 2000);
}
};
const handleCopyUserId = () => {
if (log.userId) {
navigator.clipboard.writeText(log.userId);
setCopiedUserId(true);
setTimeout(() => setCopiedUserId(false), 2000);
}
};
const renderActionBadge = () => {
switch (category) {
case "CREATE":
......@@ -270,17 +240,14 @@ export function AuditLogDetailDrawer({
</span>
<div className="flex items-center gap-1.5 font-mono text-[11px] text-foreground">
<span className="truncate">{log.userId}</span>
<button
onClick={handleCopyUserId}
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
<CopyButton
text={log.userId}
title="Sao chép User ID"
>
{copiedUserId ? (
<Check className="h-3 w-3 text-emerald-500" />
) : (
<Copy className="h-3 w-3" />
)}
</button>
showToast={true}
toastMessage="Đã sao chép User ID!"
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
iconSize="h-3 w-3"
/>
</div>
</div>
)}
......@@ -294,17 +261,14 @@ export function AuditLogDetailDrawer({
<Globe className="h-3.5 w-3.5 text-emerald-500 shrink-0" />
<span>{log.ipAddress || "127.0.0.1"}</span>
{log.ipAddress && (
<button
onClick={handleCopyIp}
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
<CopyButton
text={log.ipAddress}
title="Sao chép IP"
>
{copiedIp ? (
<Check className="h-3 w-3 text-emerald-500" />
) : (
<Copy className="h-3 w-3" />
)}
</button>
showToast={true}
toastMessage="Đã sao chép IP!"
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
iconSize="h-3 w-3"
/>
)}
</div>
</div>
......@@ -361,11 +325,20 @@ export function AuditLogDetailDrawer({
<span className="h-2 w-2 rounded-full bg-red-500" />
{t.auditLogs.drawer.diffBefore}
</div>
<pre className="font-mono text-[11px] text-red-700 dark:text-red-300 overflow-x-auto whitespace-pre-wrap leading-relaxed">
{beforeData
? JSON.stringify(beforeData, null, 2)
: "(Không có giá trị ban đầu)"}
</pre>
{beforeData ? (
<JsonViewer
data={beforeData}
title={t.auditLogs.drawer.diffBefore}
showActions={false}
showLineNumbers={false}
maxHeight="180px"
className="bg-transparent border-0 shadow-none p-0 text-[11px]"
/>
) : (
<p className="font-mono text-[11px] text-red-700/80 dark:text-red-300/80 italic">
(Không có giá trị ban đầu)
</p>
)}
</div>
{/* After Column */}
......@@ -374,57 +347,38 @@ export function AuditLogDetailDrawer({
<span className="h-2 w-2 rounded-full bg-emerald-500" />
{t.auditLogs.drawer.diffAfter}
</div>
<pre className="font-mono text-[11px] text-emerald-700 dark:text-emerald-300 overflow-x-auto whitespace-pre-wrap leading-relaxed">
{afterData
? JSON.stringify(afterData, null, 2)
: "(Không có giá trị mới)"}
</pre>
{afterData ? (
<JsonViewer
data={afterData}
title={t.auditLogs.drawer.diffAfter}
showActions={false}
showLineNumbers={false}
maxHeight="180px"
className="bg-transparent border-0 shadow-none p-0 text-[11px]"
/>
) : (
<p className="font-mono text-[11px] text-emerald-700/80 dark:text-emerald-300/80 italic">
(Không có giá trị mới)
</p>
)}
</div>
</div>
</div>
)}
{/* Request Payload JSON Inspector */}
<div className="rounded-2xl border border-emerald-500/20 bg-card/60 p-5 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs font-bold text-foreground">
<FileCode className="h-4 w-4 text-emerald-500" />
<span>{t.auditLogs.drawer.payloadTitle}</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleCopyPayload}
className="h-7 px-2.5 rounded-xl text-xs text-muted-foreground hover:text-emerald-500 hover:bg-emerald-500/10 cursor-pointer transition-colors"
>
{copiedPayload ? (
<>
<Check className="h-3.5 w-3.5 mr-1 text-emerald-500" />
<span className="text-emerald-500 font-semibold">
{t.auditLogs.drawer.copied}
</span>
</>
) : (
<>
<Copy className="h-3.5 w-3.5 mr-1" />
<span>{t.auditLogs.drawer.copyJson}</span>
</>
)}
</Button>
</div>
{Object.keys(details).length > 0 ? (
<div className="relative rounded-xl border border-emerald-500/20 bg-[#061a14]/90 p-4 text-[#A8D5BA] overflow-x-auto shadow-inner">
<pre className="font-mono text-xs leading-relaxed">
{JSON.stringify(details, null, 2)}
</pre>
</div>
) : (
<p className="text-xs text-muted-foreground italic py-3 text-center">
{t.auditLogs.drawer.noPayload}
</p>
)}
<div className="space-y-2">
<JsonViewer
data={details}
title={t.auditLogs.drawer.payloadTitle}
filename={generateStandardFilename({
category: "audit",
identifier: log.id,
extension: "json",
})}
maxHeight="320px"
emptyMessage={t.auditLogs.drawer.noPayload}
/>
</div>
</div>
......
"use client";
import React, { useState, useEffect, useRef } from "react";
import { Copy, Check } from "lucide-react";
import { toast } from "sonner";
import { useLanguage } from "@/providers/language-provider";
import { cn } from "@/lib/utils";
export interface CopyButtonProps {
text: string;
label?: string;
copiedLabel?: string;
title?: string;
showToast?: boolean;
toastMessage?: string;
variant?: "icon" | "badge" | "button" | "ghost" | "inline";
className?: string;
iconSize?: string;
onCopy?: () => void;
disabled?: boolean;
}
/**
* Reusable CopyButton component with smooth checkmark transition (2s duration)
* and optional toast feedback. Complies with React 19 standards and provides
* automatic English / Vietnamese localization when props are omitted.
*/
export function CopyButton({
text,
label,
copiedLabel,
title,
showToast = false,
toastMessage,
variant = "icon",
className,
iconSize = "h-3.5 w-3.5",
onCopy,
disabled = false,
}: CopyButtonProps) {
const { t } = useLanguage();
const [isCopied, setIsCopied] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const finalCopiedLabel = copiedLabel ?? t.copyAction.copied;
const finalTitle = title ?? (isCopied ? finalCopiedLabel : t.copyAction.tooltip);
const finalToastMessage = toastMessage ?? t.copyAction.toastSuccess;
const defaultLabel = t.copyAction.copy;
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
const handleCopy = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (disabled || !text) return;
try {
await navigator.clipboard.writeText(text);
setIsCopied(true);
if (showToast) {
toast.success(finalToastMessage);
}
if (onCopy) {
onCopy();
}
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setIsCopied(false);
}, 2000);
} catch {
toast.error(t.copyAction.toastError);
}
};
const renderIcon = () => {
if (isCopied) {
return (
<Check
className={cn(
iconSize,
"text-emerald-500 animate-in zoom-in-50 duration-200"
)}
/>
);
}
return (
<Copy
className={cn(
iconSize,
"text-muted-foreground transition-colors group-hover:text-foreground"
)}
/>
);
};
if (variant === "badge") {
return (
<button
type="button"
onClick={handleCopy}
disabled={disabled}
title={finalTitle}
className={cn(
"group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-xl text-xs font-medium border transition-all cursor-pointer select-none",
isCopied
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "border-border/70 bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground",
disabled && "opacity-50 cursor-not-allowed",
className
)}
>
{renderIcon()}
<span>{isCopied ? finalCopiedLabel : label || defaultLabel}</span>
</button>
);
}
if (variant === "button") {
return (
<button
type="button"
onClick={handleCopy}
disabled={disabled}
title={finalTitle}
className={cn(
"group inline-flex items-center gap-1.5 h-8 px-3 rounded-xl text-xs font-medium border transition-all cursor-pointer select-none",
isCopied
? "border-emerald-500/40 bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 font-semibold shadow-sm shadow-emerald-500/10"
: "border-border bg-card/80 text-foreground hover:bg-muted/70",
disabled && "opacity-50 cursor-not-allowed",
className
)}
>
{renderIcon()}
<span>{isCopied ? finalCopiedLabel : label || defaultLabel}</span>
</button>
);
}
if (variant === "inline") {
return (
<button
type="button"
onClick={handleCopy}
disabled={disabled}
title={finalTitle}
className={cn(
"group inline-flex items-center gap-1 text-[11px] transition-colors cursor-pointer select-none",
isCopied
? "text-emerald-600 dark:text-emerald-400 font-semibold"
: "text-muted-foreground hover:text-foreground",
disabled && "opacity-50 cursor-not-allowed",
className
)}
>
{renderIcon()}
{(label || isCopied) && (
<span>{isCopied ? finalCopiedLabel : label}</span>
)}
</button>
);
}
// Default: "icon" or "ghost"
return (
<button
type="button"
onClick={handleCopy}
disabled={disabled}
title={finalTitle}
aria-label={label || finalTitle}
className={cn(
"group relative inline-flex items-center justify-center p-1.5 rounded-xl transition-all cursor-pointer select-none",
isCopied
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20"
: "text-muted-foreground hover:bg-muted hover:text-foreground border border-transparent",
disabled && "opacity-50 cursor-not-allowed",
className
)}
>
{renderIcon()}
{label && (
<span
className={cn(
"ml-1 text-xs transition-colors",
isCopied
? "text-emerald-600 dark:text-emerald-400 font-semibold"
: "text-muted-foreground group-hover:text-foreground"
)}
>
{isCopied ? finalCopiedLabel : label}
</span>
)}
</button>
);
}
"use client";
import React, { useState, useMemo } from "react";
import {
FileCode2,
Download,
Search,
Maximize2,
Minimize2,
ListOrdered,
Sparkles,
} from "lucide-react";
import { CopyButton } from "./copy-button";
import { downloadJsonFile } from "@/lib/download-helper";
import { useLanguage } from "@/providers/language-provider";
import { cn } from "@/lib/utils";
export interface JsonViewerProps {
data: unknown;
title?: string;
filename?: string;
maxHeight?: string;
showLineNumbers?: boolean;
showActions?: boolean;
className?: string;
emptyMessage?: string;
}
// Token types for syntax highlighting
type TokenType =
| "key"
| "string"
| "number"
| "boolean"
| "null"
| "punctuation"
| "whitespace"
| "other";
interface Token {
text: string;
type: TokenType;
}
function tokenizeLine(line: string): Token[] {
const tokens: Token[] = [];
// Tokenizer regex matching JSON elements
const tokenRegex =
/("(?:\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(?:\s*:)?|\b(?:true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?|[{}[\],:]|\s+|[^\s"{}[\]:,]+)/g;
let match: RegExpExecArray | null;
while ((match = tokenRegex.exec(line)) !== null) {
const raw = match[0];
if (/^\s+$/.test(raw)) {
tokens.push({ text: raw, type: "whitespace" });
} else if (raw.endsWith(":")) {
tokens.push({ text: raw.slice(0, -1), type: "key" });
tokens.push({ text: ":", type: "punctuation" });
} else if (raw.startsWith('"')) {
tokens.push({ text: raw, type: "string" });
} else if (raw === "true" || raw === "false") {
tokens.push({ text: raw, type: "boolean" });
} else if (raw === "null") {
tokens.push({ text: raw, type: "null" });
} else if (/^-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?$/.test(raw)) {
tokens.push({ text: raw, type: "number" });
} else if (/^[{}[\],:]$/.test(raw)) {
tokens.push({ text: raw, type: "punctuation" });
} else {
tokens.push({ text: raw, type: "other" });
}
}
return tokens;
}
export function JsonViewer({
data,
title,
filename = "data.json",
maxHeight = "340px",
showLineNumbers = true,
showActions = true,
className,
emptyMessage,
}: JsonViewerProps) {
const { t } = useLanguage();
const [isCompact, setIsCompact] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [enableLineNumbers, setEnableLineNumbers] = useState(showLineNumbers);
const displayTitle = title ?? t.jsonViewer.defaultTitle;
const displayEmptyMessage = emptyMessage ?? t.jsonViewer.emptyMessage;
// Parse and serialize JSON safely
const { jsonString, lines, dataSize, keyCount } = useMemo(() => {
if (data === undefined || data === null) {
return {
jsonString: "",
lines: [],
dataSize: "0 B",
keyCount: 0,
};
}
let parsed = data;
if (typeof data === "string") {
try {
parsed = JSON.parse(data);
} catch {
parsed = data;
}
}
let str = "";
try {
str = isCompact
? JSON.stringify(parsed)
: JSON.stringify(parsed, null, 2);
} catch {
str = String(parsed);
}
const splitLines = str.split("\n");
const bytes = new Blob([str]).size;
const sizeStr =
bytes < 1024
? `${bytes} B`
: `${(bytes / 1024).toFixed(1)} KB`;
let count = 0;
if (parsed && typeof parsed === "object") {
count = Array.isArray(parsed)
? parsed.length
: Object.keys(parsed).length;
}
return {
jsonString: str,
lines: splitLines,
dataSize: sizeStr,
keyCount: count,
};
}, [data, isCompact]);
const handleDownload = () => {
if (!jsonString) return;
downloadJsonFile(data, filename);
};
if (!jsonString || lines.length === 0) {
return (
<div
className={cn(
"rounded-2xl border border-dashed border-border/80 bg-muted/20 p-6 text-center text-xs text-muted-foreground italic",
className
)}
>
{displayEmptyMessage}
</div>
);
}
const itemsLabel = Array.isArray(data)
? t.jsonViewer.itemsCount.replace("{count}", String(keyCount))
: t.jsonViewer.propsCount.replace("{count}", String(keyCount));
return (
<div
className={cn(
"relative rounded-2xl border border-emerald-500/20 bg-[#070e17] text-slate-200 overflow-hidden shadow-xl shadow-black/20 flex flex-col font-mono",
className
)}
>
{/* Header Bar */}
{showActions && (
<div className="flex flex-wrap items-center justify-between gap-2 px-3.5 py-2.5 bg-[#0b1523] border-b border-emerald-500/15 text-xs select-none">
{/* Title & Metadata Badges */}
<div className="flex items-center gap-2 min-w-0">
<div className="flex h-6 w-6 items-center justify-center rounded-lg bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
<FileCode2 className="h-3.5 w-3.5" />
</div>
<span className="font-sans font-bold text-xs text-foreground truncate">
{displayTitle}
</span>
<div className="flex items-center gap-1.5 text-[10px]">
{keyCount > 0 && (
<span className="rounded-md bg-emerald-500/10 text-emerald-400 px-1.5 py-0.5 border border-emerald-500/20 font-semibold">
{itemsLabel}
</span>
)}
<span className="rounded-md bg-slate-800/80 text-slate-400 px-1.5 py-0.5 border border-slate-700/60 font-semibold">
{dataSize}
</span>
</div>
</div>
{/* Action Tools */}
<div className="flex items-center gap-1.5">
{/* Search Input Filter */}
<div className="relative hidden sm:flex items-center">
<Search className="absolute left-2 h-3 w-3 text-slate-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t.jsonViewer.searchPlaceholder}
className="h-6 w-36 pl-6 pr-2 rounded-lg bg-slate-900/90 border border-slate-700/60 text-[11px] text-slate-200 placeholder:text-slate-500 focus:outline-none focus:border-emerald-500/50 transition-colors"
/>
</div>
{/* Toggle Line Numbers */}
<button
type="button"
onClick={() => setEnableLineNumbers(!enableLineNumbers)}
title={enableLineNumbers ? t.jsonViewer.toggleLinesHide : t.jsonViewer.toggleLinesShow}
className={cn(
"p-1.5 rounded-lg border transition-colors cursor-pointer",
enableLineNumbers
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-400"
: "border-slate-800 bg-slate-900 text-slate-400 hover:text-slate-200"
)}
>
<ListOrdered className="h-3.5 w-3.5" />
</button>
{/* Toggle Compact / Formatted */}
<button
type="button"
onClick={() => setIsCompact(!isCompact)}
title={isCompact ? t.jsonViewer.toggleCompactOn : t.jsonViewer.toggleCompactOff}
className={cn(
"p-1.5 rounded-lg border transition-colors cursor-pointer",
isCompact
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-400"
: "border-slate-800 bg-slate-900 text-slate-400 hover:text-slate-200"
)}
>
{isCompact ? (
<Maximize2 className="h-3.5 w-3.5" />
) : (
<Minimize2 className="h-3.5 w-3.5" />
)}
</button>
{/* Download JSON Button */}
<button
type="button"
onClick={handleDownload}
title={t.jsonViewer.downloadTitle.replace("{filename}", filename)}
className="p-1.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-400 hover:text-emerald-400 hover:border-emerald-500/30 transition-colors cursor-pointer"
>
<Download className="h-3.5 w-3.5" />
</button>
{/* Copy Button */}
<CopyButton
text={jsonString}
variant="icon"
title={t.jsonViewer.copyAll}
showToast={true}
toastMessage={t.jsonViewer.copied}
className="h-6 w-6 p-0 rounded-lg border border-slate-800 bg-slate-900 text-slate-400 hover:text-foreground"
iconSize="h-3.5 w-3.5"
/>
</div>
</div>
)}
{/* Code Display Area */}
<div
style={{ maxHeight }}
className="overflow-auto p-3 text-xs leading-relaxed custom-scrollbar"
>
<pre className="m-0 font-mono">
{lines.map((line, idx) => {
const tokens = tokenizeLine(line);
const lineNum = idx + 1;
const isMatch =
searchQuery.trim() &&
line.toLowerCase().includes(searchQuery.toLowerCase());
return (
<div
key={lineNum}
className={cn(
"flex items-start hover:bg-white/[0.03] transition-colors rounded-sm px-1",
isMatch && "bg-emerald-500/20 border-l-2 border-emerald-500 pl-1"
)}
>
{/* Line Number Gutter */}
{enableLineNumbers && (
<span className="w-9 pr-3 select-none text-right text-[11px] text-slate-600 font-mono shrink-0">
{lineNum}
</span>
)}
{/* Tokenized Content */}
<span className="flex-1 whitespace-pre">
{tokens.map((token, tIdx) => {
if (token.type === "whitespace") {
return <span key={tIdx}>{token.text}</span>;
}
if (token.type === "key") {
return (
<span
key={tIdx}
className="text-cyan-300 dark:text-cyan-300 font-semibold"
>
{token.text}
</span>
);
}
if (token.type === "string") {
return (
<span
key={tIdx}
className="text-emerald-300 dark:text-emerald-300"
>
{token.text}
</span>
);
}
if (token.type === "number") {
return (
<span
key={tIdx}
className="text-amber-400 dark:text-amber-300 font-bold"
>
{token.text}
</span>
);
}
if (token.type === "boolean") {
return (
<span
key={tIdx}
className="text-purple-400 dark:text-purple-300 font-bold"
>
{token.text}
</span>
);
}
if (token.type === "null") {
return (
<span
key={tIdx}
className="text-rose-400 dark:text-rose-300 italic font-semibold"
>
{token.text}
</span>
);
}
if (token.type === "punctuation") {
return (
<span
key={tIdx}
className="text-slate-500 dark:text-slate-400 font-bold"
>
{token.text}
</span>
);
}
return <span key={tIdx}>{token.text}</span>;
})}
</span>
</div>
);
})}
</pre>
</div>
{/* Footer Meta */}
<div className="px-3.5 py-1.5 bg-[#09101a] border-t border-emerald-500/10 flex items-center justify-between text-[10px] text-slate-500">
<span className="flex items-center gap-1">
<Sparkles className="h-3 w-3 text-emerald-500" />
<span>{t.jsonViewer.footerBadge}</span>
</span>
<span>{t.jsonViewer.linesCount.replace("{count}", String(lines.length))}</span>
</div>
</div>
);
}
"use client";
import React, { useState } from "react";
import { WifiOff, RefreshCw, AlertTriangle } from "lucide-react";
import { useNetworkStatus } from "@/hooks/use-network-status";
import { useLanguage } from "@/providers/language-provider";
import { cn } from "@/lib/utils";
export interface NetworkStatusBannerProps {
className?: string;
message?: string;
}
/**
* Earth-orange alert banner indicating offline internet state.
* Floating at the top of the app to inform users that real-time and screen data might be outdated.
* Fully localized in English and Vietnamese.
*/
export function NetworkStatusBanner({
className,
message,
}: NetworkStatusBannerProps) {
const { t } = useLanguage();
const { isOnline } = useNetworkStatus({ showToasts: true });
const [isChecking, setIsChecking] = useState(false);
const displayMessage = message || t.network.offlineBanner;
if (isOnline) {
return null;
}
const handleManualCheck = () => {
setIsChecking(true);
setTimeout(() => {
setIsChecking(false);
}, 1000);
};
return (
<div
role="alert"
aria-live="assertive"
className={cn(
"sticky top-0 z-[9999] w-full bg-gradient-to-r from-amber-950 via-[#78350f] to-amber-950 text-amber-100 border-b border-amber-500/50 px-4 py-2 shadow-md shadow-black/30 backdrop-blur-md transition-all duration-300 animate-in slide-in-from-top-4",
className
)}
>
<div className="container mx-auto max-w-7xl flex flex-wrap items-center justify-between gap-3 text-xs sm:text-sm font-medium">
<div className="flex items-center gap-2.5 min-w-0">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-amber-500/20 text-amber-400 border border-amber-500/30">
<WifiOff className="h-3.5 w-3.5 animate-pulse" />
</div>
<span className="truncate">{displayMessage}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="hidden sm:inline-flex items-center gap-1 text-[11px] text-amber-300/80">
<AlertTriangle className="h-3 w-3 text-amber-400" />
<span>{t.network.offlineMode}</span>
</span>
<button
type="button"
onClick={handleManualCheck}
disabled={isChecking}
className="inline-flex items-center gap-1.5 rounded-lg border border-amber-500/40 bg-amber-900/60 px-2.5 py-1 text-xs font-semibold text-amber-100 hover:bg-amber-800 hover:border-amber-400/60 active:scale-95 transition-all cursor-pointer disabled:opacity-50"
>
<RefreshCw
className={cn("h-3 w-3", isChecking && "animate-spin")}
/>
<span>{isChecking ? t.network.checking : t.network.recheckBtn}</span>
</button>
</div>
</div>
</div>
);
}
"use client";
import React, { useState } from "react";
import { Bell, BellRing, BellOff } from "lucide-react";
import { toast } from "sonner";
import {
isNotificationSupported,
getNotificationPermission,
requestNotificationPermission,
} from "@/lib/web-notifications";
import { useLanguage } from "@/providers/language-provider";
import { cn } from "@/lib/utils";
export interface NotificationToggleButtonProps {
className?: string;
size?: "sm" | "default";
}
/**
* Toggle button enabling users to request and check browser notification permissions.
* Lets users receive desktop notifications when jobs complete while in another tab.
* Fully localized in English and Vietnamese.
*/
export function NotificationToggleButton({
className,
size = "sm",
}: NotificationToggleButtonProps) {
const { t } = useLanguage();
const [permission, setPermission] = useState<NotificationPermission | "unsupported">(() =>
getNotificationPermission()
);
const [isPending, setIsPending] = useState(false);
if (permission === "unsupported" || !isNotificationSupported()) {
return null;
}
const handleToggle = async () => {
if (permission === "granted") {
toast.info(t.notifications.activeNotice, {
description: t.notifications.activeNoticeDesc,
});
return;
}
if (permission === "denied") {
toast.error(t.notifications.blockedNotice, {
description: t.notifications.blockedNoticeDesc,
});
return;
}
setIsPending(true);
try {
const granted = await requestNotificationPermission();
setPermission(granted ? "granted" : "denied");
if (granted) {
toast.success(t.notifications.grantedNotice, {
description: t.notifications.grantedNoticeDesc,
});
} else {
toast.error(t.notifications.deniedNotice);
}
} finally {
setIsPending(false);
}
};
const isGranted = permission === "granted";
const isDenied = permission === "denied";
return (
<button
type="button"
onClick={handleToggle}
disabled={isPending}
title={
isGranted
? t.notifications.titleOn
: isDenied
? t.notifications.titleBlocked
: t.notifications.titleOff
}
className={cn(
"inline-flex items-center gap-1.5 rounded-2xl border text-xs font-semibold transition-all cursor-pointer select-none",
size === "sm" ? "px-2.5 py-1.5 h-8" : "px-3.5 py-2 h-9",
isGranted
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/20"
: isDenied
? "border-rose-500/30 bg-rose-500/10 text-rose-500 opacity-70"
: "border-border/80 bg-background/60 text-muted-foreground hover:text-foreground hover:bg-muted/50",
className
)}
>
{isGranted ? (
<BellRing className="h-3.5 w-3.5 text-emerald-500 animate-in zoom-in-50" />
) : isDenied ? (
<BellOff className="h-3.5 w-3.5 text-rose-500" />
) : (
<Bell className="h-3.5 w-3.5" />
)}
<span>{isGranted ? t.notifications.btnOn : t.notifications.btnOff}</span>
</button>
);
}
"use client";
import React, { useMemo } from "react";
import DOMPurify from "dompurify";
import { ShieldCheck } from "lucide-react";
interface SafeHtmlPreviewProps {
htmlContent: string;
title?: string;
className?: string;
maxHeight?: string;
}
/**
* SafeHtmlPreview Component
* Chống tấn công XSS đa tầng khi xem trước nội dung HTML cào được:
* 1. Tầng 1: Làm sạch mã độc qua DOMPurify (loại bỏ <script>, các sự kiện onerror, onload, onclick,...)
* 2. Tầng 2: Cách ly trong iframe sandbox="allow-same-origin" (TUYỆT ĐỐI KHÔNG có allow-scripts)
* 3. Tầng 3: Thiết lập Content-Security-Policy (CSP) nghiêm ngặt chặn tải script từ bên ngoài
*/
export function SafeHtmlPreview({
htmlContent,
title = "Safe Extracted HTML Preview",
className = "",
maxHeight = "420px",
}: SafeHtmlPreviewProps) {
// Tầng 1: Làm sạch HTML bằng DOMPurify
const sanitizedHtml = useMemo(() => {
if (!htmlContent) return "";
if (typeof window === "undefined") return htmlContent;
return DOMPurify.sanitize(htmlContent, {
USE_PROFILES: { html: true },
FORBID_TAGS: ["script", "iframe", "object", "embed", "applet", "base", "form"],
FORBID_ATTR: [
"onerror",
"onload",
"onclick",
"onmouseover",
"onfocus",
"onblur",
"onchange",
"onsubmit",
"onkeydown",
"onkeyup",
"formaction",
],
ALLOW_DATA_ATTR: false,
});
}, [htmlContent]);
// Tầng 2 & 3: Bọc vào trang tài liệu an toàn với CSP
const srcDoc = useMemo(() => {
return `<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src https: http: data:; font-src https: data:;">
<style>
:root {
color-scheme: light dark;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
body {
margin: 0;
padding: 16px;
font-size: 13px;
line-height: 1.6;
color: #1e293b;
background-color: #ffffff;
word-break: break-word;
}
@media (prefers-color-scheme: dark) {
body {
color: #e2e8f0;
background-color: #080d16;
}
a { color: #34d399; }
th, td { border-color: #334155; }
}
img { max-width: 100%; height: auto; border-radius: 8px; }
a { color: #059669; text-decoration: underline; pointer-events: none; }
table { width: 100%; border-collapse: collapse; margin: 12px 0; }
th, td { border: 1px solid #cbd5e1; padding: 8px; font-size: 12px; }
pre, code { font-family: monospace; background: rgba(0,0,0,0.06); padding: 2px 4px; border-radius: 4px; }
</style>
</head>
<body>
${sanitizedHtml || "<p style='color:#64748b;font-style:italic;'>Không có nội dung HTML để hiển thị.</p>"}
</body>
</html>`;
}, [sanitizedHtml]);
return (
<div className={`flex flex-col rounded-2xl border border-emerald-500/20 bg-card overflow-hidden ${className}`}>
<div className="flex items-center justify-between px-3.5 py-2 bg-muted/40 border-b border-border/60 text-[11px] font-medium text-muted-foreground">
<div className="flex items-center gap-1.5 text-emerald-600 dark:text-emerald-400 font-semibold">
<ShieldCheck className="h-3.5 w-3.5 shrink-0" />
<span>Cô lập bảo mật Sandbox &amp; DOMPurify (Chống XSS)</span>
</div>
<span className="text-[10px] text-muted-foreground font-mono">sandbox=&quot;allow-same-origin&quot;</span>
</div>
<iframe
title={title}
srcDoc={srcDoc}
sandbox="allow-same-origin"
className="w-full border-0 transition-all bg-background"
style={{ height: maxHeight }}
/>
</div>
);
}
"use client";
import React from "react";
import { Radio, RefreshCw, AlertCircle, RotateCcw } from "lucide-react";
import { SSEConnectionStatus } from "@/hooks/use-event-source";
import { useLanguage } from "@/providers/language-provider";
import { cn } from "@/lib/utils";
export interface SseStatusBadgeProps {
status: SSEConnectionStatus;
retryCount?: number;
nextRetryDelayMs?: number;
onReconnect?: () => void;
className?: string;
showReconnectButton?: boolean;
}
/**
* Visual badge displaying SSE stream connectivity status:
* - 🟢 Emerald: Live pulse (Đang phát trực tiếp / Live Stream)
* - 🟡 Amber: Spinning loader, backoff countdown (Đang kết nối lại / Reconnecting)
* - 🔴 Rose: Alert with optional manual retry (Mất kết nối / Disconnected)
*/
export function SseStatusBadge({
status,
retryCount = 0,
nextRetryDelayMs = 0,
onReconnect,
className,
showReconnectButton = true,
}: SseStatusBadgeProps) {
const { t } = useLanguage();
if (status === "OPEN") {
return (
<div
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-semibold border border-emerald-500/30 bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 shadow-sm shadow-emerald-500/10 transition-all select-none",
className
)}
title={t.sse.liveTitle}
>
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500" />
</span>
<Radio className="h-3 w-3 animate-pulse text-emerald-500" />
<span>{t.sse.live}</span>
</div>
);
}
if (status === "RECONNECTING" || status === "CONNECTING") {
const delaySec = nextRetryDelayMs > 0 ? Math.round(nextRetryDelayMs / 1000) : 0;
const retryText =
status === "CONNECTING" && retryCount === 0
? t.sse.connecting
: delaySec > 0
? t.sse.reconnectingSec.replace("{sec}", String(delaySec))
: t.sse.reconnectingAttempt.replace("{attempt}", String(retryCount));
return (
<div
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-semibold border border-amber-500/30 bg-amber-500/15 text-amber-600 dark:text-amber-400 shadow-sm shadow-amber-500/10 transition-all select-none",
className
)}
title={t.sse.reconnectingTitle.replace("{attempt}", String(retryCount))}
>
<RefreshCw className="h-3 w-3 animate-spin text-amber-500" />
<span>{retryText}</span>
</div>
);
}
// ERROR or closed unexpectedly
return (
<div
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-semibold border border-rose-500/30 bg-rose-500/15 text-rose-600 dark:text-rose-400 shadow-sm shadow-rose-500/10 transition-all select-none",
className
)}
title={t.sse.disconnectedTitle}
>
<AlertCircle className="h-3 w-3 text-rose-500" />
<span>{t.sse.disconnected}</span>
{showReconnectButton && onReconnect && (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onReconnect();
}}
className="ml-1 inline-flex items-center gap-0.5 rounded-full bg-rose-500/20 px-2 py-0.5 text-[11px] font-bold text-rose-700 dark:text-rose-300 hover:bg-rose-500/30 active:scale-95 transition-all cursor-pointer"
>
<RotateCcw className="h-2.5 w-2.5" />
<span>{t.sse.retryBtn}</span>
</button>
)}
</div>
);
}
......@@ -243,7 +243,7 @@ export function QuotaUsageWidget({ initialUsage, className }: QuotaUsageWidgetPr
: "bg-rose-500/15 text-rose-500"
)}
>
{usage.concurrentJobsAvailable} slots
{usage.concurrentJobsAvailable} {t.quota.slotsUnit}
</span>
</div>
......@@ -275,13 +275,19 @@ export function QuotaUsageWidget({ initialUsage, className }: QuotaUsageWidgetPr
? "bg-teal-500 shadow-sm shadow-teal-500/30"
: "bg-muted/70"
)}
title={isOccupied ? "Slot đang chạy" : "Slot khả dụng"}
title={isOccupied ? t.quota.slotRunning : t.quota.slotAvailable}
/>
);
})}
</div>
</div>
</div>
{/* Quota Exemption Policy Note */}
<div className="mt-4 flex items-center gap-2 rounded-2xl border border-emerald-500/20 bg-emerald-500/5 px-3 py-2 text-xs text-muted-foreground">
<Sparkles className="h-3.5 w-3.5 text-emerald-500 shrink-0" />
<span>{t.quota.exemptionNote}</span>
</div>
</div>
);
}
......@@ -4,11 +4,9 @@ import React, { useState, useRef, useEffect } from "react";
import {
X,
KeyRound,
Copy,
Check,
AlertTriangle,
Sparkles,
ChevronDown,
Check,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
......@@ -16,6 +14,7 @@ import { Label } from "@/components/ui/label";
import { useLanguage } from "@/providers/language-provider";
import { useCreateApiKey } from "@/hooks/use-developer";
import { CreateApiKeyResponse } from "@/types/developer";
import { CopyButton } from "@/components/common/copy-button";
interface CreateApiKeyModalProps {
isOpen: boolean;
......@@ -39,16 +38,23 @@ export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) {
const dropdownRef = useRef<HTMLDivElement>(null);
const [createdKeyData, setCreatedKeyData] = useState<CreateApiKeyResponse | null>(null);
const [isCopied, setIsCopied] = useState(false);
useEffect(() => {
const [prevIsOpen, setPrevIsOpen] = useState(isOpen);
if (isOpen !== prevIsOpen) {
setPrevIsOpen(isOpen);
if (!isOpen) {
setName("");
setExpirationChoice("NEVER");
setCreatedKeyData(null);
setIsCopied(false);
}
}, [isOpen]);
}
const handleClose = () => {
setName("");
setExpirationChoice("NEVER");
setCreatedKeyData(null);
onClose();
};
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
......@@ -62,13 +68,6 @@ export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) {
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;
......@@ -109,7 +108,7 @@ export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) {
</div>
</div>
<button
onClick={onClose}
onClick={handleClose}
className="rounded-xl p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
>
<X className="h-4 w-4" />
......@@ -137,23 +136,20 @@ export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) {
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}
<CopyButton
text={createdKeyData.key}
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>
title={t.developer.apiKeys.newKeyNotice.copyBtn}
showToast={true}
toastMessage="Đã sao chép khóa API vào bộ nhớ tạm!"
iconSize="h-4 w-4"
/>
</div>
</div>
<div className="flex justify-end pt-3 border-t border-border/60">
<Button
onClick={onClose}
onClick={handleClose}
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}
......
"use client";
import React, { useState, useEffect } from "react";
import React, { useState } from "react";
import {
X,
Webhook,
Sparkles,
Key,
Radio,
Check,
RefreshCw,
ShieldCheck,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
......@@ -19,6 +17,7 @@ import {
useUpdateWebhookConfig,
} from "@/hooks/use-developer";
import { WebhookConfig } from "@/types/developer";
import { CopyButton } from "@/components/common/copy-button";
interface CreateWebhookModalProps {
isOpen: boolean;
......@@ -34,6 +33,15 @@ const AVAILABLE_EVENTS = [
{ value: "export.failed", labelVi: "Tạo tệp xuất thất bại", labelEn: "Export File Failed" },
];
function generateSecretString(): string {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_!#*";
let res = "";
for (let i = 0; i < 32; i++) {
res += chars.charAt(Math.floor(Math.random() * chars.length));
}
return res;
}
export function CreateWebhookModal({
isOpen,
onClose,
......@@ -46,33 +54,33 @@ export function CreateWebhookModal({
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);
};
const [url, setUrl] = useState(initialData?.url || "");
const [secret, setSecret] = useState(initialData ? "" : generateSecretString());
const [selectedEvents, setSelectedEvents] = useState<string[]>(
initialData?.events || ["crawl.job.completed", "crawl.job.failed"]
);
const [createdSecretData, setCreatedSecretData] = useState<{ url: string; secret: string } | null>(null);
useEffect(() => {
// Sync state safely on open/close or target webhook change without useEffect
const [prevTracked, setPrevTracked] = useState({ isOpen, id: initialData?.id });
if (prevTracked.isOpen !== isOpen || prevTracked.id !== initialData?.id) {
setPrevTracked({ isOpen, id: initialData?.id });
if (initialData) {
setUrl(initialData.url);
setSelectedEvents(initialData.events || []);
setSecret(""); // Keep hidden for security on edit unless modified
setSecret("");
} else {
setUrl("");
setSelectedEvents(["crawl.job.completed", "crawl.job.failed"]);
generateRandomSecret();
setSecret(generateSecretString());
}
}, [initialData, isOpen]);
setCreatedSecretData(null);
}
const handleClose = () => {
setCreatedSecretData(null);
onClose();
};
if (!isOpen) return null;
......@@ -97,17 +105,21 @@ export function CreateWebhookModal({
secret: secret.trim() ? secret.trim() : undefined,
},
});
handleClose();
} else {
if (!secret.trim()) {
generateRandomSecret();
}
const activeSecret = secret.trim() || generateSecretString();
await createMutation.mutateAsync({
url: url.trim(),
secret: secret.trim(),
secret: activeSecret,
events: selectedEvents,
});
// Reveal secret once to user
setCreatedSecretData({ url: url.trim(), secret: activeSecret });
}
onClose();
};
const generateRandomSecret = () => {
setSecret(generateSecretString());
};
return (
......@@ -121,23 +133,72 @@ export function CreateWebhookModal({
</div>
<div>
<h2 className="text-base font-bold tracking-tight text-foreground">
{isEditing ? t.developer.webhooks.modal.editTitle : t.developer.webhooks.modal.title}
{createdSecretData
? t.developer.webhooks.modal.newSecretNotice.title
: isEditing
? t.developer.webhooks.modal.editTitle
: t.developer.webhooks.modal.title}
</h2>
<p className="text-xs text-muted-foreground">
{t.developer.webhooks.modal.desc}
{createdSecretData
? t.developer.webhooks.modal.newSecretNotice.desc
: t.developer.webhooks.modal.desc}
</p>
</div>
</div>
<button
onClick={onClose}
onClick={handleClose}
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">
{/* Content: Form or 1-Time Secret View */}
{createdSecretData ? (
<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">
<ShieldCheck className="h-4 w-4" />
<span>{t.developer.webhooks.modal.newSecretNotice.title}</span>
</div>
<p className="text-xs text-muted-foreground">
{t.developer.webhooks.modal.newSecretNotice.desc}
</p>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold">
{t.developer.webhooks.modal.secretLabel}
</Label>
<div className="relative flex items-center">
<Input
readOnly
value={createdSecretData.secret}
className="rounded-2xl pr-10 font-mono text-xs bg-muted/60 text-emerald-600 dark:text-emerald-400 font-bold select-all"
/>
<CopyButton
text={createdSecretData.secret}
className="absolute right-2 p-1.5 rounded-xl text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
title={t.developer.webhooks.modal.newSecretNotice.copyBtn}
showToast={true}
toastMessage={t.developer.webhooks.modal.newSecretNotice.copied}
iconSize="h-4 w-4"
/>
</div>
</div>
<div className="flex justify-end pt-3 border-t border-border/60">
<Button
onClick={handleClose}
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.webhooks.modal.newSecretNotice.close}
</Button>
</div>
</div>
) : (
<form onSubmit={handleSubmit} className="py-4 space-y-4">
{/* Endpoint URL */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold">
......@@ -219,7 +280,7 @@ export function CreateWebhookModal({
<Button
type="button"
variant="outline"
onClick={onClose}
onClick={handleClose}
className="rounded-2xl text-xs h-9 px-4 cursor-pointer"
>
{t.developer.webhooks.modal.cancel}
......@@ -235,6 +296,7 @@ export function CreateWebhookModal({
</Button>
</div>
</form>
)}
</div>
</div>
);
......
"use client";
import React, { useState } from "react";
import React, { useState, useMemo } from "react";
import {
X,
Play,
CheckCircle2,
AlertCircle,
ExternalLink,
Code2,
Sparkles,
Eye,
} 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";
import { SafeHtmlPreview } from "@/components/common/safe-html-preview";
import { JsonViewer } from "@/components/common/json-viewer";
import { generateStandardFilename } from "@/lib/download-helper";
interface TemplatePreviewModalProps {
isOpen: boolean;
......@@ -28,16 +30,46 @@ export function TemplatePreviewModal({
template,
}: TemplatePreviewModalProps) {
const { t } = useLanguage();
const [testUrl, setTestUrl] = useState("");
const [testUrl, setTestUrl] = useState(() =>
template ? `https://${template.domain}/sample-article-2026` : ""
);
const [prevTemplateId, setPrevTemplateId] = useState(template?.id);
const [isSimulating, setIsSimulating] = useState(false);
const [simulatedData, setSimulatedData] = useState<Record<string, unknown> | null>(null);
const [previewTab, setPreviewTab] = useState<"json" | "html">("html");
if (template?.id !== prevTemplateId) {
setPrevTemplateId(template?.id);
setTestUrl(`https://${template?.domain || "example.com"}/sample-article-2026`);
setSimulatedData(null);
}
React.useEffect(() => {
if (template) {
setTestUrl(`https://${template.domain}/sample-article-2026`);
setSimulatedData(null);
}
}, [template]);
// Construct simulated HTML content demonstrating XSS sanitization
const simulatedHtml = useMemo(() => {
if (!simulatedData || !template) return "";
const title = String(simulatedData.title || simulatedData.name || "Bài Viết Trích Xuất Mẫu");
const author = String(simulatedData.author || "Ban Biên Tập");
const content = String(simulatedData.content || simulatedData.description || "Nội dung dữ liệu bóc tách được từ trang đích...");
const price = simulatedData.price
? `<div style="color:#059669;font-weight:bold;font-size:16px;margin:8px 0;">${simulatedData.price}</div>`
: "";
return `
<article>
<header style="margin-bottom:12px;border-bottom:1px solid #e2e8f0;padding-bottom:8px;">
<h1 style="font-size:18px;margin:0 0 6px 0;color:#0f172a;">${title}</h1>
<p style="margin:0;font-size:12px;color:#64748b;">Nguồn: <strong>${template.domain}</strong> • Tác giả: <strong>${author}</strong></p>
</header>
${price}
<div style="font-size:13px;line-height:1.6;margin-top:10px;">
<p>${content}</p>
</div>
<footer style="margin-top:20px;padding:8px;background:rgba(16,185,129,0.08);border-radius:8px;border:1px solid rgba(16,185,129,0.2);font-size:11px;color:#047857;">
✓ Nội dung đã được bọc và làm sạch an toàn qua <strong>DOMPurify</strong> và cách ly trong <strong>Iframe Sandbox</strong> (ngăn chặn hoàn toàn mã thực thi độc hại).
</footer>
</article>
`;
}, [simulatedData, template]);
if (!isOpen || !template) return null;
......@@ -66,12 +98,12 @@ export function TemplatePreviewModal({
});
setSimulatedData(result);
setIsSimulating(false);
}, 600);
}, 500);
};
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">
<div className="relative w-full max-w-2xl 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">
......@@ -96,7 +128,7 @@ export function TemplatePreviewModal({
</div>
{/* Content */}
<div className="py-4 space-y-4">
<div className="py-4 space-y-4 max-h-[75vh] overflow-y-auto pr-1">
<div className="space-y-1.5">
<Label className="text-xs font-semibold">
{t.templates.preview.testUrl}
......@@ -136,21 +168,62 @@ export function TemplatePreviewModal({
</div>
</div>
{/* Simulated Output */}
{/* Simulated Output with Safe HTML / JSON Tabs */}
{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="p-4 rounded-2xl border border-emerald-500/20 bg-card space-y-3 animate-in fade-in-50 duration-200">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 pb-2 border-b border-border/60">
<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>
{/* Tab Switcher: Safe HTML vs JSON */}
<div className="flex items-center rounded-xl bg-muted/60 p-1 border border-border/70">
<button
type="button"
onClick={() => setPreviewTab("html")}
className={`flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium transition-all cursor-pointer ${
previewTab === "html"
? "bg-background text-emerald-600 dark:text-emerald-400 shadow-sm border border-emerald-500/20"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Eye className="h-3 w-3" />
<span>{t.templates.preview.tabHtml}</span>
</button>
<button
type="button"
onClick={() => setPreviewTab("json")}
className={`flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium transition-all cursor-pointer ${
previewTab === "json"
? "bg-background text-emerald-600 dark:text-emerald-400 shadow-sm border border-emerald-500/20"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Code2 className="h-3 w-3" />
<span>{t.templates.preview.tabJson}</span>
</button>
</div>
</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>
{previewTab === "html" ? (
<SafeHtmlPreview
htmlContent={simulatedHtml}
maxHeight="280px"
title="Extracted Template HTML Preview"
/>
) : (
<JsonViewer
data={simulatedData}
maxHeight="280px"
title={`Dữ Liệu JSON [${template.domain}]`}
filename={generateStandardFilename({
category: "preview",
identifier: template.domain,
extension: "json",
})}
/>
)}
</div>
)}
</div>
......
"use client";
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useMemo } from "react";
import {
X,
ShieldCheck,
ShieldAlert,
Check,
Sparkles,
AlertCircle,
RefreshCw,
User,
Info,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useLanguage } from "@/providers/language-provider";
......@@ -55,28 +52,27 @@ export function UserRolesModal({
// Mutation for syncing: PUT /users/:id/roles
const assignRolesMutation = useAssignUserRoles();
// Local state for checked role IDs
const [selectedRoleIds, setSelectedRoleIds] = useState<string[]>([]);
// Local state for user manual overrides
const [userOverrideIds, setUserOverrideIds] = useState<string[] | null>(null);
// Sync current user roles into local state with equality check to prevent loops
useEffect(() => {
if (!isOpen || !userRoles || !Array.isArray(userRoles)) return;
const newIds = userRoles.map((r) => r.id);
setSelectedRoleIds((prev) => {
if (
prev.length === newIds.length &&
prev.every((id) => newIds.includes(id))
) {
return prev;
}
return newIds;
});
}, [isOpen, userRoles]);
// Derive initial roles from server data without useEffect setState
const defaultRoleIds = useMemo(
() => (Array.isArray(userRoles) ? userRoles.map((r) => r.id) : []),
[userRoles]
);
const selectedRoleIds = userOverrideIds ?? defaultRoleIds;
const handleClose = () => {
setUserOverrideIds(null);
onClose();
};
// Handle escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) {
setUserOverrideIds(null);
onClose();
}
};
......@@ -92,10 +88,11 @@ export function UserRolesModal({
const handleToggleRole = (roleId: string) => {
if (isSelf) return; // Self-escalation guard
setSelectedRoleIds((prev) =>
prev.includes(roleId)
? prev.filter((id) => id !== roleId)
: [...prev, roleId]
const current = selectedRoleIds;
setUserOverrideIds(
current.includes(roleId)
? current.filter((id: string) => id !== roleId)
: [...current, roleId]
);
};
......@@ -104,7 +101,7 @@ export function UserRolesModal({
userId: user.id,
roleIds: selectedRoleIds,
});
onClose();
handleClose();
};
return (
......@@ -126,7 +123,7 @@ export function UserRolesModal({
</div>
</div>
<button
onClick={onClose}
onClick={handleClose}
className="rounded-xl p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
>
<X className="h-4 w-4" />
......@@ -270,7 +267,7 @@ export function UserRolesModal({
<Button
type="button"
variant="outline"
onClick={onClose}
onClick={handleClose}
className="rounded-2xl text-xs cursor-pointer"
>
{t.users.modalRoles.cancel}
......
......@@ -138,12 +138,16 @@ export function UsersManagementView({
isFetching,
} = useUsersList(queryParams);
const rawUsers: UserItem[] = usersData?.items || [];
const users = useMemo(() => {
if (!sortBy) return rawUsers;
return [...rawUsers].sort((a, b) => {
let aVal: any = a[sortBy as keyof UserItem];
let bVal: any = b[sortBy as keyof UserItem];
const items: UserItem[] = usersData?.items || [];
if (!sortBy) return items;
return [...items].sort((a, b) => {
let aVal: string | number | boolean | null | undefined = a[
sortBy as keyof UserItem
] as string | number | boolean | null | undefined;
let bVal: string | number | boolean | null | undefined = b[
sortBy as keyof UserItem
] as string | number | boolean | null | undefined;
if (sortBy === "fullName") {
aVal = a.fullName || a.email;
bVal = b.fullName || b.email;
......@@ -154,7 +158,7 @@ export function UsersManagementView({
const cmp = aVal < bVal ? -1 : 1;
return sortOrder === "asc" ? cmp : -cmp;
});
}, [rawUsers, sortBy, sortOrder]);
}, [usersData?.items, sortBy, sortOrder]);
const meta = usersData?.meta || {
total: 0,
......
......@@ -2,7 +2,12 @@
import { useEffect, useRef, useState, useCallback } from "react";
export type SSEConnectionStatus = "CONNECTING" | "OPEN" | "CLOSED" | "ERROR";
export type SSEConnectionStatus =
| "CONNECTING"
| "OPEN"
| "RECONNECTING"
| "CLOSED"
| "ERROR";
export interface UseEventSourceOptions<T = unknown> {
enabled?: boolean;
......@@ -12,7 +17,8 @@ export interface UseEventSourceOptions<T = unknown> {
onDone?: (data: { status?: string; message?: string }) => void;
onMessage?: (data: T, eventName: string) => void;
onError?: (error: Event) => void;
reconnectInterval?: number;
initialRetryDelay?: number;
maxRetryDelay?: number;
maxRetries?: number;
}
......@@ -21,13 +27,29 @@ export interface UseEventSourceResult<T = unknown> {
data: T | null;
lastEventName: string | null;
error: Event | null;
retryCount: number;
nextRetryDelayMs: number;
reconnect: () => void;
close: () => void;
}
/**
* Calculates exponential backoff delay capped at maxDelay.
* Sequence: 1s, 2s, 4s, 8s, 16s, max 30s.
*/
export function calculateBackoffDelay(
attempt: number,
initialDelay = 1000,
maxDelay = 30000
): number {
const delay = initialDelay * Math.pow(2, attempt);
return Math.min(delay, maxDelay);
}
/**
* Custom hook kết nối Server-Sent Events (SSE) theo thời gian thực.
* Tận dụng endpoint SSE GET /api/v1/crawl-jobs/:id/events (qua BFF proxy /api/proxy/crawl-jobs/:id/events).
* Tự động kết nối lại với Exponential Backoff (1s, 2s, 4s, tối đa 30s),
* đồng thời tự động phục hồi kết nối ngay khi mạng online hoặc người dùng mở lại tab/laptop.
*/
export function useEventSource<T = unknown>(
url: string | null,
......@@ -41,18 +63,27 @@ export function useEventSource<T = unknown>(
onDone,
onMessage,
onError,
reconnectInterval = 4000,
maxRetries = 5,
initialRetryDelay = 1000,
maxRetryDelay = 30000,
maxRetries = 10,
} = options;
const [status, setStatus] = useState<SSEConnectionStatus>("CONNECTING");
const [status, setStatus] = useState<SSEConnectionStatus>(() => {
if (!url || !enabled || typeof window === "undefined") {
return "CLOSED";
}
return "CONNECTING";
});
const [data, setData] = useState<T | null>(null);
const [lastEventName, setLastEventName] = useState<string | null>(null);
const [error, setError] = useState<Event | null>(null);
const [retryCount, setRetryCount] = useState(0);
const [nextRetryDelayMs, setNextRetryDelayMs] = useState(0);
const eventSourceRef = useRef<EventSource | null>(null);
const retryCountRef = useRef(0);
const retryTimerRef = useRef<NodeJS.Timeout | null>(null);
const connectRef = useRef<() => void>(() => {});
// Safe ref callbacks to avoid unnecessary reconnections
const callbacksRef = useRef({ onInitial, onProgress, onDone, onMessage, onError });
......@@ -75,12 +106,10 @@ export function useEventSource<T = unknown>(
cleanUp();
if (!url || !enabled || typeof window === "undefined") {
setStatus("CLOSED");
return;
}
try {
setStatus("CONNECTING");
const es = new EventSource(url, { withCredentials });
eventSourceRef.current = es;
......@@ -88,6 +117,8 @@ export function useEventSource<T = unknown>(
setStatus("OPEN");
setError(null);
retryCountRef.current = 0;
setRetryCount(0);
setNextRetryDelayMs(0);
};
// Handle custom SSE event: "initial"
......@@ -146,27 +177,45 @@ export function useEventSource<T = unknown>(
setError(err);
callbacksRef.current.onError?.(err);
// If readyState is CLOSED or we can retry
if (es.readyState === EventSource.CLOSED) {
setStatus("CLOSED");
es.close();
if (retryCountRef.current < maxRetries) {
retryCountRef.current += 1;
setStatus("CONNECTING");
retryTimerRef.current = setTimeout(() => {
connect();
}, reconnectInterval);
} else {
setStatus("ERROR");
}
// Close stale instance
es.close();
if (retryCountRef.current < maxRetries) {
const delay = calculateBackoffDelay(
retryCountRef.current,
initialRetryDelay,
maxRetryDelay
);
retryCountRef.current += 1;
setRetryCount(retryCountRef.current);
setNextRetryDelayMs(delay);
setStatus("RECONNECTING");
retryTimerRef.current = setTimeout(() => {
connectRef.current();
}, delay);
} else {
setStatus("ERROR");
}
};
} catch {
setStatus("ERROR");
queueMicrotask(() => {
setStatus("ERROR");
});
}
}, [cleanUp, enabled, maxRetries, reconnectInterval, url, withCredentials]);
}, [
cleanUp,
enabled,
initialRetryDelay,
maxRetryDelay,
maxRetries,
url,
withCredentials,
]);
useEffect(() => {
connectRef.current = connect;
}, [connect]);
useEffect(() => {
connect();
......@@ -177,6 +226,9 @@ export function useEventSource<T = unknown>(
const reconnect = useCallback(() => {
retryCountRef.current = 0;
setRetryCount(0);
setNextRetryDelayMs(0);
setStatus("CONNECTING");
connect();
}, [connect]);
......@@ -185,11 +237,38 @@ export function useEventSource<T = unknown>(
setStatus("CLOSED");
}, [cleanUp]);
// Immediate reconnection when network comes back online or laptop wake up / tab becomes visible
useEffect(() => {
if (typeof window === "undefined") return;
const handleNetworkOrWakeup = () => {
if (
document.visibilityState === "visible" &&
(status === "RECONNECTING" || status === "ERROR")
) {
reconnect();
}
};
window.addEventListener("online", handleNetworkOrWakeup);
document.addEventListener("visibilitychange", handleNetworkOrWakeup);
return () => {
window.removeEventListener("online", handleNetworkOrWakeup);
document.removeEventListener("visibilitychange", handleNetworkOrWakeup);
};
}, [reconnect, status]);
const currentStatus: SSEConnectionStatus =
!url || !enabled ? "CLOSED" : status;
return {
status,
status: currentStatus,
data,
lastEventName,
error,
retryCount,
nextRetryDelayMs,
reconnect,
close,
};
......
"use client";
import { useState, useEffect } from "react";
import { toast } from "sonner";
import { useLanguage } from "@/providers/language-provider";
export interface UseNetworkStatusOptions {
showToasts?: boolean;
offlineMessage?: string;
onlineMessage?: string;
}
/**
* Hook detecting browser online/offline status via navigator.onLine and window events.
* Fully compliant with React 19 standards (avoids synchronous setState in effect bodies)
* and supports multi-language (Anh - Việt) notifications.
*/
export function useNetworkStatus(options: UseNetworkStatusOptions = {}) {
const { t } = useLanguage();
const {
showToasts = true,
offlineMessage = t.network.offlineToast,
onlineMessage = t.network.onlineToast,
} = options;
const [isOnline, setIsOnline] = useState<boolean>(() => {
if (typeof window === "undefined") return true;
return typeof navigator.onLine === "boolean" ? navigator.onLine : true;
});
useEffect(() => {
if (typeof window === "undefined") return;
const handleOffline = () => {
setIsOnline(false);
if (showToasts) {
toast.warning(offlineMessage, {
id: "network-status-toast",
duration: Infinity,
});
}
};
const handleOnline = () => {
setIsOnline(true);
if (showToasts) {
toast.dismiss("network-status-toast");
toast.success(onlineMessage, {
id: "network-restored-toast",
duration: 3500,
});
}
};
window.addEventListener("offline", handleOffline);
window.addEventListener("online", handleOnline);
return () => {
window.removeEventListener("offline", handleOffline);
window.removeEventListener("online", handleOnline);
};
}, [showToasts, offlineMessage, onlineMessage]);
return { isOnline };
}
import { Locale, translations } from "./i18n/translations";
/**
* Helper kiểm tra xem lỗi cào dữ liệu có phải do mất kết nối mạng, timeout hoặc không thể kết nối tới host hay không.
* Các tác vụ thất bại do lỗi kết nối được hệ thống tự động miễn trừ và không tính vào hạn mức sử dụng (Quota).
*/
export function isConnectionLossError(
rawError: string | null | undefined
): boolean {
if (!rawError) return true;
const err = rawError.toLowerCase();
return (
// Timeout
err.includes("timeout") ||
err.includes("timed out") ||
err.includes("etimedout") ||
err.includes("esockettimedout") ||
// Connection drops & network resets
err.includes("econnrefused") ||
err.includes("econnreset") ||
err.includes("econnaborted") ||
err.includes("network error") ||
err.includes("network_error") ||
err.includes("connection reset") ||
err.includes("connection refused") ||
err.includes("connection lost") ||
err.includes("connection closed") ||
err.includes("connection error") ||
err.includes("socket hang up") ||
err.includes("network is unreachable") ||
err.includes("host unreachable") ||
err.includes("offline") ||
// DNS errors
err.includes("dns") ||
err.includes("getaddrinfo") ||
err.includes("enotfound") ||
// Provider & gateway / credit errors (Firecrawl 402, 500, 502, 503, 504)
err.includes("402") ||
err.includes("credit") ||
err.includes("500") ||
err.includes("502") ||
err.includes("503") ||
err.includes("504") ||
err.includes("bad gateway") ||
err.includes("gateway") ||
err.includes("service unavailable") ||
// Vietnamese mapped phrases & generic system errors
err.includes("mất kết nối") ||
err.includes("lỗi kết nối") ||
err.includes("không thể kết nối") ||
err.includes("kết nối đến trang web đích bị quá thời gian") ||
err.includes("không thể phân giải tên miền") ||
err.includes("đã xảy ra lỗi trong quá trình cào dữ liệu") ||
err.includes("lỗi không xác định") ||
err.includes("lỗi xác thực hệ thống cào dữ liệu")
);
}
export type CrawlErrorCategory =
| "firecrawlCredit"
| "timeout"
| "connectionRefused"
| "dns"
| "connectionReset"
| "serverUnavailable"
| "networkUnreachable"
| "unknown";
export interface LocalizedCrawlErrorInfo {
title: string;
description: string;
isConnectionLoss: boolean;
category: CrawlErrorCategory;
rawError: string;
}
/**
* Trích xuất và định dạng lỗi cào dữ liệu thành thông điệp đa ngôn ngữ (Anh - Việt)
* Thân thiện, trực quan và giải thích rõ nguyên nhân cho người dùng.
*/
export function getLocalizedCrawlErrorInfo(
rawError: string | null | undefined,
locale: Locale = "vi"
): LocalizedCrawlErrorInfo {
const isLoss = isConnectionLossError(rawError);
const t = translations[locale].crawlErrors.types;
const raw = rawError || "";
const err = raw.toLowerCase();
if (
err.includes("402") ||
err.includes("credit") ||
err.includes("xác thực") ||
err.includes("api key") ||
err.includes("apikey") ||
err.includes("gói dịch vụ") ||
err.includes("vượt quá giới hạn") ||
err.includes("unauthorized")
) {
return {
title: t.firecrawlCredit.title,
description: t.firecrawlCredit.desc,
isConnectionLoss: true,
category: "firecrawlCredit",
rawError: raw,
};
}
if (
err.includes("timeout") ||
err.includes("timed out") ||
err.includes("etimedout") ||
err.includes("esockettimedout") ||
err.includes("quá thời gian")
) {
return {
title: t.timeout.title,
description: t.timeout.desc,
isConnectionLoss: true,
category: "timeout",
rawError: raw,
};
}
if (
err.includes("econnrefused") ||
err.includes("connection refused") ||
err.includes("từ chối")
) {
return {
title: t.connectionRefused.title,
description: t.connectionRefused.desc,
isConnectionLoss: true,
category: "connectionRefused",
rawError: raw,
};
}
if (
err.includes("dns") ||
err.includes("getaddrinfo") ||
err.includes("enotfound") ||
err.includes("phân giải tên miền")
) {
return {
title: t.dns.title,
description: t.dns.desc,
isConnectionLoss: true,
category: "dns",
rawError: raw,
};
}
if (
err.includes("econnreset") ||
err.includes("econnaborted") ||
err.includes("socket hang up") ||
err.includes("connection reset")
) {
return {
title: t.connectionReset.title,
description: t.connectionReset.desc,
isConnectionLoss: true,
category: "connectionReset",
rawError: raw,
};
}
if (
err.includes("500") ||
err.includes("502") ||
err.includes("503") ||
err.includes("504") ||
err.includes("bad gateway") ||
err.includes("service unavailable")
) {
return {
title: t.serverUnavailable.title,
description: t.serverUnavailable.desc,
isConnectionLoss: true,
category: "serverUnavailable",
rawError: raw,
};
}
if (
err.includes("network error") ||
err.includes("network_error") ||
err.includes("unreachable") ||
err.includes("offline") ||
err.includes("mất kết nối")
) {
return {
title: t.networkUnreachable.title,
description: t.networkUnreachable.desc,
isConnectionLoss: true,
category: "networkUnreachable",
rawError: raw,
};
}
return {
title: t.unknown.title,
description: raw || t.unknown.desc,
isConnectionLoss: isLoss,
category: "unknown",
rawError: raw,
};
}
/**
* Blob Downloader & Standardized File Exporter Utility
* Provides safe virtual <a> tag triggers, standardized file naming,
* and automatic memory cleanup via URL.revokeObjectURL to prevent memory leaks.
*/
export interface GenerateFilenameOptions {
prefix?: string; // Default: 'datacrawler'
category?: "export" | "diff" | "preview" | "audit" | "report" | "template" | "logs";
identifier?: string; // e.g. job ID, export ID, template ID
extension: "csv" | "json" | "xlsx" | "txt" | "bin" | "zip" | "md" | string;
date?: Date | string;
}
/**
* Generates a standardized, clean filename following the convention:
* datacrawler_{category}_{identifier}_{YYYY-MM-DD}.{ext}
* Example: datacrawler_export_job12_2026-09-08.csv
*/
export function generateStandardFilename({
prefix = "datacrawler",
category = "export",
identifier,
extension,
date = new Date(),
}: GenerateFilenameOptions): string {
const d = date instanceof Date ? date : new Date(date);
const datePart = !isNaN(d.getTime())
? d.toISOString().split("T")[0]
: new Date().toISOString().split("T")[0];
const cleanId = identifier
? identifier.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 32)
: "";
const ext = extension.startsWith(".") ? extension.slice(1) : extension;
const parts = [prefix, category, cleanId, datePart].filter(Boolean);
return `${parts.join("_")}.${ext}`;
}
export interface DownloadBlobOptions {
filename: string;
mimeType?: string;
revokeDelayMs?: number; // Default: 250ms
}
/**
* Downloads a Blob, ArrayBuffer, or string safely using a virtual <a> tag.
* Automatically cleans up the object URL after the browser initiates the download.
*/
export function downloadBlob(
content: Blob | ArrayBuffer | Uint8Array | string,
options: DownloadBlobOptions | string
): void {
if (typeof window === "undefined") return;
const resolvedOptions: DownloadBlobOptions =
typeof options === "string" ? { filename: options } : options;
const { filename, mimeType, revokeDelayMs = 250 } = resolvedOptions;
let blob: Blob;
if (content instanceof Blob) {
blob = mimeType ? new Blob([content], { type: mimeType }) : content;
} else if (typeof content === "string") {
blob = new Blob([content], {
type: mimeType || "text/plain;charset=utf-8",
});
} else {
blob = new Blob([content as unknown as BlobPart], {
type: mimeType || "application/octet-stream",
});
}
const blobUrl = window.URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.style.display = "none";
anchor.href = blobUrl;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
// Remove element and revoke Blob URL to prevent memory leaks
document.body.removeChild(anchor);
window.setTimeout(() => {
window.URL.revokeObjectURL(blobUrl);
}, revokeDelayMs);
}
/**
* Helper to export JavaScript objects/arrays as a formatted JSON file.
*/
export function downloadJsonFile(
data: unknown,
filenameOrOptions?: string | Partial<GenerateFilenameOptions>
): void {
const filename =
typeof filenameOrOptions === "string"
? filenameOrOptions
: generateStandardFilename({
prefix: "datacrawler",
category: "export",
extension: "json",
...filenameOrOptions,
});
const jsonString = JSON.stringify(data, null, 2);
downloadBlob(jsonString, {
filename,
mimeType: "application/json;charset=utf-8",
});
}
/**
* Helper to export CSV or plain text with proper encoding.
*/
export function downloadTextFile(
text: string,
filename: string,
mimeType: string = "text/csv;charset=utf-8"
): void {
downloadBlob(text, { filename, mimeType });
}
......@@ -129,6 +129,10 @@ export const translations = {
warningDesc: "Bạn đã sử dụng gần hết hạn mức tác vụ cho phép trong ngày. Các tác vụ tiếp theo có thể bị giới hạn tốc độ.",
criticalBadge: "Chạm ngưỡng tối đa",
criticalDesc: "Tài khoản của bạn đã đạt giới hạn tài nguyên tối đa cho phép. Vui lòng chờ đến mốc làm mới tiếp theo để tiếp tục chạy tác vụ.",
exemptionNote: "Các tác vụ thất bại do mất kết nối mạng sẽ tự động được miễn trừ, không tính vào hạn mức.",
slotsUnit: "tác vụ",
slotRunning: "Slot đang chạy",
slotAvailable: "Slot khả dụng",
},
// Service Health Monitoring
health: {
......@@ -243,6 +247,10 @@ export const translations = {
limitLabel: "Giới hạn",
completeData: "Dữ liệu hoàn chỉnh",
copiedNotice: "Đã sao chép vào bộ nhớ tạm!",
quotaWaivedTitle: "Miễn trừ hạn mức sử dụng",
quotaWaivedBadge: "Miễn trừ hạn mức",
quotaReason: "Nguyên nhân:",
quotaWaivedDesc: "Tác vụ thất bại do sự cố mất kết nối mạng hoặc lỗi dịch vụ cào dữ liệu, tự động được miễn trừ và không bị tính vào hạn mức sử dụng của bạn.",
metrics: {
totalPages: "Tổng số trang",
successPages: "Trang thành công",
......@@ -503,6 +511,8 @@ export const translations = {
simulateBtn: "Mô Phỏng Trích Xuất",
simulating: "Đang phân tích...",
sampleResult: "Kết quả bóc tách giả định",
tabJson: "Dữ liệu JSON",
tabHtml: "Xem trước HTML an toàn",
},
},
......@@ -673,6 +683,7 @@ export const translations = {
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",
copiedKey: "Đã sao chép tiền tố khóa an toàn!",
},
modal: {
title: "Tạo Khóa API Mới",
......@@ -709,6 +720,8 @@ export const translations = {
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",
secretLabel: "Khóa bí mật HMAC:",
copiedSecret: "Đã sao chép mã định danh bí mật!",
},
modal: {
title: "Đăng Ký Webhook Mới",
......@@ -723,6 +736,13 @@ export const translations = {
cancel: "Hủy bỏ",
submit: "Lưu Cấu Hình Webhook",
submitting: "Đang lưu...",
newSecretNotice: {
title: "Webhook đã được tạo thành công!",
desc: "Khóa bí mật HMAC chỉ hiển thị đầy đủ 1 lần duy nhất lúc này để bạn sao chép. Hệ thống sẽ che giấu khóa này sau khi đóng.",
copyBtn: "Sao chép khóa HMAC",
copied: "Đã sao chép!",
close: "Tôi đã lưu khóa bí mật",
},
},
deliveries: {
title: "Nhật Ký Giao Vận",
......@@ -766,6 +786,7 @@ export const translations = {
saveBtn: "Lưu Thay Đổi",
saving: "Đang lưu...",
saveSuccess: "Cập nhật thông tin cá nhân thành công!",
cancelBtn: "Hủy bỏ",
},
quota: {
title: "Hạn Mức Sử Dụng Tài Khoản",
......@@ -1142,6 +1163,114 @@ export const translations = {
close: "Đóng cửa sổ",
},
},
// Network & Connection Resilience
network: {
offlineBanner: "Mất kết nối Internet. Dữ liệu trên màn hình có thể chưa được cập nhật.",
offlineMode: "Chế độ ngoại tuyến",
recheckBtn: "Thử kết nối lại",
checking: "Đang kiểm tra...",
offlineToast: "Mất kết nối Internet. Dữ liệu trên màn hình có thể chưa được cập nhật.",
onlineToast: "Đã khôi phục kết nối Internet.",
},
// SSE Realtime Stream
sse: {
live: "Đang phát trực tiếp",
connecting: "Đang kết nối...",
reconnecting: "Đang kết nối lại...",
reconnectingSec: "Đang kết nối lại ({sec}s)...",
reconnectingAttempt: "Đang kết nối lại (lần {attempt})...",
disconnected: "Mất kết nối",
retryBtn: "Thử lại",
liveTitle: "Kết nối SSE luồng dữ liệu thời gian thực đang hoạt động bình thường",
reconnectingTitle: "Đang kích hoạt thuật toán Exponential Backoff. Lượt thử: {attempt}",
disconnectedTitle: "Mất kết nối với máy chủ SSE. Nhấn Thử lại để kết nối lại.",
},
// Web Notifications
notifications: {
btnOn: "Đã bật thông báo",
btnOff: "Báo khi xong",
titleOn: "Đang bật thông báo trình duyệt khi hoàn thành tác vụ",
titleOff: "Bấm để nhận thông báo trình duyệt khi Job hoàn tất",
titleBlocked: "Thông báo bị chặn trong cài đặt trình duyệt",
activeNotice: "Thông báo trình duyệt đã được cấp quyền và đang hoạt động.",
activeNoticeDesc: "Bạn sẽ nhận được thông báo ngay khi Job cào hoàn tất.",
blockedNotice: "Quyền thông báo đã bị chặn trên trình duyệt này.",
blockedNoticeDesc: "Vui lòng mở cài đặt trình duyệt để cho phép hiển thị thông báo.",
grantedNotice: "Đã bật thông báo trình duyệt!",
grantedNoticeDesc: "Hệ thống sẽ thông báo khi tác vụ cào hoàn tất ngay cả khi bạn đang làm việc ở tab khác.",
deniedNotice: "Bạn chưa cấp quyền thông báo cho Data Crawler.",
taskCompletedTitle: "Data Crawler - Tác vụ hoàn tất",
taskCompletedBody: "Job #{jobId}{site} đã hoàn thành cào {pages} trang!",
},
// JSON Viewer
jsonViewer: {
defaultTitle: "Dữ Liệu JSON",
emptyMessage: "(Dữ liệu rỗng hoặc chưa được khởi tạo)",
searchPlaceholder: "Tìm kiếm khóa hoặc giá trị...",
compact: "Dạng nén",
formatted: "Dạng thụt lề",
downloadTitle: "Tải xuống {filename}",
copyAll: "Sao chép toàn bộ JSON",
copied: "Đã sao chép nội dung JSON!",
linesCount: "{count} dòng",
keysCount: "{count} trường",
toggleLinesHide: "Ẩn số dòng",
toggleLinesShow: "Hiện số dòng",
toggleCompactOn: "Định dạng nhiều dòng",
toggleCompactOff: "Thu gọn một dòng",
itemsCount: "{count} phần tử",
propsCount: "{count} thuộc tính",
footerBadge: "Trình xem cú pháp JSON • DataCrawler Core",
},
// Copy button
copyAction: {
copy: "Sao chép",
copied: "Đã sao chép!",
tooltip: "Sao chép vào bộ nhớ tạm",
toastSuccess: "Đã sao chép vào bộ nhớ tạm!",
toastError: "Không thể sao chép vào bộ nhớ tạm",
},
// Crawl Error Explanations
crawlErrors: {
reasonLabel: "Nguyên nhân:",
quotaWaivedTitle: "Miễn trừ hạn mức sử dụng (Quota Exempted)",
quotaWaivedBadge: "Miễn phí hạn ngạch",
quotaWaivedDesc: "Tác vụ thất bại do sự cố mạng hoặc hạn mức nhà cung cấp, hệ thống tự động miễn trừ và không tính vào hạn mức (Quota) của bạn.",
types: {
firecrawlCredit: {
title: "Lỗi xác thực & Hạn mức Provider",
desc: "Lỗi xác thực hệ thống cào dữ liệu (API Key không hợp lệ, hết hạn hoặc vượt quá giới hạn gói dịch vụ).",
},
timeout: {
title: "Hết thời gian chờ phản hồi (Request Timeout)",
desc: "Kết nối đến trang web đích bị quá thời gian (Timeout). Trang web phản hồi quá chậm.",
},
connectionRefused: {
title: "Máy chủ từ chối kết nối (Connection Refused)",
desc: "Máy chủ đích đang ngoại tuyến hoặc tường lửa từ chối kết nối từ crawler.",
},
dns: {
title: "Không thể phân giải tên miền (DNS Failure)",
desc: "Địa chỉ website không tồn tại, tên miền hết hạn hoặc máy chủ DNS gặp sự cố.",
},
connectionReset: {
title: "Kết nối bị ngắt đột ngột (Connection Reset)",
desc: "Đường truyền mạng bị ngắt giữa chừng hoặc kết nối TCP bị đóng bất ngờ.",
},
serverUnavailable: {
title: "Máy chủ đích gặp sự cố (HTTP 5xx)",
desc: "Trang web mục tiêu bị lỗi nội bộ, đang bảo trì hoặc cổng Gateway crawler bị lỗi.",
},
networkUnreachable: {
title: "Mất kết nối mạng (Network Unreachable)",
desc: "Không tìm thấy tuyến đường mạng tới máy chủ đích hoặc mất kết nối Internet.",
},
unknown: {
title: "Lỗi thực thi cào dữ liệu",
desc: "Đã xảy ra sự cố trong quá trình thu thập và bóc tách dữ liệu.",
},
},
},
},
en: {
// Navigation & Common
......@@ -1271,6 +1400,10 @@ export const translations = {
warningDesc: "You have consumed almost all of your daily allocated jobs. Subsequent tasks may experience rate-limiting.",
criticalBadge: "Quota Exceeded",
criticalDesc: "Your account has reached its maximum quota. New crawl jobs will remain pending until the quota reset window.",
exemptionNote: "Tasks failed due to connection loss are automatically exempt from quota consumption.",
slotsUnit: "slots",
slotRunning: "Slot currently running",
slotAvailable: "Slot available",
},
// Service Health Monitoring
health: {
......@@ -1385,6 +1518,10 @@ export const translations = {
limitLabel: "Limit",
completeData: "Complete records",
copiedNotice: "Copied to clipboard!",
quotaWaivedTitle: "Usage Quota Exempted",
quotaWaivedBadge: "Quota Exempted",
quotaReason: "Reason:",
quotaWaivedDesc: "This task failed due to network disruption or provider credit limit and is not charged against your account quota.",
metrics: {
totalPages: "Total Pages",
successPages: "Successful Pages",
......@@ -1645,6 +1782,8 @@ export const translations = {
simulateBtn: "Simulate Extraction",
simulating: "Analyzing...",
sampleResult: "Simulated Extraction Result",
tabJson: "JSON Data",
tabHtml: "Safe HTML Preview",
},
},
......@@ -1815,6 +1954,7 @@ export const translations = {
revokeConfirm: "Are you sure you want to revoke this API key? Applications using it will lose access immediately.",
active: "Active",
inactive: "Inactive",
copiedKey: "Key prefix safely copied!",
},
modal: {
title: "Generate API Key",
......@@ -1851,6 +1991,8 @@ export const translations = {
deleteConfirm: "Are you sure you want to delete this webhook configuration?",
active: "Active",
inactive: "Paused",
secretLabel: "HMAC Secret:",
copiedSecret: "Secret identifier copied!",
},
modal: {
title: "Register New Webhook",
......@@ -1865,6 +2007,13 @@ export const translations = {
cancel: "Cancel",
submit: "Save Webhook",
submitting: "Saving...",
newSecretNotice: {
title: "Webhook Created Successfully!",
desc: "The HMAC signing secret is displayed in full only once right now. Please copy and store it securely. It will be masked after closing.",
copyBtn: "Copy HMAC Secret",
copied: "Copied!",
close: "I have saved the secret",
},
},
deliveries: {
title: "Deliveries Log",
......@@ -1908,6 +2057,7 @@ export const translations = {
saveBtn: "Save Changes",
saving: "Saving...",
saveSuccess: "Personal information updated successfully!",
cancelBtn: "Cancel",
},
quota: {
title: "Account Quota & Limits",
......@@ -2284,6 +2434,114 @@ export const translations = {
close: "Close Drawer",
},
},
// Network & Connection Resilience
network: {
offlineBanner: "No Internet connection. Data on screen may not be up to date.",
offlineMode: "Offline Mode",
recheckBtn: "Retry connection",
checking: "Checking...",
offlineToast: "Internet connection lost. Data on screen may not be updated.",
onlineToast: "Internet connection restored.",
},
// SSE Realtime Stream
sse: {
live: "Live Stream",
connecting: "Connecting...",
reconnecting: "Reconnecting...",
reconnectingSec: "Reconnecting ({sec}s)...",
reconnectingAttempt: "Reconnecting (attempt {attempt})...",
disconnected: "Disconnected",
retryBtn: "Retry",
liveTitle: "Real-time SSE data stream is active and healthy",
reconnectingTitle: "Exponential backoff active. Attempt: {attempt}",
disconnectedTitle: "Disconnected from SSE server. Click Retry to reconnect.",
},
// Web Notifications
notifications: {
btnOn: "Notifications On",
btnOff: "Notify when done",
titleOn: "Browser notification is enabled for completed jobs",
titleOff: "Click to receive browser notifications when job finishes",
titleBlocked: "Notifications are blocked in browser settings",
activeNotice: "Browser notifications are authorized and running.",
activeNoticeDesc: "You will be alerted as soon as the crawl job finishes.",
blockedNotice: "Notification permissions are blocked in this browser.",
blockedNoticeDesc: "Please enable notification permissions in browser settings.",
grantedNotice: "Browser notifications enabled!",
grantedNoticeDesc: "You will be notified upon job completion even when working on another tab.",
deniedNotice: "Browser notification permissions were not granted.",
taskCompletedTitle: "Data Crawler - Task Completed",
taskCompletedBody: "Job #{jobId}{site} completed crawling {pages} pages!",
},
// JSON Viewer
jsonViewer: {
defaultTitle: "JSON Data",
emptyMessage: "(Empty or uninitialized data)",
searchPlaceholder: "Search keys or values...",
compact: "Compact",
formatted: "Formatted",
downloadTitle: "Download {filename}",
copyAll: "Copy full JSON",
copied: "JSON content copied to clipboard!",
linesCount: "{count} lines",
keysCount: "{count} fields",
toggleLinesHide: "Hide line numbers",
toggleLinesShow: "Show line numbers",
toggleCompactOn: "Expand formatted JSON",
toggleCompactOff: "Collapse to single line",
itemsCount: "{count} items",
propsCount: "{count} properties",
footerBadge: "JSON Syntax Highlighter • DataCrawler Core",
},
// Copy button
copyAction: {
copy: "Copy",
copied: "Copied!",
tooltip: "Copy to clipboard",
toastSuccess: "Copied to clipboard!",
toastError: "Failed to copy to clipboard",
},
// Crawl Error Explanations
crawlErrors: {
reasonLabel: "Reason:",
quotaWaivedTitle: "Usage Quota Exempted",
quotaWaivedBadge: "Quota Exempted",
quotaWaivedDesc: "This task failed due to network disruption or provider outage; it has been automatically exempted from your quota.",
types: {
firecrawlCredit: {
title: "Authentication & Provider Quota Error",
desc: "Crawler system authentication error (Invalid API key, expired or plan service limit exceeded).",
},
timeout: {
title: "Request Timed Out",
desc: "Connection to target website timed out (Timeout). The target site responded too slowly.",
},
connectionRefused: {
title: "Connection Refused",
desc: "The target host is offline or actively rejected crawler connections.",
},
dns: {
title: "Domain Resolution Failed (DNS)",
desc: "Target domain name cannot be resolved, has expired, or DNS server is unreachable.",
},
connectionReset: {
title: "Connection Reset Abruptly",
desc: "The connection between crawler and target server was reset by peer or disrupted.",
},
serverUnavailable: {
title: "Target Server Error (HTTP 5xx)",
desc: "Target website encountered an internal server error or gateway failure.",
},
networkUnreachable: {
title: "Network Unreachable",
desc: "No network route to target host could be established or connection is offline.",
},
unknown: {
title: "Crawl Execution Error",
desc: "An unexpected error occurred during page crawling and data extraction.",
},
},
},
},
};
......
/**
* Web Notification API Helper
* Allows sending desktop notifications when a long-running crawl job completes
* while the user is working on another browser tab.
* Supports multi-language (Anh - Việt) titles and body templates.
*/
export interface JobCompletionNotificationPayload {
jobId: string;
totalPages?: number;
successPages?: number;
domain?: string;
force?: boolean;
title?: string;
bodyTemplate?: string;
}
/**
* Checks whether the browser supports the Web Notification API.
*/
export function isNotificationSupported(): boolean {
return typeof window !== "undefined" && "Notification" in window;
}
/**
* Returns current notification permission: "granted", "denied", "default", or "unsupported".
*/
export function getNotificationPermission(): NotificationPermission | "unsupported" {
if (!isNotificationSupported()) {
return "unsupported";
}
return Notification.permission;
}
/**
* Requests user permission to display browser notifications.
*/
export async function requestNotificationPermission(): Promise<boolean> {
if (!isNotificationSupported()) {
return false;
}
try {
const permission = await Notification.requestPermission();
return permission === "granted";
} catch {
return false;
}
}
/**
* Displays a desktop browser notification when a crawl job completes.
* By default, this only fires when `document.hidden === true` (user is working on another tab),
* matching the user UX expectation and avoiding screen clutter when actively viewing the page.
*/
export function notifyJobCompletion({
jobId,
totalPages = 0,
successPages = 0,
domain,
force = false,
title,
bodyTemplate,
}: JobCompletionNotificationPayload): boolean {
if (!isNotificationSupported()) {
return false;
}
if (Notification.permission !== "granted") {
return false;
}
// Only notify when tab is backgrounded / hidden, unless explicitly forced
if (!force && typeof document !== "undefined" && !document.hidden) {
return false;
}
try {
const pages = (successPages > 0 ? successPages : totalPages).toLocaleString();
const shortId = jobId.length > 8 ? jobId.slice(0, 8) : jobId;
const siteInfo = domain ? ` (${domain})` : "";
const finalTitle = title || "Data Crawler - Tác vụ hoàn tất";
const finalBody = bodyTemplate
? bodyTemplate
.replace("{jobId}", shortId)
.replace("{site}", siteInfo)
.replace("{pages}", pages)
: `Job #${shortId}${siteInfo} đã hoàn thành cào ${pages} trang!`;
const notification = new Notification(finalTitle, {
body: finalBody,
icon: "/favicon.ico",
tag: `crawl-job-completed-${jobId}`,
});
notification.onclick = () => {
window.focus();
notification.close();
};
return true;
} catch {
return false;
}
}
......@@ -13,33 +13,39 @@ const LanguageContext = createContext<LanguageContextType | undefined>(undefined
const LANGUAGE_STORAGE_KEY = "data_crawler_lang";
function getInitialLocale(): Locale {
if (typeof window === "undefined") return "vi";
try {
const saved = localStorage.getItem(LANGUAGE_STORAGE_KEY) as Locale | null;
if (saved && (saved === "vi" || saved === "en")) {
return saved;
}
const browserLang = navigator.language?.toLowerCase() || "";
return browserLang.startsWith("vi") ? "vi" : "en";
} catch {
return "vi";
}
}
export function LanguageProvider({ children }: { children: ReactNode }) {
// Always initialize with "vi" to match SSR output and prevent hydration mismatch
const [locale, setLocaleState] = useState<Locale>("vi");
const [locale, setLocaleState] = useState<Locale>(getInitialLocale);
React.useEffect(() => {
try {
const saved = localStorage.getItem(LANGUAGE_STORAGE_KEY) as Locale | null;
if (saved && (saved === "vi" || saved === "en")) {
setLocaleState(saved);
document.documentElement.lang = saved;
return;
}
const browserLang = navigator.language?.toLowerCase() || "";
const initial = browserLang.startsWith("vi") ? "vi" : "en";
if (initial !== "vi") {
setLocaleState(initial);
document.documentElement.lang = initial;
}
document.documentElement.lang = locale;
} catch {
// Ignore errors if localStorage is restricted
// Ignore DOM update errors
}
}, []);
}, [locale]);
const setLocale = (newLocale: Locale) => {
setLocaleState(newLocale);
if (typeof window !== "undefined") {
localStorage.setItem(LANGUAGE_STORAGE_KEY, newLocale);
try {
localStorage.setItem(LANGUAGE_STORAGE_KEY, newLocale);
} catch {
// Ignore localStorage errors
}
document.documentElement.lang = newLocale;
}
};
......
......@@ -9,6 +9,8 @@ import { ThemeProvider } from "./theme-provider";
import { LanguageProvider } from "./language-provider";
import { AuthProvider } from "./auth-provider";
import { NetworkStatusBanner } from "@/components/common/network-status-banner";
interface ProvidersProps {
children: ReactNode;
}
......@@ -26,6 +28,7 @@ export function Providers({ children }: ProvidersProps) {
<LanguageProvider>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<NetworkStatusBanner />
{children}
<Toaster position="top-right" richColors closeButton />
<ReactQueryDevtools initialIsOpen={false} />
......
......@@ -3,9 +3,9 @@ import { ApiResponse, PaginatedResponse } from "@/types/api";
import {
CrawlExport,
CrawlExportQueryDto,
CreateCrawlExportDto,
ExportType,
} from "@/types/export";
import { downloadBlob, generateStandardFilename } from "@/lib/download-helper";
const MOCK_CRAWL_EXPORTS: CrawlExport[] = [
{
......@@ -164,31 +164,30 @@ export class CrawlExportService {
}
async downloadExport(exportId: string, fileName?: string): Promise<void> {
const defaultFilename =
fileName ||
generateStandardFilename({
category: "export",
identifier: exportId,
extension: "csv",
});
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);
const headerContentType = response.headers?.["content-type"];
downloadBlob(response.data, {
filename: defaultFilename,
mimeType: typeof headerContentType === "string" ? headerContentType : "application/octet-stream",
});
} catch {
// Fallback mock download trigger
// Fallback mock download trigger with standardized filename
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);
downloadBlob(mockContent, {
filename: defaultFilename,
mimeType: "text/csv;charset=utf-8",
});
}
}
......
......@@ -99,7 +99,7 @@ const MOCK_WEBHOOK_DELIVERIES: WebhookDelivery[] = [
let localApiKeys = [...MOCK_API_KEYS];
let localWebhooks = [...MOCK_WEBHOOK_CONFIGS];
let localDeliveries = [...MOCK_WEBHOOK_DELIVERIES];
const localDeliveries = [...MOCK_WEBHOOK_DELIVERIES];
export class DeveloperService {
// API Keys
......
......@@ -38,6 +38,7 @@ export interface WebhookConfig {
id: string;
userId?: string;
url: string;
secret?: string;
events: string[];
isActive: boolean;
createdAt: string;
......
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