diff --git a/web/src/app/home/bots/components/bot-log/view/BotLogCard.tsx b/web/src/app/home/bots/components/bot-log/view/BotLogCard.tsx index 11aff19e..86c9d3db 100644 --- a/web/src/app/home/bots/components/bot-log/view/BotLogCard.tsx +++ b/web/src/app/home/bots/components/bot-log/view/BotLogCard.tsx @@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next'; import { Check, ChevronDown, ChevronRight, Copy } from 'lucide-react'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; +import { copyToClipboard } from '@/app/utils/clipboard'; const LEVEL_STYLES: Record = { error: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', @@ -31,36 +32,19 @@ export function BotLogCard({ function copySessionId() { const text = botLog.message_session_id; - if (navigator.clipboard?.writeText) { - navigator.clipboard - .writeText(text) - .then(() => { + copyToClipboard(text) + .then((ok) => { + if (ok) { setCopied(true); setTimeout(() => setCopied(false), 2000); toast.success(t('common.copySuccess')); - }) - .catch(() => fallbackCopy(text)); - } else { - fallbackCopy(text); - } - } - - function fallbackCopy(text: string) { - const ta = document.createElement('textarea'); - ta.value = text; - ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px'; - document.body.appendChild(ta); - ta.focus(); - ta.select(); - try { - document.execCommand('copy'); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - toast.success(t('common.copySuccess')); - } catch { - toast.error(t('common.copyFailed')); - } - document.body.removeChild(ta); + } else { + toast.error(t('common.copyFailed')); + } + }) + .catch(() => { + toast.error(t('common.copyFailed')); + }); } function formatTime(timestamp: number) { diff --git a/web/src/app/home/bots/components/bot-session/BotSessionMonitor.tsx b/web/src/app/home/bots/components/bot-session/BotSessionMonitor.tsx index 9b7d8928..e38da389 100644 --- a/web/src/app/home/bots/components/bot-session/BotSessionMonitor.tsx +++ b/web/src/app/home/bots/components/bot-session/BotSessionMonitor.tsx @@ -19,6 +19,7 @@ import { ThumbsUp, ThumbsDown, } from 'lucide-react'; +import { copyToClipboard } from '@/app/utils/clipboard'; import { MessageChainComponent, Plain, @@ -108,10 +109,9 @@ const BotSessionMonitor = forwardRef< }; const copyUserId = (userId: string) => { - navigator.clipboard.writeText(userId).then(() => { - setCopiedUserId(true); - setTimeout(() => setCopiedUserId(false), 2000); - }); + copyToClipboard(userId).catch(() => {}); + setCopiedUserId(true); + setTimeout(() => setCopiedUserId(false), 2000); }; const loadSessions = useCallback(async () => { diff --git a/web/src/app/home/components/api-integration-dialog/ApiIntegrationDialog.tsx b/web/src/app/home/components/api-integration-dialog/ApiIntegrationDialog.tsx index bc9bf67a..8ac3f496 100644 --- a/web/src/app/home/components/api-integration-dialog/ApiIntegrationDialog.tsx +++ b/web/src/app/home/components/api-integration-dialog/ApiIntegrationDialog.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import { Copy, Check, Trash2, Plus } from 'lucide-react'; @@ -86,6 +86,9 @@ export default function ApiIntegrationDialog({ const [newWebhookDescription, setNewWebhookDescription] = useState(''); const [newWebhookEnabled, setNewWebhookEnabled] = useState(true); const [deleteWebhookId, setDeleteWebhookId] = useState(null); + const copiedTimerRef = useRef | undefined>( + undefined, + ); const [copiedKey, setCopiedKey] = useState(null); // Sync URL with dialog state @@ -182,10 +185,29 @@ export default function ApiIntegrationDialog({ } }; + const copyToClipboard = (text: string) => { + const el = document.createElement('span'); + el.textContent = text; + el.style.cssText = + 'position:fixed;opacity:0;pointer-events:none;white-space:pre;'; + document.body.appendChild(el); + const range = document.createRange(); + range.selectNodeContents(el); + const sel = window.getSelection(); + sel?.removeAllRanges(); + sel?.addRange(range); + document.execCommand('copy'); + sel?.removeAllRanges(); + document.body.removeChild(el); + }; + const handleCopyKey = (key: string) => { - navigator.clipboard.writeText(key); + try { + copyToClipboard(key); + } catch {} + clearTimeout(copiedTimerRef.current); setCopiedKey(key); - setTimeout(() => setCopiedKey(null), 2000); + copiedTimerRef.current = setTimeout(() => setCopiedKey(null), 2000); }; const maskApiKey = (key: string) => { @@ -330,21 +352,21 @@ export default function ApiIntegrationDialog({ - {apiKeys.map((key) => ( - + {apiKeys.map((item) => ( +
-
{key.name}
- {key.description && ( +
{item.name}
+ {item.description && (
- {key.description} + {item.description}
)}
- {maskApiKey(key.key)} + {maskApiKey(item.key)} @@ -352,10 +374,11 @@ export default function ApiIntegrationDialog({ - - - - - - - - - {/* Delete Confirmation Dialog */} diff --git a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx index bcd6e8be..75c9f47f 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx @@ -18,6 +18,7 @@ import { cn } from '@/lib/utils'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { Copy, Check, Globe } from 'lucide-react'; +import { copyToClipboard } from '@/app/utils/clipboard'; import { systemInfo } from '@/app/infra/http'; /** @@ -44,6 +45,54 @@ function resolveShowIfValue( return externalDependentValues?.[field]; } +/** + * Display-only component for embed code fields with copy animation. + */ +function EmbedCodeField({ + label, + description, + snippet, +}: { + label: string; + description?: string; + snippet: string; +}) { + const [copied, setCopied] = useState(false); + + const handleCopy = () => { + copyToClipboard(snippet).catch(() => {}); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+ + {description && ( +

{description}

+ )} +
+
+          {snippet}
+        
+ +
+
+ ); +} + /** * Display-only component for webhook URL fields. * Rendered outside of react-hook-form binding since the value is @@ -65,15 +114,9 @@ function WebhookUrlField({ const { t } = useTranslation(); const handleCopy = (text: string, setter: (v: boolean) => void) => { - if (navigator.clipboard && navigator.clipboard.writeText) { - navigator.clipboard - .writeText(text) - .then(() => { - setter(true); - setTimeout(() => setter(false), 2000); - }) - .catch(() => {}); - } + copyToClipboard(text).catch(() => {}); + setter(true); + setTimeout(() => setter(false), 2000); }; return ( @@ -467,32 +510,16 @@ export default function DynamicFormComponent({ const embedSnippet = `