Commit 77b0973f authored by ThinhNC's avatar ThinhNC

Merge branch 'feat/resilience-quota-waiver-and-i18n' into 'develop'

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

See merge request !14
parents 6783e17c cf38fc59
...@@ -19,6 +19,7 @@ ...@@ -19,6 +19,7 @@
"axios": "^1.20.0", "axios": "^1.20.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dompurify": "^3.4.15",
"lucide-react": "^1.41.0", "lucide-react": "^1.41.0",
"next": "16.3.4", "next": "16.3.4",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
...@@ -31,6 +32,7 @@ ...@@ -31,6 +32,7 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/dompurify": "^3.2.0",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
......
...@@ -38,6 +38,9 @@ importers: ...@@ -38,6 +38,9 @@ importers:
clsx: clsx:
specifier: ^2.1.1 specifier: ^2.1.1
version: 2.1.1 version: 2.1.1
dompurify:
specifier: ^3.4.15
version: 3.4.15
lucide-react: lucide-react:
specifier: ^1.41.0 specifier: ^1.41.0
version: 1.41.0(react@19.2.8) version: 1.41.0(react@19.2.8)
...@@ -69,6 +72,9 @@ importers: ...@@ -69,6 +72,9 @@ importers:
'@tailwindcss/postcss': '@tailwindcss/postcss':
specifier: ^4 specifier: ^4
version: 4.3.3 version: 4.3.3
'@types/dompurify':
specifier: ^3.2.0
version: 3.2.0
'@types/node': '@types/node':
specifier: ^20 specifier: ^20
version: 20.19.43 version: 20.19.43
...@@ -995,6 +1001,10 @@ packages: ...@@ -995,6 +1001,10 @@ packages:
'@tybys/wasm-util@0.10.3': '@tybys/wasm-util@0.10.3':
resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} 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': '@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
...@@ -1015,6 +1025,9 @@ packages: ...@@ -1015,6 +1025,9 @@ packages:
'@types/react@19.2.18': '@types/react@19.2.18':
resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} 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': '@typescript-eslint/eslint-plugin@8.69.0':
resolution: {integrity: sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA==} resolution: {integrity: sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
...@@ -1426,6 +1439,9 @@ packages: ...@@ -1426,6 +1439,9 @@ packages:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
dompurify@3.4.15:
resolution: {integrity: sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==}
dunder-proto@1.0.1: dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
...@@ -3343,6 +3359,10 @@ snapshots: ...@@ -3343,6 +3359,10 @@ snapshots:
tslib: 2.8.1 tslib: 2.8.1
optional: true optional: true
'@types/dompurify@3.2.0':
dependencies:
dompurify: 3.4.15
'@types/estree@1.0.9': {} '@types/estree@1.0.9': {}
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
...@@ -3361,6 +3381,9 @@ snapshots: ...@@ -3361,6 +3381,9 @@ snapshots:
dependencies: dependencies:
csstype: 3.2.3 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)': '@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: dependencies:
'@eslint-community/regexpp': 4.12.2 '@eslint-community/regexpp': 4.12.2
...@@ -3779,6 +3802,10 @@ snapshots: ...@@ -3779,6 +3802,10 @@ snapshots:
dependencies: dependencies:
esutils: 2.0.3 esutils: 2.0.3
dompurify@3.4.15:
optionalDependencies:
'@types/trusted-types': 2.0.7
dunder-proto@1.0.1: dunder-proto@1.0.1:
dependencies: dependencies:
call-bind-apply-helpers: 1.0.2 call-bind-apply-helpers: 1.0.2
......
This diff is collapsed.
...@@ -11,6 +11,7 @@ import { ...@@ -11,6 +11,7 @@ import {
} from "@/hooks/use-developer"; } from "@/hooks/use-developer";
import { ApiKey } from "@/types/developer"; import { ApiKey } from "@/types/developer";
import { CreateApiKeyModal } from "@/components/developer/create-api-key-modal"; import { CreateApiKeyModal } from "@/components/developer/create-api-key-modal";
import { CopyButton } from "@/components/common/copy-button";
export default function ApiKeysPage() { export default function ApiKeysPage() {
const { t, locale } = useLanguage(); const { t, locale } = useLanguage();
...@@ -92,8 +93,23 @@ export default function ApiKeysPage() { ...@@ -92,8 +93,23 @@ export default function ApiKeysPage() {
<td className="px-5 py-4 font-bold text-foreground"> <td className="px-5 py-4 font-bold text-foreground">
{key.name} {key.name}
</td> </td>
<td className="px-4 py-4 font-mono text-muted-foreground"> <td className="px-4 py-4">
{key.keyPrefix}...**** <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>
<td className="px-4 py-4"> <td className="px-4 py-4">
<button <button
......
"use client"; "use client";
import React, { useState } from "react"; import React from "react";
import { Terminal, Code2, Check, Copy } from "lucide-react"; import { Terminal, Code2 } from "lucide-react";
import { useLanguage } from "@/providers/language-provider"; import { useLanguage } from "@/providers/language-provider";
import { CopyButton } from "@/components/common/copy-button";
export default function DeveloperDocsPage() { export default function DeveloperDocsPage() {
const { t } = useLanguage(); 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" \\ const curlSnippet = `curl -X POST "https://api.datacrawler.io/api/v1/crawl-jobs" \\
-H "X-API-Key: dc_live_your_secret_key_here" \\ -H "X-API-Key: dc_live_your_secret_key_here" \\
...@@ -58,20 +52,17 @@ console.log('Job Created:', response.data);`; ...@@ -58,20 +52,17 @@ console.log('Job Created:', response.data);`;
<Terminal className="h-4 w-4 text-emerald-500" /> <Terminal className="h-4 w-4 text-emerald-500" />
cURL cURL
</span> </span>
<button <CopyButton
type="button" variant="badge"
onClick={() => handleCopySnippet(curlSnippet, "curl")} text={curlSnippet}
className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground cursor-pointer" label="Sao chép cURL"
> copiedLabel="Đã sao chép!"
{copiedSnippet === "curl" ? ( title="Sao chép lệnh cURL"
<Check className="h-3.5 w-3.5 text-emerald-500" /> showToast={true}
) : ( toastMessage="Đã sao chép lệnh cURL vào bộ nhớ tạm!"
<Copy className="h-3.5 w-3.5" /> />
)}
<span>{copiedSnippet === "curl" ? "Đã sao chép" : "Sao chép"}</span>
</button>
</div> </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} {curlSnippet}
</pre> </pre>
</div> </div>
...@@ -83,20 +74,17 @@ console.log('Job Created:', response.data);`; ...@@ -83,20 +74,17 @@ console.log('Job Created:', response.data);`;
<Code2 className="h-4 w-4 text-emerald-500" /> <Code2 className="h-4 w-4 text-emerald-500" />
Node.js / Axios Node.js / Axios
</span> </span>
<button <CopyButton
type="button" variant="badge"
onClick={() => handleCopySnippet(nodeSnippet, "node")} text={nodeSnippet}
className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground cursor-pointer" label="Sao chép Node.js"
> copiedLabel="Đã sao chép!"
{copiedSnippet === "node" ? ( title="Sao chép đoạn mã Node.js"
<Check className="h-3.5 w-3.5 text-emerald-500" /> showToast={true}
) : ( toastMessage="Đã sao chép mã nguồn Node.js vào bộ nhớ tạm!"
<Copy className="h-3.5 w-3.5" /> />
)}
<span>{copiedSnippet === "node" ? "Đã sao chép" : "Sao chép"}</span>
</button>
</div> </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} {nodeSnippet}
</pre> </pre>
</div> </div>
......
...@@ -8,9 +8,16 @@ import { ...@@ -8,9 +8,16 @@ import {
Trash2, Trash2,
Send, Send,
RefreshCw, RefreshCw,
Eye,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useLanguage } from "@/providers/language-provider"; import { useLanguage } from "@/providers/language-provider";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { import {
useWebhookConfigsList, useWebhookConfigsList,
useDeleteWebhookConfig, useDeleteWebhookConfig,
...@@ -18,8 +25,10 @@ import { ...@@ -18,8 +25,10 @@ import {
useWebhookDeliveriesList, useWebhookDeliveriesList,
useRedeliverWebhook, useRedeliverWebhook,
} from "@/hooks/use-developer"; } from "@/hooks/use-developer";
import { WebhookConfig } from "@/types/developer"; import { WebhookConfig, WebhookDelivery } from "@/types/developer";
import { CreateWebhookModal } from "@/components/developer/create-webhook-modal"; 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() { export default function WebhooksPage() {
const { t, locale } = useLanguage(); const { t, locale } = useLanguage();
...@@ -31,6 +40,7 @@ export default function WebhooksPage() { ...@@ -31,6 +40,7 @@ export default function WebhooksPage() {
const [isWebhookModalOpen, setIsWebhookModalOpen] = useState(false); const [isWebhookModalOpen, setIsWebhookModalOpen] = useState(false);
const [editingWebhook, setEditingWebhook] = useState<WebhookConfig | null>(null); const [editingWebhook, setEditingWebhook] = useState<WebhookConfig | null>(null);
const [selectedDelivery, setSelectedDelivery] = useState<WebhookDelivery | null>(null);
const deliveries = deliveriesData?.items || []; const deliveries = deliveriesData?.items || [];
...@@ -107,6 +117,13 @@ export default function WebhooksPage() { ...@@ -107,6 +117,13 @@ export default function WebhooksPage() {
<p className="font-mono text-xs font-bold text-foreground truncate"> <p className="font-mono text-xs font-bold text-foreground truncate">
{wh.url} {wh.url}
</p> </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 <span
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold border ${ className={`rounded-full px-2 py-0.5 text-[10px] font-semibold border ${
wh.isActive wh.isActive
...@@ -130,6 +147,28 @@ export default function WebhooksPage() { ...@@ -130,6 +147,28 @@ export default function WebhooksPage() {
</span> </span>
))} ))}
</div> </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>
<div className="flex items-center gap-2 self-start lg:self-center"> <div className="flex items-center gap-2 self-start lg:self-center">
...@@ -233,7 +272,17 @@ export default function WebhooksPage() { ...@@ -233,7 +272,17 @@ export default function WebhooksPage() {
} }
)} )}
</td> </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 <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
...@@ -252,6 +301,62 @@ export default function WebhooksPage() { ...@@ -252,6 +301,62 @@ export default function WebhooksPage() {
)} )}
</div> </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 */} {/* Modal */}
<CreateWebhookModal <CreateWebhookModal
isOpen={isWebhookModalOpen} isOpen={isWebhookModalOpen}
......
...@@ -75,14 +75,23 @@ export default function ProfileSettingsPage() { ...@@ -75,14 +75,23 @@ export default function ProfileSettingsPage() {
// Keep state synced with user profile // Keep state synced with user profile
React.useEffect(() => { React.useEffect(() => {
if (profile) { if (profile) {
if (profile.fullName) setFullName(profile.fullName); if (profile.fullName !== undefined && profile.fullName !== null) {
setFullName(profile.fullName);
}
if (profile.avatarUrl) setAvatarPreview(profile.avatarUrl); if (profile.avatarUrl) setAvatarPreview(profile.avatarUrl);
} else if (user) { } 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); if (user.avatarUrl) setAvatarPreview(user.avatarUrl);
} }
}, [profile, user]); }, [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 // Compute Password Strength
const passwordStrength = React.useMemo(() => { const passwordStrength = React.useMemo(() => {
if (!newPassword) return 0; if (!newPassword) return 0;
...@@ -113,10 +122,15 @@ export default function ProfileSettingsPage() { ...@@ -113,10 +122,15 @@ export default function ProfileSettingsPage() {
// Update Profile Name Handler // Update Profile Name Handler
const handleUpdateProfile = (e: React.FormEvent) => { const handleUpdateProfile = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!fullName.trim()) return; if (!isProfileChanged || updateProfileMutation.isPending) return;
updateProfileMutation.mutate({ fullName: fullName.trim() }); updateProfileMutation.mutate({ fullName: fullName.trim() });
}; };
// Revert Profile Name Handler
const handleCancelUpdateProfile = () => {
setFullName(profile?.fullName ?? user?.fullName ?? "");
};
// Change Password Handler // Change Password Handler
const handleChangePassword = async (e: React.FormEvent) => { const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
...@@ -351,11 +365,23 @@ export default function ProfileSettingsPage() { ...@@ -351,11 +365,23 @@ export default function ProfileSettingsPage() {
</div> </div>
</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 <Button
type="submit" type="submit"
disabled={updateProfileMutation.isPending} 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" 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 {updateProfileMutation.isPending
? t.profile.personal.saving ? t.profile.personal.saving
......
"use client"; "use client";
import React, { useState, useEffect } from "react"; import React, { useEffect } from "react";
import { import {
X, X,
Copy,
Check,
AlertTriangle, AlertTriangle,
Clock, Clock,
User, User,
Globe, Globe,
Monitor, Monitor,
Shield, Shield,
FileCode,
ArrowRight,
Database, Database,
Key,
Layers,
Sparkles, Sparkles,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
...@@ -29,8 +23,10 @@ import { ...@@ -29,8 +23,10 @@ import {
formatDate, formatDate,
formatRelativeTime, formatRelativeTime,
parseUserAgent, parseUserAgent,
cn,
} from "@/lib/utils"; } 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 { interface AuditLogDetailDrawerProps {
log: AuditLogItem | null; log: AuditLogItem | null;
...@@ -44,9 +40,6 @@ export function AuditLogDetailDrawer({ ...@@ -44,9 +40,6 @@ export function AuditLogDetailDrawer({
onClose, onClose,
}: AuditLogDetailDrawerProps) { }: AuditLogDetailDrawerProps) {
const { t, locale } = useLanguage(); const { t, locale } = useLanguage();
const [copiedPayload, setCopiedPayload] = useState(false);
const [copiedIp, setCopiedIp] = useState(false);
const [copiedUserId, setCopiedUserId] = useState(false);
// Handle ESC key to close // Handle ESC key to close
useEffect(() => { useEffect(() => {
...@@ -94,29 +87,6 @@ export function AuditLogDetailDrawer({ ...@@ -94,29 +87,6 @@ export function AuditLogDetailDrawer({
? details.updatedFields ? details.updatedFields
: null; : 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 = () => { const renderActionBadge = () => {
switch (category) { switch (category) {
case "CREATE": case "CREATE":
...@@ -270,17 +240,14 @@ export function AuditLogDetailDrawer({ ...@@ -270,17 +240,14 @@ export function AuditLogDetailDrawer({
</span> </span>
<div className="flex items-center gap-1.5 font-mono text-[11px] text-foreground"> <div className="flex items-center gap-1.5 font-mono text-[11px] text-foreground">
<span className="truncate">{log.userId}</span> <span className="truncate">{log.userId}</span>
<button <CopyButton
onClick={handleCopyUserId} text={log.userId}
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
title="Sao chép User ID" title="Sao chép User ID"
> showToast={true}
{copiedUserId ? ( toastMessage="Đã sao chép User ID!"
<Check className="h-3 w-3 text-emerald-500" /> className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
) : ( iconSize="h-3 w-3"
<Copy className="h-3 w-3" /> />
)}
</button>
</div> </div>
</div> </div>
)} )}
...@@ -294,17 +261,14 @@ export function AuditLogDetailDrawer({ ...@@ -294,17 +261,14 @@ export function AuditLogDetailDrawer({
<Globe className="h-3.5 w-3.5 text-emerald-500 shrink-0" /> <Globe className="h-3.5 w-3.5 text-emerald-500 shrink-0" />
<span>{log.ipAddress || "127.0.0.1"}</span> <span>{log.ipAddress || "127.0.0.1"}</span>
{log.ipAddress && ( {log.ipAddress && (
<button <CopyButton
onClick={handleCopyIp} text={log.ipAddress}
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
title="Sao chép IP" title="Sao chép IP"
> showToast={true}
{copiedIp ? ( toastMessage="Đã sao chép IP!"
<Check className="h-3 w-3 text-emerald-500" /> className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
) : ( iconSize="h-3 w-3"
<Copy className="h-3 w-3" /> />
)}
</button>
)} )}
</div> </div>
</div> </div>
...@@ -361,11 +325,20 @@ export function AuditLogDetailDrawer({ ...@@ -361,11 +325,20 @@ export function AuditLogDetailDrawer({
<span className="h-2 w-2 rounded-full bg-red-500" /> <span className="h-2 w-2 rounded-full bg-red-500" />
{t.auditLogs.drawer.diffBefore} {t.auditLogs.drawer.diffBefore}
</div> </div>
<pre className="font-mono text-[11px] text-red-700 dark:text-red-300 overflow-x-auto whitespace-pre-wrap leading-relaxed"> {beforeData ? (
{beforeData <JsonViewer
? JSON.stringify(beforeData, null, 2) data={beforeData}
: "(Không có giá trị ban đầu)"} title={t.auditLogs.drawer.diffBefore}
</pre> 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> </div>
{/* After Column */} {/* After Column */}
...@@ -374,57 +347,38 @@ export function AuditLogDetailDrawer({ ...@@ -374,57 +347,38 @@ export function AuditLogDetailDrawer({
<span className="h-2 w-2 rounded-full bg-emerald-500" /> <span className="h-2 w-2 rounded-full bg-emerald-500" />
{t.auditLogs.drawer.diffAfter} {t.auditLogs.drawer.diffAfter}
</div> </div>
<pre className="font-mono text-[11px] text-emerald-700 dark:text-emerald-300 overflow-x-auto whitespace-pre-wrap leading-relaxed"> {afterData ? (
{afterData <JsonViewer
? JSON.stringify(afterData, null, 2) data={afterData}
: "(Không có giá trị mới)"} title={t.auditLogs.drawer.diffAfter}
</pre> 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> </div>
</div> </div>
)} )}
{/* Request Payload JSON Inspector */} {/* Request Payload JSON Inspector */}
<div className="rounded-2xl border border-emerald-500/20 bg-card/60 p-5 space-y-3"> <div className="space-y-2">
<div className="flex items-center justify-between"> <JsonViewer
<div className="flex items-center gap-2 text-xs font-bold text-foreground"> data={details}
<FileCode className="h-4 w-4 text-emerald-500" /> title={t.auditLogs.drawer.payloadTitle}
<span>{t.auditLogs.drawer.payloadTitle}</span> filename={generateStandardFilename({
</div> category: "audit",
identifier: log.id,
<Button extension: "json",
variant="ghost" })}
size="sm" maxHeight="320px"
onClick={handleCopyPayload} emptyMessage={t.auditLogs.drawer.noPayload}
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> </div>
</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>
);
}
This diff is collapsed.
"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 ...@@ -243,7 +243,7 @@ export function QuotaUsageWidget({ initialUsage, className }: QuotaUsageWidgetPr
: "bg-rose-500/15 text-rose-500" : "bg-rose-500/15 text-rose-500"
)} )}
> >
{usage.concurrentJobsAvailable} slots {usage.concurrentJobsAvailable} {t.quota.slotsUnit}
</span> </span>
</div> </div>
...@@ -275,13 +275,19 @@ export function QuotaUsageWidget({ initialUsage, className }: QuotaUsageWidgetPr ...@@ -275,13 +275,19 @@ export function QuotaUsageWidget({ initialUsage, className }: QuotaUsageWidgetPr
? "bg-teal-500 shadow-sm shadow-teal-500/30" ? "bg-teal-500 shadow-sm shadow-teal-500/30"
: "bg-muted/70" : "bg-muted/70"
)} )}
title={isOccupied ? "Slot đang chạy" : "Slot khả dụng"} title={isOccupied ? t.quota.slotRunning : t.quota.slotAvailable}
/> />
); );
})} })}
</div> </div>
</div> </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> </div>
); );
} }
...@@ -4,11 +4,9 @@ import React, { useState, useRef, useEffect } from "react"; ...@@ -4,11 +4,9 @@ import React, { useState, useRef, useEffect } from "react";
import { import {
X, X,
KeyRound, KeyRound,
Copy,
Check,
AlertTriangle,
Sparkles, Sparkles,
ChevronDown, ChevronDown,
Check,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
...@@ -16,6 +14,7 @@ import { Label } from "@/components/ui/label"; ...@@ -16,6 +14,7 @@ import { Label } from "@/components/ui/label";
import { useLanguage } from "@/providers/language-provider"; import { useLanguage } from "@/providers/language-provider";
import { useCreateApiKey } from "@/hooks/use-developer"; import { useCreateApiKey } from "@/hooks/use-developer";
import { CreateApiKeyResponse } from "@/types/developer"; import { CreateApiKeyResponse } from "@/types/developer";
import { CopyButton } from "@/components/common/copy-button";
interface CreateApiKeyModalProps { interface CreateApiKeyModalProps {
isOpen: boolean; isOpen: boolean;
...@@ -39,16 +38,23 @@ export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) { ...@@ -39,16 +38,23 @@ export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) {
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const [createdKeyData, setCreatedKeyData] = useState<CreateApiKeyResponse | null>(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) { if (!isOpen) {
setName(""); setName("");
setExpirationChoice("NEVER"); setExpirationChoice("NEVER");
setCreatedKeyData(null); setCreatedKeyData(null);
setIsCopied(false);
} }
}, [isOpen]); }
const handleClose = () => {
setName("");
setExpirationChoice("NEVER");
setCreatedKeyData(null);
onClose();
};
useEffect(() => { useEffect(() => {
function handleClickOutside(event: MouseEvent) { function handleClickOutside(event: MouseEvent) {
...@@ -62,13 +68,6 @@ export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) { ...@@ -62,13 +68,6 @@ export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) {
if (!isOpen) return null; 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) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!name.trim()) return; if (!name.trim()) return;
...@@ -109,7 +108,7 @@ export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) { ...@@ -109,7 +108,7 @@ export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) {
</div> </div>
</div> </div>
<button <button
onClick={onClose} onClick={handleClose}
className="rounded-xl p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer" className="rounded-xl p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
...@@ -137,23 +136,20 @@ export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) { ...@@ -137,23 +136,20 @@ export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) {
value={createdKeyData.key} 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" className="rounded-2xl pr-10 font-mono text-xs bg-muted/60 text-emerald-600 dark:text-emerald-400 font-bold select-all"
/> />
<button <CopyButton
type="button" text={createdKeyData.key}
onClick={handleCopyKey}
className="absolute right-2 p-1.5 rounded-xl text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer" 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.apiKeys.newKeyNotice.copyBtn}
{isCopied ? ( showToast={true}
<Check className="h-4 w-4 text-emerald-500" /> toastMessage="Đã sao chép khóa API vào bộ nhớ tạm!"
) : ( iconSize="h-4 w-4"
<Copy className="h-4 w-4" /> />
)}
</button>
</div> </div>
</div> </div>
<div className="flex justify-end pt-3 border-t border-border/60"> <div className="flex justify-end pt-3 border-t border-border/60">
<Button <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" 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} {t.developer.apiKeys.newKeyNotice.close}
......
"use client"; "use client";
import React, { useState, useEffect } from "react"; import React, { useState } from "react";
import { import {
X, X,
Webhook, Webhook,
Sparkles,
Key,
Radio,
Check, Check,
RefreshCw, RefreshCw,
ShieldCheck,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
...@@ -19,6 +17,7 @@ import { ...@@ -19,6 +17,7 @@ import {
useUpdateWebhookConfig, useUpdateWebhookConfig,
} from "@/hooks/use-developer"; } from "@/hooks/use-developer";
import { WebhookConfig } from "@/types/developer"; import { WebhookConfig } from "@/types/developer";
import { CopyButton } from "@/components/common/copy-button";
interface CreateWebhookModalProps { interface CreateWebhookModalProps {
isOpen: boolean; isOpen: boolean;
...@@ -34,6 +33,15 @@ const AVAILABLE_EVENTS = [ ...@@ -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" }, { 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({ export function CreateWebhookModal({
isOpen, isOpen,
onClose, onClose,
...@@ -46,33 +54,33 @@ export function CreateWebhookModal({ ...@@ -46,33 +54,33 @@ export function CreateWebhookModal({
const isEditing = !!initialData; const isEditing = !!initialData;
const isSubmitting = createMutation.isPending || updateMutation.isPending; const isSubmitting = createMutation.isPending || updateMutation.isPending;
const [url, setUrl] = useState(""); const [url, setUrl] = useState(initialData?.url || "");
const [secret, setSecret] = useState(""); const [secret, setSecret] = useState(initialData ? "" : generateSecretString());
const [selectedEvents, setSelectedEvents] = useState<string[]>([ const [selectedEvents, setSelectedEvents] = useState<string[]>(
"crawl.job.completed", initialData?.events || ["crawl.job.completed", "crawl.job.failed"]
"crawl.job.failed", );
]); const [createdSecretData, setCreatedSecretData] = useState<{ url: string; secret: string } | null>(null);
const generateRandomSecret = () => {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_!#*";
let res = "";
for (let i = 0; i < 32; i++) {
res += chars.charAt(Math.floor(Math.random() * chars.length));
}
setSecret(res);
};
useEffect(() => { // 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) { if (initialData) {
setUrl(initialData.url); setUrl(initialData.url);
setSelectedEvents(initialData.events || []); setSelectedEvents(initialData.events || []);
setSecret(""); // Keep hidden for security on edit unless modified setSecret("");
} else { } else {
setUrl(""); setUrl("");
setSelectedEvents(["crawl.job.completed", "crawl.job.failed"]); setSelectedEvents(["crawl.job.completed", "crawl.job.failed"]);
generateRandomSecret(); setSecret(generateSecretString());
} }
}, [initialData, isOpen]); setCreatedSecretData(null);
}
const handleClose = () => {
setCreatedSecretData(null);
onClose();
};
if (!isOpen) return null; if (!isOpen) return null;
...@@ -97,17 +105,21 @@ export function CreateWebhookModal({ ...@@ -97,17 +105,21 @@ export function CreateWebhookModal({
secret: secret.trim() ? secret.trim() : undefined, secret: secret.trim() ? secret.trim() : undefined,
}, },
}); });
handleClose();
} else { } else {
if (!secret.trim()) { const activeSecret = secret.trim() || generateSecretString();
generateRandomSecret();
}
await createMutation.mutateAsync({ await createMutation.mutateAsync({
url: url.trim(), url: url.trim(),
secret: secret.trim(), secret: activeSecret,
events: selectedEvents, events: selectedEvents,
}); });
// Reveal secret once to user
setCreatedSecretData({ url: url.trim(), secret: activeSecret });
} }
onClose(); };
const generateRandomSecret = () => {
setSecret(generateSecretString());
}; };
return ( return (
...@@ -121,23 +133,72 @@ export function CreateWebhookModal({ ...@@ -121,23 +133,72 @@ export function CreateWebhookModal({
</div> </div>
<div> <div>
<h2 className="text-base font-bold tracking-tight text-foreground"> <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> </h2>
<p className="text-xs text-muted-foreground"> <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> </p>
</div> </div>
</div> </div>
<button <button
onClick={onClose} onClick={handleClose}
className="rounded-xl p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer" className="rounded-xl p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
</div> </div>
{/* Form */} {/* Content: Form or 1-Time Secret View */}
<form onSubmit={handleSubmit} className="py-4 space-y-4"> {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 */} {/* Endpoint URL */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-xs font-semibold"> <Label className="text-xs font-semibold">
...@@ -219,7 +280,7 @@ export function CreateWebhookModal({ ...@@ -219,7 +280,7 @@ export function CreateWebhookModal({
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={onClose} onClick={handleClose}
className="rounded-2xl text-xs h-9 px-4 cursor-pointer" className="rounded-2xl text-xs h-9 px-4 cursor-pointer"
> >
{t.developer.webhooks.modal.cancel} {t.developer.webhooks.modal.cancel}
...@@ -235,6 +296,7 @@ export function CreateWebhookModal({ ...@@ -235,6 +296,7 @@ export function CreateWebhookModal({
</Button> </Button>
</div> </div>
</form> </form>
)}
</div> </div>
</div> </div>
); );
......
"use client"; "use client";
import React, { useState } from "react"; import React, { useState, useMemo } from "react";
import { import {
X, X,
Play, Play,
CheckCircle2, CheckCircle2,
AlertCircle,
ExternalLink,
Code2, Code2,
Sparkles, Sparkles,
Eye,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { ExtractionTemplate } from "@/types/extraction-template"; import { ExtractionTemplate } from "@/types/extraction-template";
import { useLanguage } from "@/providers/language-provider"; 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 { interface TemplatePreviewModalProps {
isOpen: boolean; isOpen: boolean;
...@@ -28,16 +30,46 @@ export function TemplatePreviewModal({ ...@@ -28,16 +30,46 @@ export function TemplatePreviewModal({
template, template,
}: TemplatePreviewModalProps) { }: TemplatePreviewModalProps) {
const { t } = useLanguage(); 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 [isSimulating, setIsSimulating] = useState(false);
const [simulatedData, setSimulatedData] = useState<Record<string, unknown> | null>(null); 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(() => { // Construct simulated HTML content demonstrating XSS sanitization
if (template) { const simulatedHtml = useMemo(() => {
setTestUrl(`https://${template.domain}/sample-article-2026`); if (!simulatedData || !template) return "";
setSimulatedData(null); 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");
}, [template]); 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; if (!isOpen || !template) return null;
...@@ -66,12 +98,12 @@ export function TemplatePreviewModal({ ...@@ -66,12 +98,12 @@ export function TemplatePreviewModal({
}); });
setSimulatedData(result); setSimulatedData(result);
setIsSimulating(false); setIsSimulating(false);
}, 600); }, 500);
}; };
return ( 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="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 */} {/* Header */}
<div className="flex items-center justify-between pb-4 border-b border-border/60"> <div className="flex items-center justify-between pb-4 border-b border-border/60">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
...@@ -96,7 +128,7 @@ export function TemplatePreviewModal({ ...@@ -96,7 +128,7 @@ export function TemplatePreviewModal({
</div> </div>
{/* Content */} {/* 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"> <div className="space-y-1.5">
<Label className="text-xs font-semibold"> <Label className="text-xs font-semibold">
{t.templates.preview.testUrl} {t.templates.preview.testUrl}
...@@ -136,21 +168,62 @@ export function TemplatePreviewModal({ ...@@ -136,21 +168,62 @@ export function TemplatePreviewModal({
</div> </div>
</div> </div>
{/* Simulated Output */} {/* Simulated Output with Safe HTML / JSON Tabs */}
{simulatedData && ( {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="p-4 rounded-2xl border border-emerald-500/20 bg-card space-y-3 animate-in fade-in-50 duration-200">
<div className="flex items-center justify-between"> <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"> <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" /> <CheckCircle2 className="h-4 w-4" />
<span>{t.templates.preview.sampleResult}</span> <span>{t.templates.preview.sampleResult}</span>
</div> </div>
<span className="text-[10px] text-muted-foreground font-mono">
HTTP 200 OK {/* Tab Switcher: Safe HTML vs JSON */}
</span> <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> </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)} {previewTab === "html" ? (
</pre> <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>
)} )}
</div> </div>
......
"use client"; "use client";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect, useMemo } from "react";
import { import {
X, X,
ShieldCheck, ShieldCheck,
ShieldAlert, ShieldAlert,
Check, Check,
Sparkles,
AlertCircle, AlertCircle,
RefreshCw, RefreshCw,
User,
Info,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useLanguage } from "@/providers/language-provider"; import { useLanguage } from "@/providers/language-provider";
...@@ -55,28 +52,27 @@ export function UserRolesModal({ ...@@ -55,28 +52,27 @@ export function UserRolesModal({
// Mutation for syncing: PUT /users/:id/roles // Mutation for syncing: PUT /users/:id/roles
const assignRolesMutation = useAssignUserRoles(); const assignRolesMutation = useAssignUserRoles();
// Local state for checked role IDs // Local state for user manual overrides
const [selectedRoleIds, setSelectedRoleIds] = useState<string[]>([]); const [userOverrideIds, setUserOverrideIds] = useState<string[] | null>(null);
// Sync current user roles into local state with equality check to prevent loops // Derive initial roles from server data without useEffect setState
useEffect(() => { const defaultRoleIds = useMemo(
if (!isOpen || !userRoles || !Array.isArray(userRoles)) return; () => (Array.isArray(userRoles) ? userRoles.map((r) => r.id) : []),
const newIds = userRoles.map((r) => r.id); [userRoles]
setSelectedRoleIds((prev) => { );
if (
prev.length === newIds.length && const selectedRoleIds = userOverrideIds ?? defaultRoleIds;
prev.every((id) => newIds.includes(id))
) { const handleClose = () => {
return prev; setUserOverrideIds(null);
} onClose();
return newIds; };
});
}, [isOpen, userRoles]);
// Handle escape key // Handle escape key
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) { if (e.key === "Escape" && isOpen) {
setUserOverrideIds(null);
onClose(); onClose();
} }
}; };
...@@ -92,10 +88,11 @@ export function UserRolesModal({ ...@@ -92,10 +88,11 @@ export function UserRolesModal({
const handleToggleRole = (roleId: string) => { const handleToggleRole = (roleId: string) => {
if (isSelf) return; // Self-escalation guard if (isSelf) return; // Self-escalation guard
setSelectedRoleIds((prev) => const current = selectedRoleIds;
prev.includes(roleId) setUserOverrideIds(
? prev.filter((id) => id !== roleId) current.includes(roleId)
: [...prev, roleId] ? current.filter((id: string) => id !== roleId)
: [...current, roleId]
); );
}; };
...@@ -104,7 +101,7 @@ export function UserRolesModal({ ...@@ -104,7 +101,7 @@ export function UserRolesModal({
userId: user.id, userId: user.id,
roleIds: selectedRoleIds, roleIds: selectedRoleIds,
}); });
onClose(); handleClose();
}; };
return ( return (
...@@ -126,7 +123,7 @@ export function UserRolesModal({ ...@@ -126,7 +123,7 @@ export function UserRolesModal({
</div> </div>
</div> </div>
<button <button
onClick={onClose} onClick={handleClose}
className="rounded-xl p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer" className="rounded-xl p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
...@@ -270,7 +267,7 @@ export function UserRolesModal({ ...@@ -270,7 +267,7 @@ export function UserRolesModal({
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={onClose} onClick={handleClose}
className="rounded-2xl text-xs cursor-pointer" className="rounded-2xl text-xs cursor-pointer"
> >
{t.users.modalRoles.cancel} {t.users.modalRoles.cancel}
......
...@@ -138,12 +138,16 @@ export function UsersManagementView({ ...@@ -138,12 +138,16 @@ export function UsersManagementView({
isFetching, isFetching,
} = useUsersList(queryParams); } = useUsersList(queryParams);
const rawUsers: UserItem[] = usersData?.items || [];
const users = useMemo(() => { const users = useMemo(() => {
if (!sortBy) return rawUsers; const items: UserItem[] = usersData?.items || [];
return [...rawUsers].sort((a, b) => { if (!sortBy) return items;
let aVal: any = a[sortBy as keyof UserItem]; return [...items].sort((a, b) => {
let bVal: any = b[sortBy as keyof UserItem]; 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") { if (sortBy === "fullName") {
aVal = a.fullName || a.email; aVal = a.fullName || a.email;
bVal = b.fullName || b.email; bVal = b.fullName || b.email;
...@@ -154,7 +158,7 @@ export function UsersManagementView({ ...@@ -154,7 +158,7 @@ export function UsersManagementView({
const cmp = aVal < bVal ? -1 : 1; const cmp = aVal < bVal ? -1 : 1;
return sortOrder === "asc" ? cmp : -cmp; return sortOrder === "asc" ? cmp : -cmp;
}); });
}, [rawUsers, sortBy, sortOrder]); }, [usersData?.items, sortBy, sortOrder]);
const meta = usersData?.meta || { const meta = usersData?.meta || {
total: 0, total: 0,
......
...@@ -2,7 +2,12 @@ ...@@ -2,7 +2,12 @@
import { useEffect, useRef, useState, useCallback } from "react"; 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> { export interface UseEventSourceOptions<T = unknown> {
enabled?: boolean; enabled?: boolean;
...@@ -12,7 +17,8 @@ export interface UseEventSourceOptions<T = unknown> { ...@@ -12,7 +17,8 @@ export interface UseEventSourceOptions<T = unknown> {
onDone?: (data: { status?: string; message?: string }) => void; onDone?: (data: { status?: string; message?: string }) => void;
onMessage?: (data: T, eventName: string) => void; onMessage?: (data: T, eventName: string) => void;
onError?: (error: Event) => void; onError?: (error: Event) => void;
reconnectInterval?: number; initialRetryDelay?: number;
maxRetryDelay?: number;
maxRetries?: number; maxRetries?: number;
} }
...@@ -21,13 +27,29 @@ export interface UseEventSourceResult<T = unknown> { ...@@ -21,13 +27,29 @@ export interface UseEventSourceResult<T = unknown> {
data: T | null; data: T | null;
lastEventName: string | null; lastEventName: string | null;
error: Event | null; error: Event | null;
retryCount: number;
nextRetryDelayMs: number;
reconnect: () => void; reconnect: () => void;
close: () => 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. * 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>( export function useEventSource<T = unknown>(
url: string | null, url: string | null,
...@@ -41,18 +63,27 @@ export function useEventSource<T = unknown>( ...@@ -41,18 +63,27 @@ export function useEventSource<T = unknown>(
onDone, onDone,
onMessage, onMessage,
onError, onError,
reconnectInterval = 4000, initialRetryDelay = 1000,
maxRetries = 5, maxRetryDelay = 30000,
maxRetries = 10,
} = options; } = 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 [data, setData] = useState<T | null>(null);
const [lastEventName, setLastEventName] = useState<string | null>(null); const [lastEventName, setLastEventName] = useState<string | null>(null);
const [error, setError] = useState<Event | 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 eventSourceRef = useRef<EventSource | null>(null);
const retryCountRef = useRef(0); const retryCountRef = useRef(0);
const retryTimerRef = useRef<NodeJS.Timeout | null>(null); const retryTimerRef = useRef<NodeJS.Timeout | null>(null);
const connectRef = useRef<() => void>(() => {});
// Safe ref callbacks to avoid unnecessary reconnections // Safe ref callbacks to avoid unnecessary reconnections
const callbacksRef = useRef({ onInitial, onProgress, onDone, onMessage, onError }); const callbacksRef = useRef({ onInitial, onProgress, onDone, onMessage, onError });
...@@ -75,12 +106,10 @@ export function useEventSource<T = unknown>( ...@@ -75,12 +106,10 @@ export function useEventSource<T = unknown>(
cleanUp(); cleanUp();
if (!url || !enabled || typeof window === "undefined") { if (!url || !enabled || typeof window === "undefined") {
setStatus("CLOSED");
return; return;
} }
try { try {
setStatus("CONNECTING");
const es = new EventSource(url, { withCredentials }); const es = new EventSource(url, { withCredentials });
eventSourceRef.current = es; eventSourceRef.current = es;
...@@ -88,6 +117,8 @@ export function useEventSource<T = unknown>( ...@@ -88,6 +117,8 @@ export function useEventSource<T = unknown>(
setStatus("OPEN"); setStatus("OPEN");
setError(null); setError(null);
retryCountRef.current = 0; retryCountRef.current = 0;
setRetryCount(0);
setNextRetryDelayMs(0);
}; };
// Handle custom SSE event: "initial" // Handle custom SSE event: "initial"
...@@ -146,27 +177,45 @@ export function useEventSource<T = unknown>( ...@@ -146,27 +177,45 @@ export function useEventSource<T = unknown>(
setError(err); setError(err);
callbacksRef.current.onError?.(err); callbacksRef.current.onError?.(err);
// If readyState is CLOSED or we can retry // Close stale instance
if (es.readyState === EventSource.CLOSED) { es.close();
setStatus("CLOSED");
es.close(); if (retryCountRef.current < maxRetries) {
if (retryCountRef.current < maxRetries) { const delay = calculateBackoffDelay(
retryCountRef.current += 1; retryCountRef.current,
setStatus("CONNECTING"); initialRetryDelay,
retryTimerRef.current = setTimeout(() => { maxRetryDelay
connect(); );
}, reconnectInterval); retryCountRef.current += 1;
} else { setRetryCount(retryCountRef.current);
setStatus("ERROR"); setNextRetryDelayMs(delay);
} setStatus("RECONNECTING");
retryTimerRef.current = setTimeout(() => {
connectRef.current();
}, delay);
} else { } else {
setStatus("ERROR"); setStatus("ERROR");
} }
}; };
} catch { } 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(() => { useEffect(() => {
connect(); connect();
...@@ -177,6 +226,9 @@ export function useEventSource<T = unknown>( ...@@ -177,6 +226,9 @@ export function useEventSource<T = unknown>(
const reconnect = useCallback(() => { const reconnect = useCallback(() => {
retryCountRef.current = 0; retryCountRef.current = 0;
setRetryCount(0);
setNextRetryDelayMs(0);
setStatus("CONNECTING");
connect(); connect();
}, [connect]); }, [connect]);
...@@ -185,11 +237,38 @@ export function useEventSource<T = unknown>( ...@@ -185,11 +237,38 @@ export function useEventSource<T = unknown>(
setStatus("CLOSED"); setStatus("CLOSED");
}, [cleanUp]); }, [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 { return {
status, status: currentStatus,
data, data,
lastEventName, lastEventName,
error, error,
retryCount,
nextRetryDelayMs,
reconnect, reconnect,
close, 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 });
}
This diff is collapsed.
/**
* 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 ...@@ -13,33 +13,39 @@ const LanguageContext = createContext<LanguageContextType | undefined>(undefined
const LANGUAGE_STORAGE_KEY = "data_crawler_lang"; 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 }) { export function LanguageProvider({ children }: { children: ReactNode }) {
// Always initialize with "vi" to match SSR output and prevent hydration mismatch const [locale, setLocaleState] = useState<Locale>(getInitialLocale);
const [locale, setLocaleState] = useState<Locale>("vi");
React.useEffect(() => { React.useEffect(() => {
try { try {
const saved = localStorage.getItem(LANGUAGE_STORAGE_KEY) as Locale | null; document.documentElement.lang = locale;
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;
}
} catch { } catch {
// Ignore errors if localStorage is restricted // Ignore DOM update errors
} }
}, []); }, [locale]);
const setLocale = (newLocale: Locale) => { const setLocale = (newLocale: Locale) => {
setLocaleState(newLocale); setLocaleState(newLocale);
if (typeof window !== "undefined") { 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; document.documentElement.lang = newLocale;
} }
}; };
......
...@@ -9,6 +9,8 @@ import { ThemeProvider } from "./theme-provider"; ...@@ -9,6 +9,8 @@ import { ThemeProvider } from "./theme-provider";
import { LanguageProvider } from "./language-provider"; import { LanguageProvider } from "./language-provider";
import { AuthProvider } from "./auth-provider"; import { AuthProvider } from "./auth-provider";
import { NetworkStatusBanner } from "@/components/common/network-status-banner";
interface ProvidersProps { interface ProvidersProps {
children: ReactNode; children: ReactNode;
} }
...@@ -26,6 +28,7 @@ export function Providers({ children }: ProvidersProps) { ...@@ -26,6 +28,7 @@ export function Providers({ children }: ProvidersProps) {
<LanguageProvider> <LanguageProvider>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AuthProvider> <AuthProvider>
<NetworkStatusBanner />
{children} {children}
<Toaster position="top-right" richColors closeButton /> <Toaster position="top-right" richColors closeButton />
<ReactQueryDevtools initialIsOpen={false} /> <ReactQueryDevtools initialIsOpen={false} />
......
...@@ -3,9 +3,9 @@ import { ApiResponse, PaginatedResponse } from "@/types/api"; ...@@ -3,9 +3,9 @@ import { ApiResponse, PaginatedResponse } from "@/types/api";
import { import {
CrawlExport, CrawlExport,
CrawlExportQueryDto, CrawlExportQueryDto,
CreateCrawlExportDto,
ExportType, ExportType,
} from "@/types/export"; } from "@/types/export";
import { downloadBlob, generateStandardFilename } from "@/lib/download-helper";
const MOCK_CRAWL_EXPORTS: CrawlExport[] = [ const MOCK_CRAWL_EXPORTS: CrawlExport[] = [
{ {
...@@ -164,31 +164,30 @@ export class CrawlExportService { ...@@ -164,31 +164,30 @@ export class CrawlExportService {
} }
async downloadExport(exportId: string, fileName?: string): Promise<void> { async downloadExport(exportId: string, fileName?: string): Promise<void> {
const defaultFilename =
fileName ||
generateStandardFilename({
category: "export",
identifier: exportId,
extension: "csv",
});
try { try {
const response = await apiClient.get(`/exports/${exportId}/download`, { const response = await apiClient.get(`/exports/${exportId}/download`, {
responseType: "blob", responseType: "blob",
}); });
const blob = new Blob([response.data]); const headerContentType = response.headers?.["content-type"];
const url = window.URL.createObjectURL(blob); downloadBlob(response.data, {
const a = document.createElement("a"); filename: defaultFilename,
a.href = url; mimeType: typeof headerContentType === "string" ? headerContentType : "application/octet-stream",
a.download = fileName || `export_${exportId}.bin`; });
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch { } catch {
// Fallback mock download trigger // Fallback mock download trigger with standardized filename
const mockContent = `Export dataset for ${exportId} generated at ${new Date().toISOString()}`; const mockContent = `Export dataset for ${exportId} generated at ${new Date().toISOString()}`;
const blob = new Blob([mockContent], { type: "text/plain;charset=utf-8" }); downloadBlob(mockContent, {
const url = window.URL.createObjectURL(blob); filename: defaultFilename,
const a = document.createElement("a"); mimeType: "text/csv;charset=utf-8",
a.href = url; });
a.download = fileName || `export_${exportId}.txt`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} }
} }
......
...@@ -99,7 +99,7 @@ const MOCK_WEBHOOK_DELIVERIES: WebhookDelivery[] = [ ...@@ -99,7 +99,7 @@ const MOCK_WEBHOOK_DELIVERIES: WebhookDelivery[] = [
let localApiKeys = [...MOCK_API_KEYS]; let localApiKeys = [...MOCK_API_KEYS];
let localWebhooks = [...MOCK_WEBHOOK_CONFIGS]; let localWebhooks = [...MOCK_WEBHOOK_CONFIGS];
let localDeliveries = [...MOCK_WEBHOOK_DELIVERIES]; const localDeliveries = [...MOCK_WEBHOOK_DELIVERIES];
export class DeveloperService { export class DeveloperService {
// API Keys // API Keys
......
...@@ -38,6 +38,7 @@ export interface WebhookConfig { ...@@ -38,6 +38,7 @@ export interface WebhookConfig {
id: string; id: string;
userId?: string; userId?: string;
url: string; url: string;
secret?: string;
events: string[]; events: string[];
isActive: boolean; isActive: boolean;
createdAt: string; 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