Commit cf38fc59 authored by ThinhNC's avatar ThinhNC

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

parent c380673d
......@@ -19,6 +19,7 @@
"axios": "^1.20.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dompurify": "^3.4.15",
"lucide-react": "^1.41.0",
"next": "16.3.4",
"next-themes": "^0.4.6",
......@@ -31,6 +32,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/dompurify": "^3.2.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
......
......@@ -38,6 +38,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
dompurify:
specifier: ^3.4.15
version: 3.4.15
lucide-react:
specifier: ^1.41.0
version: 1.41.0(react@19.2.8)
......@@ -69,6 +72,9 @@ importers:
'@tailwindcss/postcss':
specifier: ^4
version: 4.3.3
'@types/dompurify':
specifier: ^3.2.0
version: 3.2.0
'@types/node':
specifier: ^20
version: 20.19.43
......@@ -995,6 +1001,10 @@ packages:
'@tybys/wasm-util@0.10.3':
resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
'@types/dompurify@3.2.0':
resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==}
deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
......@@ -1015,6 +1025,9 @@ packages:
'@types/react@19.2.18':
resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@typescript-eslint/eslint-plugin@8.69.0':
resolution: {integrity: sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
......@@ -1426,6 +1439,9 @@ packages:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
dompurify@3.4.15:
resolution: {integrity: sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
......@@ -3343,6 +3359,10 @@ snapshots:
tslib: 2.8.1
optional: true
'@types/dompurify@3.2.0':
dependencies:
dompurify: 3.4.15
'@types/estree@1.0.9': {}
'@types/json-schema@7.0.15': {}
......@@ -3361,6 +3381,9 @@ snapshots:
dependencies:
csstype: 3.2.3
'@types/trusted-types@2.0.7':
optional: true
'@typescript-eslint/eslint-plugin@8.69.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
......@@ -3779,6 +3802,10 @@ snapshots:
dependencies:
esutils: 2.0.3
dompurify@3.4.15:
optionalDependencies:
'@types/trusted-types': 2.0.7
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
......
This diff is collapsed.
......@@ -11,6 +11,7 @@ import {
} from "@/hooks/use-developer";
import { ApiKey } from "@/types/developer";
import { CreateApiKeyModal } from "@/components/developer/create-api-key-modal";
import { CopyButton } from "@/components/common/copy-button";
export default function ApiKeysPage() {
const { t, locale } = useLanguage();
......@@ -92,8 +93,23 @@ export default function ApiKeysPage() {
<td className="px-5 py-4 font-bold text-foreground">
{key.name}
</td>
<td className="px-4 py-4 font-mono text-muted-foreground">
{key.keyPrefix}...****
<td className="px-4 py-4">
<div className="flex items-center gap-2 font-mono text-xs">
<span className="text-muted-foreground/80 tracking-widest select-none">
••••••••••••
</span>
<span className="font-semibold text-emerald-600 dark:text-emerald-400">
{(key.keyPrefix || "").slice(-4) || "key"}
</span>
<CopyButton
text={key.key || `dc_live_••••••••••••${(key.keyPrefix || "").slice(-4) || "key"}`}
title={t.developer.apiKeys.table.copiedKey}
showToast={true}
toastMessage={t.developer.apiKeys.table.copiedKey}
className="p-1 rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
iconSize="h-3.5 w-3.5"
/>
</div>
</td>
<td className="px-4 py-4">
<button
......
"use client";
import React, { useState } from "react";
import { Terminal, Code2, Check, Copy } from "lucide-react";
import React from "react";
import { Terminal, Code2 } from "lucide-react";
import { useLanguage } from "@/providers/language-provider";
import { CopyButton } from "@/components/common/copy-button";
export default function DeveloperDocsPage() {
const { t } = useLanguage();
const [copiedSnippet, setCopiedSnippet] = useState<string | null>(null);
const handleCopySnippet = (snippet: string, key: string) => {
navigator.clipboard.writeText(snippet);
setCopiedSnippet(key);
setTimeout(() => setCopiedSnippet(null), 2000);
};
const curlSnippet = `curl -X POST "https://api.datacrawler.io/api/v1/crawl-jobs" \\
-H "X-API-Key: dc_live_your_secret_key_here" \\
......@@ -58,20 +52,17 @@ console.log('Job Created:', response.data);`;
<Terminal className="h-4 w-4 text-emerald-500" />
cURL
</span>
<button
type="button"
onClick={() => handleCopySnippet(curlSnippet, "curl")}
className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground cursor-pointer"
>
{copiedSnippet === "curl" ? (
<Check className="h-3.5 w-3.5 text-emerald-500" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
<span>{copiedSnippet === "curl" ? "Đã sao chép" : "Sao chép"}</span>
</button>
<CopyButton
variant="badge"
text={curlSnippet}
label="Sao chép cURL"
copiedLabel="Đã sao chép!"
title="Sao chép lệnh cURL"
showToast={true}
toastMessage="Đã sao chép lệnh cURL vào bộ nhớ tạm!"
/>
</div>
<pre className="p-4 rounded-2xl bg-muted/60 font-mono text-xs text-foreground overflow-x-auto">
<pre className="p-4 rounded-2xl bg-[#070e17] border border-slate-800/80 font-mono text-xs text-emerald-400 overflow-x-auto selection:bg-emerald-500/30">
{curlSnippet}
</pre>
</div>
......@@ -83,20 +74,17 @@ console.log('Job Created:', response.data);`;
<Code2 className="h-4 w-4 text-emerald-500" />
Node.js / Axios
</span>
<button
type="button"
onClick={() => handleCopySnippet(nodeSnippet, "node")}
className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground cursor-pointer"
>
{copiedSnippet === "node" ? (
<Check className="h-3.5 w-3.5 text-emerald-500" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
<span>{copiedSnippet === "node" ? "Đã sao chép" : "Sao chép"}</span>
</button>
<CopyButton
variant="badge"
text={nodeSnippet}
label="Sao chép Node.js"
copiedLabel="Đã sao chép!"
title="Sao chép đoạn mã Node.js"
showToast={true}
toastMessage="Đã sao chép mã nguồn Node.js vào bộ nhớ tạm!"
/>
</div>
<pre className="p-4 rounded-2xl bg-muted/60 font-mono text-xs text-foreground overflow-x-auto">
<pre className="p-4 rounded-2xl bg-[#070e17] border border-slate-800/80 font-mono text-xs text-cyan-300 overflow-x-auto selection:bg-cyan-500/30">
{nodeSnippet}
</pre>
</div>
......
......@@ -8,9 +8,16 @@ import {
Trash2,
Send,
RefreshCw,
Eye,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useLanguage } from "@/providers/language-provider";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
useWebhookConfigsList,
useDeleteWebhookConfig,
......@@ -18,8 +25,10 @@ import {
useWebhookDeliveriesList,
useRedeliverWebhook,
} from "@/hooks/use-developer";
import { WebhookConfig } from "@/types/developer";
import { WebhookConfig, WebhookDelivery } from "@/types/developer";
import { CreateWebhookModal } from "@/components/developer/create-webhook-modal";
import { CopyButton } from "@/components/common/copy-button";
import { JsonViewer } from "@/components/common/json-viewer";
export default function WebhooksPage() {
const { t, locale } = useLanguage();
......@@ -31,6 +40,7 @@ export default function WebhooksPage() {
const [isWebhookModalOpen, setIsWebhookModalOpen] = useState(false);
const [editingWebhook, setEditingWebhook] = useState<WebhookConfig | null>(null);
const [selectedDelivery, setSelectedDelivery] = useState<WebhookDelivery | null>(null);
const deliveries = deliveriesData?.items || [];
......@@ -107,6 +117,13 @@ export default function WebhooksPage() {
<p className="font-mono text-xs font-bold text-foreground truncate">
{wh.url}
</p>
<CopyButton
text={wh.url}
title="Sao chép URL Webhook"
showToast={true}
toastMessage="Đã sao chép URL Webhook!"
className="p-1 h-6 w-6 text-muted-foreground hover:text-foreground"
/>
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold border ${
wh.isActive
......@@ -130,6 +147,28 @@ export default function WebhooksPage() {
</span>
))}
</div>
{/* Secret Masking */}
<div className="flex items-center gap-2 text-xs font-mono pt-1 text-muted-foreground">
<span className="text-[11px] font-sans font-medium text-muted-foreground/90">
{t.developer.webhooks.table.secretLabel}
</span>
<div className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-lg bg-muted/60 border border-border/50 text-xs">
<span className="text-muted-foreground/80 tracking-widest select-none">
••••••••••••
</span>
<span className="font-semibold text-emerald-600 dark:text-emerald-400">
{(wh.secret ? wh.secret.slice(-4) : wh.id.slice(-4))}
</span>
<CopyButton
text={wh.secret || `whsec_${wh.id.replace(/[^a-zA-Z0-9]/g, "")}`}
title={t.developer.webhooks.table.copiedSecret}
showToast={true}
toastMessage={t.developer.webhooks.table.copiedSecret}
className="p-1 h-6 w-6 text-muted-foreground hover:text-foreground ml-1"
/>
</div>
</div>
</div>
<div className="flex items-center gap-2 self-start lg:self-center">
......@@ -233,7 +272,17 @@ export default function WebhooksPage() {
}
)}
</td>
<td className="px-5 py-3.5 text-right">
<td className="px-5 py-3.5 text-right space-x-1">
<Button
size="sm"
variant="ghost"
onClick={() => setSelectedDelivery(del)}
className="rounded-xl h-7 px-2 text-[11px] text-muted-foreground hover:text-foreground hover:bg-muted cursor-pointer"
title="Xem chi tiết dữ liệu Webhook (JSON)"
>
<Eye className="h-3 w-3 mr-1 text-emerald-500" />
<span>Dữ liệu</span>
</Button>
<Button
size="sm"
variant="ghost"
......@@ -252,6 +301,62 @@ export default function WebhooksPage() {
)}
</div>
{/* Webhook Delivery Payload & Response Inspector Dialog */}
{selectedDelivery && (
<Dialog
open={!!selectedDelivery}
onOpenChange={(open) => !open && setSelectedDelivery(null)}
>
<DialogContent className="max-w-2xl bg-card border-emerald-500/25 p-6 rounded-3xl space-y-4">
<DialogHeader className="border-b border-border/60 pb-3">
<div className="flex items-center justify-between">
<DialogTitle className="text-base font-bold text-foreground flex items-center gap-2">
<Webhook className="h-4 w-4 text-emerald-500" />
<span>Chi Tiết Giao Vận: {selectedDelivery.event}</span>
</DialogTitle>
<span
className={`rounded-full px-2.5 py-0.5 text-[10px] font-semibold border ${
selectedDelivery.status === "SUCCESS"
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20"
}`}
>
{selectedDelivery.status} {selectedDelivery.statusCode ? `(${selectedDelivery.statusCode})` : ""}
</span>
</div>
</DialogHeader>
<div className="space-y-4 pt-1 max-h-[70vh] overflow-y-auto pr-1">
{/* Event Payload */}
<div className="space-y-2">
<JsonViewer
data={selectedDelivery.payload || {
event: selectedDelivery.event,
deliveryId: selectedDelivery.id,
crawlJobId: selectedDelivery.crawlJobId,
timestamp: selectedDelivery.createdAt,
status: selectedDelivery.status,
}}
title="Dữ Liệu Sự Kiện (Webhook Payload)"
filename={`webhook_payload_${selectedDelivery.id}.json`}
maxHeight="220px"
/>
</div>
{/* Response Body */}
<div className="space-y-2">
<JsonViewer
data={selectedDelivery.responseBody || { received: true, statusCode: selectedDelivery.statusCode || 200 }}
title="Phản Hồi Máy Chủ Nhận (HTTP Response Body)"
filename={`webhook_response_${selectedDelivery.id}.json`}
maxHeight="200px"
/>
</div>
</div>
</DialogContent>
</Dialog>
)}
{/* Modal */}
<CreateWebhookModal
isOpen={isWebhookModalOpen}
......
......@@ -75,14 +75,23 @@ export default function ProfileSettingsPage() {
// Keep state synced with user profile
React.useEffect(() => {
if (profile) {
if (profile.fullName) setFullName(profile.fullName);
if (profile.fullName !== undefined && profile.fullName !== null) {
setFullName(profile.fullName);
}
if (profile.avatarUrl) setAvatarPreview(profile.avatarUrl);
} else if (user) {
if (user.fullName) setFullName(user.fullName);
if (user.fullName !== undefined && user.fullName !== null) {
setFullName(user.fullName);
}
if (user.avatarUrl) setAvatarPreview(user.avatarUrl);
}
}, [profile, user]);
// Compute if personal profile has changes
const initialFullName = (profile?.fullName ?? user?.fullName ?? "").trim();
const isProfileChanged =
fullName.trim() !== initialFullName && fullName.trim().length > 0;
// Compute Password Strength
const passwordStrength = React.useMemo(() => {
if (!newPassword) return 0;
......@@ -113,10 +122,15 @@ export default function ProfileSettingsPage() {
// Update Profile Name Handler
const handleUpdateProfile = (e: React.FormEvent) => {
e.preventDefault();
if (!fullName.trim()) return;
if (!isProfileChanged || updateProfileMutation.isPending) return;
updateProfileMutation.mutate({ fullName: fullName.trim() });
};
// Revert Profile Name Handler
const handleCancelUpdateProfile = () => {
setFullName(profile?.fullName ?? user?.fullName ?? "");
};
// Change Password Handler
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault();
......@@ -351,11 +365,23 @@ export default function ProfileSettingsPage() {
</div>
</div>
<div className="flex justify-end pt-2">
<div className="flex items-center justify-end gap-2.5 pt-2">
{isProfileChanged && (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleCancelUpdateProfile}
disabled={updateProfileMutation.isPending}
className="rounded-2xl text-xs h-9 px-4 border-border/80 hover:bg-muted cursor-pointer"
>
{t.profile.personal.cancelBtn}
</Button>
)}
<Button
type="submit"
disabled={updateProfileMutation.isPending}
className="rounded-2xl text-xs h-9 px-5 bg-emerald-600 hover:bg-emerald-700 text-white shadow-sm shadow-emerald-600/20 cursor-pointer"
disabled={!isProfileChanged || updateProfileMutation.isPending || isLoadingProfile}
className="rounded-2xl text-xs h-9 px-5 bg-emerald-600 hover:bg-emerald-700 text-white shadow-sm shadow-emerald-600/20 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
{updateProfileMutation.isPending
? t.profile.personal.saving
......
"use client";
import React, { useState, useEffect } from "react";
import React, { useEffect } from "react";
import {
X,
Copy,
Check,
AlertTriangle,
Clock,
User,
Globe,
Monitor,
Shield,
FileCode,
ArrowRight,
Database,
Key,
Layers,
Sparkles,
} from "lucide-react";
import { Button } from "@/components/ui/button";
......@@ -29,8 +23,10 @@ import {
formatDate,
formatRelativeTime,
parseUserAgent,
cn,
} from "@/lib/utils";
import { JsonViewer } from "@/components/common/json-viewer";
import { CopyButton } from "@/components/common/copy-button";
import { generateStandardFilename } from "@/lib/download-helper";
interface AuditLogDetailDrawerProps {
log: AuditLogItem | null;
......@@ -44,9 +40,6 @@ export function AuditLogDetailDrawer({
onClose,
}: AuditLogDetailDrawerProps) {
const { t, locale } = useLanguage();
const [copiedPayload, setCopiedPayload] = useState(false);
const [copiedIp, setCopiedIp] = useState(false);
const [copiedUserId, setCopiedUserId] = useState(false);
// Handle ESC key to close
useEffect(() => {
......@@ -94,29 +87,6 @@ export function AuditLogDetailDrawer({
? details.updatedFields
: null;
// Copy JSON handler
const handleCopyPayload = () => {
navigator.clipboard.writeText(JSON.stringify(details, null, 2));
setCopiedPayload(true);
setTimeout(() => setCopiedPayload(false), 2000);
};
const handleCopyIp = () => {
if (log.ipAddress) {
navigator.clipboard.writeText(log.ipAddress);
setCopiedIp(true);
setTimeout(() => setCopiedIp(false), 2000);
}
};
const handleCopyUserId = () => {
if (log.userId) {
navigator.clipboard.writeText(log.userId);
setCopiedUserId(true);
setTimeout(() => setCopiedUserId(false), 2000);
}
};
const renderActionBadge = () => {
switch (category) {
case "CREATE":
......@@ -270,17 +240,14 @@ export function AuditLogDetailDrawer({
</span>
<div className="flex items-center gap-1.5 font-mono text-[11px] text-foreground">
<span className="truncate">{log.userId}</span>
<button
onClick={handleCopyUserId}
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
<CopyButton
text={log.userId}
title="Sao chép User ID"
>
{copiedUserId ? (
<Check className="h-3 w-3 text-emerald-500" />
) : (
<Copy className="h-3 w-3" />
)}
</button>
showToast={true}
toastMessage="Đã sao chép User ID!"
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
iconSize="h-3 w-3"
/>
</div>
</div>
)}
......@@ -294,17 +261,14 @@ export function AuditLogDetailDrawer({
<Globe className="h-3.5 w-3.5 text-emerald-500 shrink-0" />
<span>{log.ipAddress || "127.0.0.1"}</span>
{log.ipAddress && (
<button
onClick={handleCopyIp}
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
<CopyButton
text={log.ipAddress}
title="Sao chép IP"
>
{copiedIp ? (
<Check className="h-3 w-3 text-emerald-500" />
) : (
<Copy className="h-3 w-3" />
)}
</button>
showToast={true}
toastMessage="Đã sao chép IP!"
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
iconSize="h-3 w-3"
/>
)}
</div>
</div>
......@@ -361,11 +325,20 @@ export function AuditLogDetailDrawer({
<span className="h-2 w-2 rounded-full bg-red-500" />
{t.auditLogs.drawer.diffBefore}
</div>
<pre className="font-mono text-[11px] text-red-700 dark:text-red-300 overflow-x-auto whitespace-pre-wrap leading-relaxed">
{beforeData
? JSON.stringify(beforeData, null, 2)
: "(Không có giá trị ban đầu)"}
</pre>
{beforeData ? (
<JsonViewer
data={beforeData}
title={t.auditLogs.drawer.diffBefore}
showActions={false}
showLineNumbers={false}
maxHeight="180px"
className="bg-transparent border-0 shadow-none p-0 text-[11px]"
/>
) : (
<p className="font-mono text-[11px] text-red-700/80 dark:text-red-300/80 italic">
(Không có giá trị ban đầu)
</p>
)}
</div>
{/* After Column */}
......@@ -374,57 +347,38 @@ export function AuditLogDetailDrawer({
<span className="h-2 w-2 rounded-full bg-emerald-500" />
{t.auditLogs.drawer.diffAfter}
</div>
<pre className="font-mono text-[11px] text-emerald-700 dark:text-emerald-300 overflow-x-auto whitespace-pre-wrap leading-relaxed">
{afterData
? JSON.stringify(afterData, null, 2)
: "(Không có giá trị mới)"}
</pre>
{afterData ? (
<JsonViewer
data={afterData}
title={t.auditLogs.drawer.diffAfter}
showActions={false}
showLineNumbers={false}
maxHeight="180px"
className="bg-transparent border-0 shadow-none p-0 text-[11px]"
/>
) : (
<p className="font-mono text-[11px] text-emerald-700/80 dark:text-emerald-300/80 italic">
(Không có giá trị mới)
</p>
)}
</div>
</div>
</div>
)}
{/* Request Payload JSON Inspector */}
<div className="rounded-2xl border border-emerald-500/20 bg-card/60 p-5 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs font-bold text-foreground">
<FileCode className="h-4 w-4 text-emerald-500" />
<span>{t.auditLogs.drawer.payloadTitle}</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleCopyPayload}
className="h-7 px-2.5 rounded-xl text-xs text-muted-foreground hover:text-emerald-500 hover:bg-emerald-500/10 cursor-pointer transition-colors"
>
{copiedPayload ? (
<>
<Check className="h-3.5 w-3.5 mr-1 text-emerald-500" />
<span className="text-emerald-500 font-semibold">
{t.auditLogs.drawer.copied}
</span>
</>
) : (
<>
<Copy className="h-3.5 w-3.5 mr-1" />
<span>{t.auditLogs.drawer.copyJson}</span>
</>
)}
</Button>
</div>
{Object.keys(details).length > 0 ? (
<div className="relative rounded-xl border border-emerald-500/20 bg-[#061a14]/90 p-4 text-[#A8D5BA] overflow-x-auto shadow-inner">
<pre className="font-mono text-xs leading-relaxed">
{JSON.stringify(details, null, 2)}
</pre>
</div>
) : (
<p className="text-xs text-muted-foreground italic py-3 text-center">
{t.auditLogs.drawer.noPayload}
</p>
)}
<div className="space-y-2">
<JsonViewer
data={details}
title={t.auditLogs.drawer.payloadTitle}
filename={generateStandardFilename({
category: "audit",
identifier: log.id,
extension: "json",
})}
maxHeight="320px"
emptyMessage={t.auditLogs.drawer.noPayload}
/>
</div>
</div>
......
"use client";
import React, { useState, useEffect, useRef } from "react";
import { Copy, Check } from "lucide-react";
import { toast } from "sonner";
import { useLanguage } from "@/providers/language-provider";
import { cn } from "@/lib/utils";
export interface CopyButtonProps {
text: string;
label?: string;
copiedLabel?: string;
title?: string;
showToast?: boolean;
toastMessage?: string;
variant?: "icon" | "badge" | "button" | "ghost" | "inline";
className?: string;
iconSize?: string;
onCopy?: () => void;
disabled?: boolean;
}
/**
* Reusable CopyButton component with smooth checkmark transition (2s duration)
* and optional toast feedback. Complies with React 19 standards and provides
* automatic English / Vietnamese localization when props are omitted.
*/
export function CopyButton({
text,
label,
copiedLabel,
title,
showToast = false,
toastMessage,
variant = "icon",
className,
iconSize = "h-3.5 w-3.5",
onCopy,
disabled = false,
}: CopyButtonProps) {
const { t } = useLanguage();
const [isCopied, setIsCopied] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const finalCopiedLabel = copiedLabel ?? t.copyAction.copied;
const finalTitle = title ?? (isCopied ? finalCopiedLabel : t.copyAction.tooltip);
const finalToastMessage = toastMessage ?? t.copyAction.toastSuccess;
const defaultLabel = t.copyAction.copy;
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
const handleCopy = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (disabled || !text) return;
try {
await navigator.clipboard.writeText(text);
setIsCopied(true);
if (showToast) {
toast.success(finalToastMessage);
}
if (onCopy) {
onCopy();
}
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setIsCopied(false);
}, 2000);
} catch {
toast.error(t.copyAction.toastError);
}
};
const renderIcon = () => {
if (isCopied) {
return (
<Check
className={cn(
iconSize,
"text-emerald-500 animate-in zoom-in-50 duration-200"
)}
/>
);
}
return (
<Copy
className={cn(
iconSize,
"text-muted-foreground transition-colors group-hover:text-foreground"
)}
/>
);
};
if (variant === "badge") {
return (
<button
type="button"
onClick={handleCopy}
disabled={disabled}
title={finalTitle}
className={cn(
"group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-xl text-xs font-medium border transition-all cursor-pointer select-none",
isCopied
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "border-border/70 bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground",
disabled && "opacity-50 cursor-not-allowed",
className
)}
>
{renderIcon()}
<span>{isCopied ? finalCopiedLabel : label || defaultLabel}</span>
</button>
);
}
if (variant === "button") {
return (
<button
type="button"
onClick={handleCopy}
disabled={disabled}
title={finalTitle}
className={cn(
"group inline-flex items-center gap-1.5 h-8 px-3 rounded-xl text-xs font-medium border transition-all cursor-pointer select-none",
isCopied
? "border-emerald-500/40 bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 font-semibold shadow-sm shadow-emerald-500/10"
: "border-border bg-card/80 text-foreground hover:bg-muted/70",
disabled && "opacity-50 cursor-not-allowed",
className
)}
>
{renderIcon()}
<span>{isCopied ? finalCopiedLabel : label || defaultLabel}</span>
</button>
);
}
if (variant === "inline") {
return (
<button
type="button"
onClick={handleCopy}
disabled={disabled}
title={finalTitle}
className={cn(
"group inline-flex items-center gap-1 text-[11px] transition-colors cursor-pointer select-none",
isCopied
? "text-emerald-600 dark:text-emerald-400 font-semibold"
: "text-muted-foreground hover:text-foreground",
disabled && "opacity-50 cursor-not-allowed",
className
)}
>
{renderIcon()}
{(label || isCopied) && (
<span>{isCopied ? finalCopiedLabel : label}</span>
)}
</button>
);
}
// Default: "icon" or "ghost"
return (
<button
type="button"
onClick={handleCopy}
disabled={disabled}
title={finalTitle}
aria-label={label || finalTitle}
className={cn(
"group relative inline-flex items-center justify-center p-1.5 rounded-xl transition-all cursor-pointer select-none",
isCopied
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20"
: "text-muted-foreground hover:bg-muted hover:text-foreground border border-transparent",
disabled && "opacity-50 cursor-not-allowed",
className
)}
>
{renderIcon()}
{label && (
<span
className={cn(
"ml-1 text-xs transition-colors",
isCopied
? "text-emerald-600 dark:text-emerald-400 font-semibold"
: "text-muted-foreground group-hover:text-foreground"
)}
>
{isCopied ? finalCopiedLabel : label}
</span>
)}
</button>
);
}
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
: "bg-rose-500/15 text-rose-500"
)}
>
{usage.concurrentJobsAvailable} slots
{usage.concurrentJobsAvailable} {t.quota.slotsUnit}
</span>
</div>
......@@ -275,13 +275,19 @@ export function QuotaUsageWidget({ initialUsage, className }: QuotaUsageWidgetPr
? "bg-teal-500 shadow-sm shadow-teal-500/30"
: "bg-muted/70"
)}
title={isOccupied ? "Slot đang chạy" : "Slot khả dụng"}
title={isOccupied ? t.quota.slotRunning : t.quota.slotAvailable}
/>
);
})}
</div>
</div>
</div>
{/* Quota Exemption Policy Note */}
<div className="mt-4 flex items-center gap-2 rounded-2xl border border-emerald-500/20 bg-emerald-500/5 px-3 py-2 text-xs text-muted-foreground">
<Sparkles className="h-3.5 w-3.5 text-emerald-500 shrink-0" />
<span>{t.quota.exemptionNote}</span>
</div>
</div>
);
}
......@@ -4,11 +4,9 @@ import React, { useState, useRef, useEffect } from "react";
import {
X,
KeyRound,
Copy,
Check,
AlertTriangle,
Sparkles,
ChevronDown,
Check,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
......@@ -16,6 +14,7 @@ import { Label } from "@/components/ui/label";
import { useLanguage } from "@/providers/language-provider";
import { useCreateApiKey } from "@/hooks/use-developer";
import { CreateApiKeyResponse } from "@/types/developer";
import { CopyButton } from "@/components/common/copy-button";
interface CreateApiKeyModalProps {
isOpen: boolean;
......@@ -39,16 +38,23 @@ export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) {
const dropdownRef = useRef<HTMLDivElement>(null);
const [createdKeyData, setCreatedKeyData] = useState<CreateApiKeyResponse | null>(null);
const [isCopied, setIsCopied] = useState(false);
useEffect(() => {
const [prevIsOpen, setPrevIsOpen] = useState(isOpen);
if (isOpen !== prevIsOpen) {
setPrevIsOpen(isOpen);
if (!isOpen) {
setName("");
setExpirationChoice("NEVER");
setCreatedKeyData(null);
setIsCopied(false);
}
}, [isOpen]);
}
const handleClose = () => {
setName("");
setExpirationChoice("NEVER");
setCreatedKeyData(null);
onClose();
};
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
......@@ -62,13 +68,6 @@ export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) {
if (!isOpen) return null;
const handleCopyKey = () => {
if (!createdKeyData?.key) return;
navigator.clipboard.writeText(createdKeyData.key);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2500);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
......@@ -109,7 +108,7 @@ export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) {
</div>
</div>
<button
onClick={onClose}
onClick={handleClose}
className="rounded-xl p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
>
<X className="h-4 w-4" />
......@@ -137,23 +136,20 @@ export function CreateApiKeyModal({ isOpen, onClose }: CreateApiKeyModalProps) {
value={createdKeyData.key}
className="rounded-2xl pr-10 font-mono text-xs bg-muted/60 text-emerald-600 dark:text-emerald-400 font-bold select-all"
/>
<button
type="button"
onClick={handleCopyKey}
<CopyButton
text={createdKeyData.key}
className="absolute right-2 p-1.5 rounded-xl text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
>
{isCopied ? (
<Check className="h-4 w-4 text-emerald-500" />
) : (
<Copy className="h-4 w-4" />
)}
</button>
title={t.developer.apiKeys.newKeyNotice.copyBtn}
showToast={true}
toastMessage="Đã sao chép khóa API vào bộ nhớ tạm!"
iconSize="h-4 w-4"
/>
</div>
</div>
<div className="flex justify-end pt-3 border-t border-border/60">
<Button
onClick={onClose}
onClick={handleClose}
className="rounded-2xl text-xs h-9 px-5 bg-emerald-600 hover:bg-emerald-700 text-white shadow-sm shadow-emerald-600/20 cursor-pointer"
>
{t.developer.apiKeys.newKeyNotice.close}
......
"use client";
import React, { useState, useEffect } from "react";
import React, { useState } from "react";
import {
X,
Webhook,
Sparkles,
Key,
Radio,
Check,
RefreshCw,
ShieldCheck,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
......@@ -19,6 +17,7 @@ import {
useUpdateWebhookConfig,
} from "@/hooks/use-developer";
import { WebhookConfig } from "@/types/developer";
import { CopyButton } from "@/components/common/copy-button";
interface CreateWebhookModalProps {
isOpen: boolean;
......@@ -34,6 +33,15 @@ const AVAILABLE_EVENTS = [
{ value: "export.failed", labelVi: "Tạo tệp xuất thất bại", labelEn: "Export File Failed" },
];
function generateSecretString(): string {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_!#*";
let res = "";
for (let i = 0; i < 32; i++) {
res += chars.charAt(Math.floor(Math.random() * chars.length));
}
return res;
}
export function CreateWebhookModal({
isOpen,
onClose,
......@@ -46,33 +54,33 @@ export function CreateWebhookModal({
const isEditing = !!initialData;
const isSubmitting = createMutation.isPending || updateMutation.isPending;
const [url, setUrl] = useState("");
const [secret, setSecret] = useState("");
const [selectedEvents, setSelectedEvents] = useState<string[]>([
"crawl.job.completed",
"crawl.job.failed",
]);
const generateRandomSecret = () => {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_!#*";
let res = "";
for (let i = 0; i < 32; i++) {
res += chars.charAt(Math.floor(Math.random() * chars.length));
}
setSecret(res);
};
const [url, setUrl] = useState(initialData?.url || "");
const [secret, setSecret] = useState(initialData ? "" : generateSecretString());
const [selectedEvents, setSelectedEvents] = useState<string[]>(
initialData?.events || ["crawl.job.completed", "crawl.job.failed"]
);
const [createdSecretData, setCreatedSecretData] = useState<{ url: string; secret: string } | null>(null);
useEffect(() => {
// Sync state safely on open/close or target webhook change without useEffect
const [prevTracked, setPrevTracked] = useState({ isOpen, id: initialData?.id });
if (prevTracked.isOpen !== isOpen || prevTracked.id !== initialData?.id) {
setPrevTracked({ isOpen, id: initialData?.id });
if (initialData) {
setUrl(initialData.url);
setSelectedEvents(initialData.events || []);
setSecret(""); // Keep hidden for security on edit unless modified
setSecret("");
} else {
setUrl("");
setSelectedEvents(["crawl.job.completed", "crawl.job.failed"]);
generateRandomSecret();
setSecret(generateSecretString());
}
}, [initialData, isOpen]);
setCreatedSecretData(null);
}
const handleClose = () => {
setCreatedSecretData(null);
onClose();
};
if (!isOpen) return null;
......@@ -97,17 +105,21 @@ export function CreateWebhookModal({
secret: secret.trim() ? secret.trim() : undefined,
},
});
handleClose();
} else {
if (!secret.trim()) {
generateRandomSecret();
}
const activeSecret = secret.trim() || generateSecretString();
await createMutation.mutateAsync({
url: url.trim(),
secret: secret.trim(),
secret: activeSecret,
events: selectedEvents,
});
// Reveal secret once to user
setCreatedSecretData({ url: url.trim(), secret: activeSecret });
}
onClose();
};
const generateRandomSecret = () => {
setSecret(generateSecretString());
};
return (
......@@ -121,23 +133,72 @@ export function CreateWebhookModal({
</div>
<div>
<h2 className="text-base font-bold tracking-tight text-foreground">
{isEditing ? t.developer.webhooks.modal.editTitle : t.developer.webhooks.modal.title}
{createdSecretData
? t.developer.webhooks.modal.newSecretNotice.title
: isEditing
? t.developer.webhooks.modal.editTitle
: t.developer.webhooks.modal.title}
</h2>
<p className="text-xs text-muted-foreground">
{t.developer.webhooks.modal.desc}
{createdSecretData
? t.developer.webhooks.modal.newSecretNotice.desc
: t.developer.webhooks.modal.desc}
</p>
</div>
</div>
<button
onClick={onClose}
onClick={handleClose}
className="rounded-xl p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="py-4 space-y-4">
{/* Content: Form or 1-Time Secret View */}
{createdSecretData ? (
<div className="py-4 space-y-4">
<div className="p-4 rounded-2xl border border-emerald-500/30 bg-emerald-500/5 space-y-2">
<div className="flex items-center gap-2 text-emerald-600 dark:text-emerald-400 font-bold text-xs">
<ShieldCheck className="h-4 w-4" />
<span>{t.developer.webhooks.modal.newSecretNotice.title}</span>
</div>
<p className="text-xs text-muted-foreground">
{t.developer.webhooks.modal.newSecretNotice.desc}
</p>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold">
{t.developer.webhooks.modal.secretLabel}
</Label>
<div className="relative flex items-center">
<Input
readOnly
value={createdSecretData.secret}
className="rounded-2xl pr-10 font-mono text-xs bg-muted/60 text-emerald-600 dark:text-emerald-400 font-bold select-all"
/>
<CopyButton
text={createdSecretData.secret}
className="absolute right-2 p-1.5 rounded-xl text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
title={t.developer.webhooks.modal.newSecretNotice.copyBtn}
showToast={true}
toastMessage={t.developer.webhooks.modal.newSecretNotice.copied}
iconSize="h-4 w-4"
/>
</div>
</div>
<div className="flex justify-end pt-3 border-t border-border/60">
<Button
onClick={handleClose}
className="rounded-2xl text-xs h-9 px-5 bg-emerald-600 hover:bg-emerald-700 text-white shadow-sm shadow-emerald-600/20 cursor-pointer"
>
{t.developer.webhooks.modal.newSecretNotice.close}
</Button>
</div>
</div>
) : (
<form onSubmit={handleSubmit} className="py-4 space-y-4">
{/* Endpoint URL */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold">
......@@ -219,7 +280,7 @@ export function CreateWebhookModal({
<Button
type="button"
variant="outline"
onClick={onClose}
onClick={handleClose}
className="rounded-2xl text-xs h-9 px-4 cursor-pointer"
>
{t.developer.webhooks.modal.cancel}
......@@ -235,6 +296,7 @@ export function CreateWebhookModal({
</Button>
</div>
</form>
)}
</div>
</div>
);
......
"use client";
import React, { useState } from "react";
import React, { useState, useMemo } from "react";
import {
X,
Play,
CheckCircle2,
AlertCircle,
ExternalLink,
Code2,
Sparkles,
Eye,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ExtractionTemplate } from "@/types/extraction-template";
import { useLanguage } from "@/providers/language-provider";
import { SafeHtmlPreview } from "@/components/common/safe-html-preview";
import { JsonViewer } from "@/components/common/json-viewer";
import { generateStandardFilename } from "@/lib/download-helper";
interface TemplatePreviewModalProps {
isOpen: boolean;
......@@ -28,16 +30,46 @@ export function TemplatePreviewModal({
template,
}: TemplatePreviewModalProps) {
const { t } = useLanguage();
const [testUrl, setTestUrl] = useState("");
const [testUrl, setTestUrl] = useState(() =>
template ? `https://${template.domain}/sample-article-2026` : ""
);
const [prevTemplateId, setPrevTemplateId] = useState(template?.id);
const [isSimulating, setIsSimulating] = useState(false);
const [simulatedData, setSimulatedData] = useState<Record<string, unknown> | null>(null);
const [previewTab, setPreviewTab] = useState<"json" | "html">("html");
if (template?.id !== prevTemplateId) {
setPrevTemplateId(template?.id);
setTestUrl(`https://${template?.domain || "example.com"}/sample-article-2026`);
setSimulatedData(null);
}
React.useEffect(() => {
if (template) {
setTestUrl(`https://${template.domain}/sample-article-2026`);
setSimulatedData(null);
}
}, [template]);
// Construct simulated HTML content demonstrating XSS sanitization
const simulatedHtml = useMemo(() => {
if (!simulatedData || !template) return "";
const title = String(simulatedData.title || simulatedData.name || "Bài Viết Trích Xuất Mẫu");
const author = String(simulatedData.author || "Ban Biên Tập");
const content = String(simulatedData.content || simulatedData.description || "Nội dung dữ liệu bóc tách được từ trang đích...");
const price = simulatedData.price
? `<div style="color:#059669;font-weight:bold;font-size:16px;margin:8px 0;">${simulatedData.price}</div>`
: "";
return `
<article>
<header style="margin-bottom:12px;border-bottom:1px solid #e2e8f0;padding-bottom:8px;">
<h1 style="font-size:18px;margin:0 0 6px 0;color:#0f172a;">${title}</h1>
<p style="margin:0;font-size:12px;color:#64748b;">Nguồn: <strong>${template.domain}</strong> • Tác giả: <strong>${author}</strong></p>
</header>
${price}
<div style="font-size:13px;line-height:1.6;margin-top:10px;">
<p>${content}</p>
</div>
<footer style="margin-top:20px;padding:8px;background:rgba(16,185,129,0.08);border-radius:8px;border:1px solid rgba(16,185,129,0.2);font-size:11px;color:#047857;">
✓ Nội dung đã được bọc và làm sạch an toàn qua <strong>DOMPurify</strong> và cách ly trong <strong>Iframe Sandbox</strong> (ngăn chặn hoàn toàn mã thực thi độc hại).
</footer>
</article>
`;
}, [simulatedData, template]);
if (!isOpen || !template) return null;
......@@ -66,12 +98,12 @@ export function TemplatePreviewModal({
});
setSimulatedData(result);
setIsSimulating(false);
}, 600);
}, 500);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm animate-in fade-in-0 duration-200">
<div className="relative w-full max-w-xl flex flex-col rounded-3xl border border-emerald-500/20 bg-card p-6 shadow-2xl shadow-emerald-950/20 overflow-hidden">
<div className="relative w-full max-w-2xl flex flex-col rounded-3xl border border-emerald-500/20 bg-card p-6 shadow-2xl shadow-emerald-950/20 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between pb-4 border-b border-border/60">
<div className="flex items-center gap-3">
......@@ -96,7 +128,7 @@ export function TemplatePreviewModal({
</div>
{/* Content */}
<div className="py-4 space-y-4">
<div className="py-4 space-y-4 max-h-[75vh] overflow-y-auto pr-1">
<div className="space-y-1.5">
<Label className="text-xs font-semibold">
{t.templates.preview.testUrl}
......@@ -136,21 +168,62 @@ export function TemplatePreviewModal({
</div>
</div>
{/* Simulated Output */}
{/* Simulated Output with Safe HTML / JSON Tabs */}
{simulatedData && (
<div className="p-3.5 rounded-2xl border border-emerald-500/20 bg-card space-y-2 animate-in fade-in-50 duration-200">
<div className="flex items-center justify-between">
<div className="p-4 rounded-2xl border border-emerald-500/20 bg-card space-y-3 animate-in fade-in-50 duration-200">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 pb-2 border-b border-border/60">
<div className="flex items-center gap-1.5 text-xs font-semibold text-emerald-600 dark:text-emerald-400">
<CheckCircle2 className="h-4 w-4" />
<span>{t.templates.preview.sampleResult}</span>
</div>
<span className="text-[10px] text-muted-foreground font-mono">
HTTP 200 OK
</span>
{/* Tab Switcher: Safe HTML vs JSON */}
<div className="flex items-center rounded-xl bg-muted/60 p-1 border border-border/70">
<button
type="button"
onClick={() => setPreviewTab("html")}
className={`flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium transition-all cursor-pointer ${
previewTab === "html"
? "bg-background text-emerald-600 dark:text-emerald-400 shadow-sm border border-emerald-500/20"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Eye className="h-3 w-3" />
<span>{t.templates.preview.tabHtml}</span>
</button>
<button
type="button"
onClick={() => setPreviewTab("json")}
className={`flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium transition-all cursor-pointer ${
previewTab === "json"
? "bg-background text-emerald-600 dark:text-emerald-400 shadow-sm border border-emerald-500/20"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Code2 className="h-3 w-3" />
<span>{t.templates.preview.tabJson}</span>
</button>
</div>
</div>
<pre className="p-3 rounded-xl bg-muted/60 text-foreground font-mono text-[11px] overflow-x-auto max-h-48">
{JSON.stringify(simulatedData, null, 2)}
</pre>
{previewTab === "html" ? (
<SafeHtmlPreview
htmlContent={simulatedHtml}
maxHeight="280px"
title="Extracted Template HTML Preview"
/>
) : (
<JsonViewer
data={simulatedData}
maxHeight="280px"
title={`Dữ Liệu JSON [${template.domain}]`}
filename={generateStandardFilename({
category: "preview",
identifier: template.domain,
extension: "json",
})}
/>
)}
</div>
)}
</div>
......
"use client";
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useMemo } from "react";
import {
X,
ShieldCheck,
ShieldAlert,
Check,
Sparkles,
AlertCircle,
RefreshCw,
User,
Info,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useLanguage } from "@/providers/language-provider";
......@@ -55,28 +52,27 @@ export function UserRolesModal({
// Mutation for syncing: PUT /users/:id/roles
const assignRolesMutation = useAssignUserRoles();
// Local state for checked role IDs
const [selectedRoleIds, setSelectedRoleIds] = useState<string[]>([]);
// Local state for user manual overrides
const [userOverrideIds, setUserOverrideIds] = useState<string[] | null>(null);
// Sync current user roles into local state with equality check to prevent loops
useEffect(() => {
if (!isOpen || !userRoles || !Array.isArray(userRoles)) return;
const newIds = userRoles.map((r) => r.id);
setSelectedRoleIds((prev) => {
if (
prev.length === newIds.length &&
prev.every((id) => newIds.includes(id))
) {
return prev;
}
return newIds;
});
}, [isOpen, userRoles]);
// Derive initial roles from server data without useEffect setState
const defaultRoleIds = useMemo(
() => (Array.isArray(userRoles) ? userRoles.map((r) => r.id) : []),
[userRoles]
);
const selectedRoleIds = userOverrideIds ?? defaultRoleIds;
const handleClose = () => {
setUserOverrideIds(null);
onClose();
};
// Handle escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) {
setUserOverrideIds(null);
onClose();
}
};
......@@ -92,10 +88,11 @@ export function UserRolesModal({
const handleToggleRole = (roleId: string) => {
if (isSelf) return; // Self-escalation guard
setSelectedRoleIds((prev) =>
prev.includes(roleId)
? prev.filter((id) => id !== roleId)
: [...prev, roleId]
const current = selectedRoleIds;
setUserOverrideIds(
current.includes(roleId)
? current.filter((id: string) => id !== roleId)
: [...current, roleId]
);
};
......@@ -104,7 +101,7 @@ export function UserRolesModal({
userId: user.id,
roleIds: selectedRoleIds,
});
onClose();
handleClose();
};
return (
......@@ -126,7 +123,7 @@ export function UserRolesModal({
</div>
</div>
<button
onClick={onClose}
onClick={handleClose}
className="rounded-xl p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
>
<X className="h-4 w-4" />
......@@ -270,7 +267,7 @@ export function UserRolesModal({
<Button
type="button"
variant="outline"
onClick={onClose}
onClick={handleClose}
className="rounded-2xl text-xs cursor-pointer"
>
{t.users.modalRoles.cancel}
......
......@@ -138,12 +138,16 @@ export function UsersManagementView({
isFetching,
} = useUsersList(queryParams);
const rawUsers: UserItem[] = usersData?.items || [];
const users = useMemo(() => {
if (!sortBy) return rawUsers;
return [...rawUsers].sort((a, b) => {
let aVal: any = a[sortBy as keyof UserItem];
let bVal: any = b[sortBy as keyof UserItem];
const items: UserItem[] = usersData?.items || [];
if (!sortBy) return items;
return [...items].sort((a, b) => {
let aVal: string | number | boolean | null | undefined = a[
sortBy as keyof UserItem
] as string | number | boolean | null | undefined;
let bVal: string | number | boolean | null | undefined = b[
sortBy as keyof UserItem
] as string | number | boolean | null | undefined;
if (sortBy === "fullName") {
aVal = a.fullName || a.email;
bVal = b.fullName || b.email;
......@@ -154,7 +158,7 @@ export function UsersManagementView({
const cmp = aVal < bVal ? -1 : 1;
return sortOrder === "asc" ? cmp : -cmp;
});
}, [rawUsers, sortBy, sortOrder]);
}, [usersData?.items, sortBy, sortOrder]);
const meta = usersData?.meta || {
total: 0,
......
......@@ -2,7 +2,12 @@
import { useEffect, useRef, useState, useCallback } from "react";
export type SSEConnectionStatus = "CONNECTING" | "OPEN" | "CLOSED" | "ERROR";
export type SSEConnectionStatus =
| "CONNECTING"
| "OPEN"
| "RECONNECTING"
| "CLOSED"
| "ERROR";
export interface UseEventSourceOptions<T = unknown> {
enabled?: boolean;
......@@ -12,7 +17,8 @@ export interface UseEventSourceOptions<T = unknown> {
onDone?: (data: { status?: string; message?: string }) => void;
onMessage?: (data: T, eventName: string) => void;
onError?: (error: Event) => void;
reconnectInterval?: number;
initialRetryDelay?: number;
maxRetryDelay?: number;
maxRetries?: number;
}
......@@ -21,13 +27,29 @@ export interface UseEventSourceResult<T = unknown> {
data: T | null;
lastEventName: string | null;
error: Event | null;
retryCount: number;
nextRetryDelayMs: number;
reconnect: () => void;
close: () => void;
}
/**
* Calculates exponential backoff delay capped at maxDelay.
* Sequence: 1s, 2s, 4s, 8s, 16s, max 30s.
*/
export function calculateBackoffDelay(
attempt: number,
initialDelay = 1000,
maxDelay = 30000
): number {
const delay = initialDelay * Math.pow(2, attempt);
return Math.min(delay, maxDelay);
}
/**
* Custom hook kết nối Server-Sent Events (SSE) theo thời gian thực.
* Tận dụng endpoint SSE GET /api/v1/crawl-jobs/:id/events (qua BFF proxy /api/proxy/crawl-jobs/:id/events).
* Tự động kết nối lại với Exponential Backoff (1s, 2s, 4s, tối đa 30s),
* đồng thời tự động phục hồi kết nối ngay khi mạng online hoặc người dùng mở lại tab/laptop.
*/
export function useEventSource<T = unknown>(
url: string | null,
......@@ -41,18 +63,27 @@ export function useEventSource<T = unknown>(
onDone,
onMessage,
onError,
reconnectInterval = 4000,
maxRetries = 5,
initialRetryDelay = 1000,
maxRetryDelay = 30000,
maxRetries = 10,
} = options;
const [status, setStatus] = useState<SSEConnectionStatus>("CONNECTING");
const [status, setStatus] = useState<SSEConnectionStatus>(() => {
if (!url || !enabled || typeof window === "undefined") {
return "CLOSED";
}
return "CONNECTING";
});
const [data, setData] = useState<T | null>(null);
const [lastEventName, setLastEventName] = useState<string | null>(null);
const [error, setError] = useState<Event | null>(null);
const [retryCount, setRetryCount] = useState(0);
const [nextRetryDelayMs, setNextRetryDelayMs] = useState(0);
const eventSourceRef = useRef<EventSource | null>(null);
const retryCountRef = useRef(0);
const retryTimerRef = useRef<NodeJS.Timeout | null>(null);
const connectRef = useRef<() => void>(() => {});
// Safe ref callbacks to avoid unnecessary reconnections
const callbacksRef = useRef({ onInitial, onProgress, onDone, onMessage, onError });
......@@ -75,12 +106,10 @@ export function useEventSource<T = unknown>(
cleanUp();
if (!url || !enabled || typeof window === "undefined") {
setStatus("CLOSED");
return;
}
try {
setStatus("CONNECTING");
const es = new EventSource(url, { withCredentials });
eventSourceRef.current = es;
......@@ -88,6 +117,8 @@ export function useEventSource<T = unknown>(
setStatus("OPEN");
setError(null);
retryCountRef.current = 0;
setRetryCount(0);
setNextRetryDelayMs(0);
};
// Handle custom SSE event: "initial"
......@@ -146,27 +177,45 @@ export function useEventSource<T = unknown>(
setError(err);
callbacksRef.current.onError?.(err);
// If readyState is CLOSED or we can retry
if (es.readyState === EventSource.CLOSED) {
setStatus("CLOSED");
es.close();
if (retryCountRef.current < maxRetries) {
retryCountRef.current += 1;
setStatus("CONNECTING");
retryTimerRef.current = setTimeout(() => {
connect();
}, reconnectInterval);
} else {
setStatus("ERROR");
}
// Close stale instance
es.close();
if (retryCountRef.current < maxRetries) {
const delay = calculateBackoffDelay(
retryCountRef.current,
initialRetryDelay,
maxRetryDelay
);
retryCountRef.current += 1;
setRetryCount(retryCountRef.current);
setNextRetryDelayMs(delay);
setStatus("RECONNECTING");
retryTimerRef.current = setTimeout(() => {
connectRef.current();
}, delay);
} else {
setStatus("ERROR");
}
};
} catch {
setStatus("ERROR");
queueMicrotask(() => {
setStatus("ERROR");
});
}
}, [cleanUp, enabled, maxRetries, reconnectInterval, url, withCredentials]);
}, [
cleanUp,
enabled,
initialRetryDelay,
maxRetryDelay,
maxRetries,
url,
withCredentials,
]);
useEffect(() => {
connectRef.current = connect;
}, [connect]);
useEffect(() => {
connect();
......@@ -177,6 +226,9 @@ export function useEventSource<T = unknown>(
const reconnect = useCallback(() => {
retryCountRef.current = 0;
setRetryCount(0);
setNextRetryDelayMs(0);
setStatus("CONNECTING");
connect();
}, [connect]);
......@@ -185,11 +237,38 @@ export function useEventSource<T = unknown>(
setStatus("CLOSED");
}, [cleanUp]);
// Immediate reconnection when network comes back online or laptop wake up / tab becomes visible
useEffect(() => {
if (typeof window === "undefined") return;
const handleNetworkOrWakeup = () => {
if (
document.visibilityState === "visible" &&
(status === "RECONNECTING" || status === "ERROR")
) {
reconnect();
}
};
window.addEventListener("online", handleNetworkOrWakeup);
document.addEventListener("visibilitychange", handleNetworkOrWakeup);
return () => {
window.removeEventListener("online", handleNetworkOrWakeup);
document.removeEventListener("visibilitychange", handleNetworkOrWakeup);
};
}, [reconnect, status]);
const currentStatus: SSEConnectionStatus =
!url || !enabled ? "CLOSED" : status;
return {
status,
status: currentStatus,
data,
lastEventName,
error,
retryCount,
nextRetryDelayMs,
reconnect,
close,
};
......
"use client";
import { useState, useEffect } from "react";
import { toast } from "sonner";
import { useLanguage } from "@/providers/language-provider";
export interface UseNetworkStatusOptions {
showToasts?: boolean;
offlineMessage?: string;
onlineMessage?: string;
}
/**
* Hook detecting browser online/offline status via navigator.onLine and window events.
* Fully compliant with React 19 standards (avoids synchronous setState in effect bodies)
* and supports multi-language (Anh - Việt) notifications.
*/
export function useNetworkStatus(options: UseNetworkStatusOptions = {}) {
const { t } = useLanguage();
const {
showToasts = true,
offlineMessage = t.network.offlineToast,
onlineMessage = t.network.onlineToast,
} = options;
const [isOnline, setIsOnline] = useState<boolean>(() => {
if (typeof window === "undefined") return true;
return typeof navigator.onLine === "boolean" ? navigator.onLine : true;
});
useEffect(() => {
if (typeof window === "undefined") return;
const handleOffline = () => {
setIsOnline(false);
if (showToasts) {
toast.warning(offlineMessage, {
id: "network-status-toast",
duration: Infinity,
});
}
};
const handleOnline = () => {
setIsOnline(true);
if (showToasts) {
toast.dismiss("network-status-toast");
toast.success(onlineMessage, {
id: "network-restored-toast",
duration: 3500,
});
}
};
window.addEventListener("offline", handleOffline);
window.addEventListener("online", handleOnline);
return () => {
window.removeEventListener("offline", handleOffline);
window.removeEventListener("online", handleOnline);
};
}, [showToasts, offlineMessage, onlineMessage]);
return { isOnline };
}
import { Locale, translations } from "./i18n/translations";
/**
* Helper kiểm tra xem lỗi cào dữ liệu có phải do mất kết nối mạng, timeout hoặc không thể kết nối tới host hay không.
* Các tác vụ thất bại do lỗi kết nối được hệ thống tự động miễn trừ và không tính vào hạn mức sử dụng (Quota).
*/
export function isConnectionLossError(
rawError: string | null | undefined
): boolean {
if (!rawError) return true;
const err = rawError.toLowerCase();
return (
// Timeout
err.includes("timeout") ||
err.includes("timed out") ||
err.includes("etimedout") ||
err.includes("esockettimedout") ||
// Connection drops & network resets
err.includes("econnrefused") ||
err.includes("econnreset") ||
err.includes("econnaborted") ||
err.includes("network error") ||
err.includes("network_error") ||
err.includes("connection reset") ||
err.includes("connection refused") ||
err.includes("connection lost") ||
err.includes("connection closed") ||
err.includes("connection error") ||
err.includes("socket hang up") ||
err.includes("network is unreachable") ||
err.includes("host unreachable") ||
err.includes("offline") ||
// DNS errors
err.includes("dns") ||
err.includes("getaddrinfo") ||
err.includes("enotfound") ||
// Provider & gateway / credit errors (Firecrawl 402, 500, 502, 503, 504)
err.includes("402") ||
err.includes("credit") ||
err.includes("500") ||
err.includes("502") ||
err.includes("503") ||
err.includes("504") ||
err.includes("bad gateway") ||
err.includes("gateway") ||
err.includes("service unavailable") ||
// Vietnamese mapped phrases & generic system errors
err.includes("mất kết nối") ||
err.includes("lỗi kết nối") ||
err.includes("không thể kết nối") ||
err.includes("kết nối đến trang web đích bị quá thời gian") ||
err.includes("không thể phân giải tên miền") ||
err.includes("đã xảy ra lỗi trong quá trình cào dữ liệu") ||
err.includes("lỗi không xác định") ||
err.includes("lỗi xác thực hệ thống cào dữ liệu")
);
}
export type CrawlErrorCategory =
| "firecrawlCredit"
| "timeout"
| "connectionRefused"
| "dns"
| "connectionReset"
| "serverUnavailable"
| "networkUnreachable"
| "unknown";
export interface LocalizedCrawlErrorInfo {
title: string;
description: string;
isConnectionLoss: boolean;
category: CrawlErrorCategory;
rawError: string;
}
/**
* Trích xuất và định dạng lỗi cào dữ liệu thành thông điệp đa ngôn ngữ (Anh - Việt)
* Thân thiện, trực quan và giải thích rõ nguyên nhân cho người dùng.
*/
export function getLocalizedCrawlErrorInfo(
rawError: string | null | undefined,
locale: Locale = "vi"
): LocalizedCrawlErrorInfo {
const isLoss = isConnectionLossError(rawError);
const t = translations[locale].crawlErrors.types;
const raw = rawError || "";
const err = raw.toLowerCase();
if (
err.includes("402") ||
err.includes("credit") ||
err.includes("xác thực") ||
err.includes("api key") ||
err.includes("apikey") ||
err.includes("gói dịch vụ") ||
err.includes("vượt quá giới hạn") ||
err.includes("unauthorized")
) {
return {
title: t.firecrawlCredit.title,
description: t.firecrawlCredit.desc,
isConnectionLoss: true,
category: "firecrawlCredit",
rawError: raw,
};
}
if (
err.includes("timeout") ||
err.includes("timed out") ||
err.includes("etimedout") ||
err.includes("esockettimedout") ||
err.includes("quá thời gian")
) {
return {
title: t.timeout.title,
description: t.timeout.desc,
isConnectionLoss: true,
category: "timeout",
rawError: raw,
};
}
if (
err.includes("econnrefused") ||
err.includes("connection refused") ||
err.includes("từ chối")
) {
return {
title: t.connectionRefused.title,
description: t.connectionRefused.desc,
isConnectionLoss: true,
category: "connectionRefused",
rawError: raw,
};
}
if (
err.includes("dns") ||
err.includes("getaddrinfo") ||
err.includes("enotfound") ||
err.includes("phân giải tên miền")
) {
return {
title: t.dns.title,
description: t.dns.desc,
isConnectionLoss: true,
category: "dns",
rawError: raw,
};
}
if (
err.includes("econnreset") ||
err.includes("econnaborted") ||
err.includes("socket hang up") ||
err.includes("connection reset")
) {
return {
title: t.connectionReset.title,
description: t.connectionReset.desc,
isConnectionLoss: true,
category: "connectionReset",
rawError: raw,
};
}
if (
err.includes("500") ||
err.includes("502") ||
err.includes("503") ||
err.includes("504") ||
err.includes("bad gateway") ||
err.includes("service unavailable")
) {
return {
title: t.serverUnavailable.title,
description: t.serverUnavailable.desc,
isConnectionLoss: true,
category: "serverUnavailable",
rawError: raw,
};
}
if (
err.includes("network error") ||
err.includes("network_error") ||
err.includes("unreachable") ||
err.includes("offline") ||
err.includes("mất kết nối")
) {
return {
title: t.networkUnreachable.title,
description: t.networkUnreachable.desc,
isConnectionLoss: true,
category: "networkUnreachable",
rawError: raw,
};
}
return {
title: t.unknown.title,
description: raw || t.unknown.desc,
isConnectionLoss: isLoss,
category: "unknown",
rawError: raw,
};
}
/**
* Blob Downloader & Standardized File Exporter Utility
* Provides safe virtual <a> tag triggers, standardized file naming,
* and automatic memory cleanup via URL.revokeObjectURL to prevent memory leaks.
*/
export interface GenerateFilenameOptions {
prefix?: string; // Default: 'datacrawler'
category?: "export" | "diff" | "preview" | "audit" | "report" | "template" | "logs";
identifier?: string; // e.g. job ID, export ID, template ID
extension: "csv" | "json" | "xlsx" | "txt" | "bin" | "zip" | "md" | string;
date?: Date | string;
}
/**
* Generates a standardized, clean filename following the convention:
* datacrawler_{category}_{identifier}_{YYYY-MM-DD}.{ext}
* Example: datacrawler_export_job12_2026-09-08.csv
*/
export function generateStandardFilename({
prefix = "datacrawler",
category = "export",
identifier,
extension,
date = new Date(),
}: GenerateFilenameOptions): string {
const d = date instanceof Date ? date : new Date(date);
const datePart = !isNaN(d.getTime())
? d.toISOString().split("T")[0]
: new Date().toISOString().split("T")[0];
const cleanId = identifier
? identifier.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 32)
: "";
const ext = extension.startsWith(".") ? extension.slice(1) : extension;
const parts = [prefix, category, cleanId, datePart].filter(Boolean);
return `${parts.join("_")}.${ext}`;
}
export interface DownloadBlobOptions {
filename: string;
mimeType?: string;
revokeDelayMs?: number; // Default: 250ms
}
/**
* Downloads a Blob, ArrayBuffer, or string safely using a virtual <a> tag.
* Automatically cleans up the object URL after the browser initiates the download.
*/
export function downloadBlob(
content: Blob | ArrayBuffer | Uint8Array | string,
options: DownloadBlobOptions | string
): void {
if (typeof window === "undefined") return;
const resolvedOptions: DownloadBlobOptions =
typeof options === "string" ? { filename: options } : options;
const { filename, mimeType, revokeDelayMs = 250 } = resolvedOptions;
let blob: Blob;
if (content instanceof Blob) {
blob = mimeType ? new Blob([content], { type: mimeType }) : content;
} else if (typeof content === "string") {
blob = new Blob([content], {
type: mimeType || "text/plain;charset=utf-8",
});
} else {
blob = new Blob([content as unknown as BlobPart], {
type: mimeType || "application/octet-stream",
});
}
const blobUrl = window.URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.style.display = "none";
anchor.href = blobUrl;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
// Remove element and revoke Blob URL to prevent memory leaks
document.body.removeChild(anchor);
window.setTimeout(() => {
window.URL.revokeObjectURL(blobUrl);
}, revokeDelayMs);
}
/**
* Helper to export JavaScript objects/arrays as a formatted JSON file.
*/
export function downloadJsonFile(
data: unknown,
filenameOrOptions?: string | Partial<GenerateFilenameOptions>
): void {
const filename =
typeof filenameOrOptions === "string"
? filenameOrOptions
: generateStandardFilename({
prefix: "datacrawler",
category: "export",
extension: "json",
...filenameOrOptions,
});
const jsonString = JSON.stringify(data, null, 2);
downloadBlob(jsonString, {
filename,
mimeType: "application/json;charset=utf-8",
});
}
/**
* Helper to export CSV or plain text with proper encoding.
*/
export function downloadTextFile(
text: string,
filename: string,
mimeType: string = "text/csv;charset=utf-8"
): void {
downloadBlob(text, { filename, mimeType });
}
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
const LANGUAGE_STORAGE_KEY = "data_crawler_lang";
function getInitialLocale(): Locale {
if (typeof window === "undefined") return "vi";
try {
const saved = localStorage.getItem(LANGUAGE_STORAGE_KEY) as Locale | null;
if (saved && (saved === "vi" || saved === "en")) {
return saved;
}
const browserLang = navigator.language?.toLowerCase() || "";
return browserLang.startsWith("vi") ? "vi" : "en";
} catch {
return "vi";
}
}
export function LanguageProvider({ children }: { children: ReactNode }) {
// Always initialize with "vi" to match SSR output and prevent hydration mismatch
const [locale, setLocaleState] = useState<Locale>("vi");
const [locale, setLocaleState] = useState<Locale>(getInitialLocale);
React.useEffect(() => {
try {
const saved = localStorage.getItem(LANGUAGE_STORAGE_KEY) as Locale | null;
if (saved && (saved === "vi" || saved === "en")) {
setLocaleState(saved);
document.documentElement.lang = saved;
return;
}
const browserLang = navigator.language?.toLowerCase() || "";
const initial = browserLang.startsWith("vi") ? "vi" : "en";
if (initial !== "vi") {
setLocaleState(initial);
document.documentElement.lang = initial;
}
document.documentElement.lang = locale;
} catch {
// Ignore errors if localStorage is restricted
// Ignore DOM update errors
}
}, []);
}, [locale]);
const setLocale = (newLocale: Locale) => {
setLocaleState(newLocale);
if (typeof window !== "undefined") {
localStorage.setItem(LANGUAGE_STORAGE_KEY, newLocale);
try {
localStorage.setItem(LANGUAGE_STORAGE_KEY, newLocale);
} catch {
// Ignore localStorage errors
}
document.documentElement.lang = newLocale;
}
};
......
......@@ -9,6 +9,8 @@ import { ThemeProvider } from "./theme-provider";
import { LanguageProvider } from "./language-provider";
import { AuthProvider } from "./auth-provider";
import { NetworkStatusBanner } from "@/components/common/network-status-banner";
interface ProvidersProps {
children: ReactNode;
}
......@@ -26,6 +28,7 @@ export function Providers({ children }: ProvidersProps) {
<LanguageProvider>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<NetworkStatusBanner />
{children}
<Toaster position="top-right" richColors closeButton />
<ReactQueryDevtools initialIsOpen={false} />
......
......@@ -3,9 +3,9 @@ import { ApiResponse, PaginatedResponse } from "@/types/api";
import {
CrawlExport,
CrawlExportQueryDto,
CreateCrawlExportDto,
ExportType,
} from "@/types/export";
import { downloadBlob, generateStandardFilename } from "@/lib/download-helper";
const MOCK_CRAWL_EXPORTS: CrawlExport[] = [
{
......@@ -164,31 +164,30 @@ export class CrawlExportService {
}
async downloadExport(exportId: string, fileName?: string): Promise<void> {
const defaultFilename =
fileName ||
generateStandardFilename({
category: "export",
identifier: exportId,
extension: "csv",
});
try {
const response = await apiClient.get(`/exports/${exportId}/download`, {
responseType: "blob",
});
const blob = new Blob([response.data]);
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName || `export_${exportId}.bin`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
const headerContentType = response.headers?.["content-type"];
downloadBlob(response.data, {
filename: defaultFilename,
mimeType: typeof headerContentType === "string" ? headerContentType : "application/octet-stream",
});
} catch {
// Fallback mock download trigger
// Fallback mock download trigger with standardized filename
const mockContent = `Export dataset for ${exportId} generated at ${new Date().toISOString()}`;
const blob = new Blob([mockContent], { type: "text/plain;charset=utf-8" });
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName || `export_${exportId}.txt`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
downloadBlob(mockContent, {
filename: defaultFilename,
mimeType: "text/csv;charset=utf-8",
});
}
}
......
......@@ -99,7 +99,7 @@ const MOCK_WEBHOOK_DELIVERIES: WebhookDelivery[] = [
let localApiKeys = [...MOCK_API_KEYS];
let localWebhooks = [...MOCK_WEBHOOK_CONFIGS];
let localDeliveries = [...MOCK_WEBHOOK_DELIVERIES];
const localDeliveries = [...MOCK_WEBHOOK_DELIVERIES];
export class DeveloperService {
// API Keys
......
......@@ -38,6 +38,7 @@ export interface WebhookConfig {
id: string;
userId?: string;
url: string;
secret?: string;
events: string[];
isActive: boolean;
createdAt: string;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment