ui-nyla/src/pages/views/workspace/ChatStream.tsx
2026-05-19 16:47:52 +02:00

750 lines
28 KiB
TypeScript

/**
* ChatStream -- SSE-driven message display for the workspace.
*
* Renders messages with full Markdown (GFM tables, code blocks with syntax
* highlighting), agent progress indicators, and file edit proposals.
*
* Audio playback uses a playlist queue: when the agent sends multiple TTS
* chunks they are queued and played one after the other instead of overlapping.
*/
import React, { useRef, useEffect, useCallback, useState } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { FaRegCopy, FaCheck } from 'react-icons/fa';
import api from '../../../api';
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
import type { Message, MessageDocument } from '../../../components/UiComponents/Messages/MessagesTypes';
import type { AgentProgress, FileEditProposal } from './useWorkspace';
import { useAudioQueue, type AudioQueueApi } from '../../../hooks/useAudioQueue';
import { useLanguage } from '../../../providers/language/LanguageContext';
interface ChatStreamProps {
messages: Message[];
agentProgress: AgentProgress | null;
isProcessing: boolean;
pendingEdits: FileEditProposal[];
onAcceptEdit: (editId: string) => void;
onRejectEdit: (editId: string) => void;
onOpenEditor?: () => void;
}
export const ChatStream: React.FC<ChatStreamProps> = ({ messages,
agentProgress,
isProcessing,
pendingEdits,
onAcceptEdit,
onRejectEdit,
onOpenEditor,
}) => {
const { t } = useLanguage();
const bottomRef = useRef<HTMLDivElement>(null);
const audioQueue = useAudioQueue();
const enqueuedIdsRef = useRef<Set<string>>(new Set());
const [copiedId, setCopiedId] = useState<string | null>(null);
const _handleCopy = useCallback((msgId: string, text: string) => {
navigator.clipboard.writeText(text).then(() => {
setCopiedId(msgId);
setTimeout(() => setCopiedId((prev) => (prev === msgId ? null : prev)), 1500);
}).catch(() => {});
}, []);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, agentProgress]);
useEffect(() => {
for (const msg of messages) {
const audioUrl = (msg as any)._audioUrl;
if (!audioUrl) continue;
if (enqueuedIdsRef.current.has(msg.id)) continue;
enqueuedIdsRef.current.add(msg.id);
audioQueue.enqueue({
id: msg.id,
url: audioUrl,
language: (msg as any)._audioLang,
charCount: (msg as any)._audioCharCount,
});
}
}, [messages, audioQueue]);
return (
<div style={{
flex: 1,
minHeight: 0,
overflowY: 'auto',
padding: '16px 24px',
display: 'flex',
flexDirection: 'column',
gap: 12,
}}>
{messages.map((msg) => (
<div
key={msg.id}
style={{
flexShrink: 0,
padding: '10px 14px',
borderRadius: 8,
maxWidth: '85%',
alignSelf: msg.role === 'user' ? 'flex-end' : 'flex-start',
background: _getBubbleBackground(msg.role || 'assistant'),
border: msg.role === 'user'
? 'none'
: '1px solid var(--border-color, #e0e0e0)',
fontSize: msg.role === 'status' ? 12 : 14,
color: msg.role === 'status' ? '#795548' : 'inherit',
fontStyle: msg.role === 'status' ? 'italic' : 'normal',
wordBreak: 'break-word',
overflow: 'hidden',
}}
>
{msg.role === 'assistant' && (
<div style={{ fontSize: 11, color: '#888', marginBottom: 4, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span>Assistant</span>
{msg.message && (
<button
onClick={() => _handleCopy(msg.id, msg.message!)}
title={copiedId === msg.id ? t('Kopiert') : t('In Zwischenablage kopieren')}
style={{
background: 'none', border: 'none', cursor: 'pointer',
padding: '2px 4px', borderRadius: 4, display: 'flex', alignItems: 'center',
color: copiedId === msg.id ? 'var(--success-color, #4caf50)' : '#aaa',
transition: 'color 0.2s',
}}
onMouseEnter={e => { if (copiedId !== msg.id) e.currentTarget.style.color = '#666'; }}
onMouseLeave={e => { if (copiedId !== msg.id) e.currentTarget.style.color = '#aaa'; }}
>
{copiedId === msg.id ? <FaCheck size={12} /> : <FaRegCopy size={12} />}
</button>
)}
</div>
)}
{msg.role === 'status' ? (
<span>{msg.message}</span>
) : (
<div className="workspace-markdown">
{msg.documentsLabel && (
<div
style={{
fontSize: 12,
color: '#666',
marginBottom: msg.message ? 8 : 0,
fontStyle: 'italic',
}}
>
{msg.documentsLabel}
</div>
)}
{msg.message && (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code: _CodeBlock,
table: ({ children }) => (
<div style={{ overflowX: 'auto', margin: '8px 0' }}>
<table style={{
borderCollapse: 'collapse',
width: '100%',
fontSize: 13,
}}>
{children}
</table>
</div>
),
th: ({ children }) => (
<th style={{
borderBottom: '2px solid #ddd',
padding: '6px 10px',
textAlign: 'left',
fontWeight: 600,
background: '#f8f9fa',
fontSize: 12,
}}>
{children}
</th>
),
td: ({ children }) => (
<td style={{
borderBottom: '1px solid #eee',
padding: '5px 10px',
fontSize: 12,
}}>
{children}
</td>
),
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--primary-color, #F25843)' }}>
{children}
</a>
),
img: ({ src, alt, ...rest }) =>
src ? <img src={src} alt={alt || ''} {...rest} style={{ maxWidth: '100%', borderRadius: 6 }} /> : null,
}}
>
{msg.message}
</ReactMarkdown>
)}
{msg.documents && msg.documents.length > 0 && (
<div style={{ marginTop: msg.message ? 8 : 0, display: 'flex', flexDirection: 'column', gap: 6 }}>
{msg.documents.map((doc) => (
<_FileCard key={doc.id || doc.fileId} doc={doc} />
))}
</div>
)}
{(msg as any)._audioUrl && (
<_QueuedAudioPlayer
msgId={msg.id}
url={(msg as any)._audioUrl}
language={(msg as any)._audioLang}
audioQueue={audioQueue}
/>
)}
{msg.role === 'assistant' && msg.documents && msg.documents.length > 0 && (
<details className="sentDataDetails" style={{ marginTop: 8, fontSize: '0.8rem', borderTop: '1px solid var(--border-color, #e5e7eb)', paddingTop: 6 }}>
<summary style={{ cursor: 'pointer', color: 'var(--text-secondary, #6b7280)', fontWeight: 500, userSelect: 'none' }}>
Gesendete Daten ({msg.documents.length} {msg.documents.length === 1 ? 'Dokument' : 'Dokumente'})
</summary>
<div style={{ marginTop: 6, display: 'flex', flexDirection: 'column', gap: 4 }}>
{msg.documents.map((doc, idx) => (
<div key={doc.id || idx} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', background: 'var(--bg-hover, rgba(0,0,0,0.02))', borderRadius: 4 }}>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{doc.documentName || doc.fileName || `Dokument ${idx + 1}`}
</span>
{doc.validationMetadata?.neutralized && (
<span style={{ fontSize: '0.7rem', padding: '1px 6px', borderRadius: 10, background: '#dcfce7', color: '#166534' }}>
neutralisiert
</span>
)}
{doc.validationMetadata?.skipped && (
<span style={{ fontSize: '0.7rem', padding: '1px 6px', borderRadius: 10, background: '#fef2f2', color: '#991b1b' }}>
übersprungen
</span>
)}
</div>
))}
</div>
{(msg as any).neutralizationExcluded?.length > 0 && (
<div style={{ marginTop: 6, padding: '6px 8px', background: '#fef2f2', borderRadius: 4, border: '1px solid #fecaca' }}>
<div style={{ fontWeight: 600, color: '#991b1b', marginBottom: 4 }}>
Nicht gesendet (Neutralisierung fehlgeschlagen):
</div>
{(msg as any).neutralizationExcluded.map((docName: string, i: number) => (
<div key={i} style={{ fontSize: '0.75rem', color: '#991b1b', paddingLeft: 4 }}>
{docName}
</div>
))}
</div>
)}
</details>
)}
</div>
)}
</div>
))}
{/* File edit proposals -- compact notification cards */}
{pendingEdits.filter(e => e.status === 'pending').length > 0 && (
<div
style={{
flexShrink: 0,
padding: 12,
borderRadius: 8,
border: '1px solid var(--warning-color, #ff9800)',
background: 'var(--edit-bg, #fff8e1)',
alignSelf: 'flex-start',
maxWidth: '85%',
}}
>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 6, display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ color: '#ff9800' }}></span>
{pendingEdits.filter(e => e.status === 'pending').length} Aenderungsvorschlag(e)
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 8 }}>
{pendingEdits.filter(e => e.status === 'pending').map(edit => (
<div key={edit.id} style={{ fontSize: 12, color: '#555', display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: '#ff9800', flexShrink: 0 }} />
{edit.fileName}
</div>
))}
</div>
<div style={{ display: 'flex', gap: 8 }}>
{onOpenEditor && (
<button
onClick={onOpenEditor}
style={{
padding: '5px 14px', borderRadius: 4, border: 'none',
background: 'var(--primary-color, #F25843)', color: '#fff',
cursor: 'pointer', fontSize: 12, fontWeight: 600,
}}
>
Im Editor pruefen
</button>
)}
<button
onClick={() => pendingEdits.filter(e => e.status === 'pending').forEach(e => onAcceptEdit(e.id))}
style={{
padding: '5px 14px', borderRadius: 4, border: 'none',
background: 'var(--success-color, #4caf50)', color: '#fff',
cursor: 'pointer', fontSize: 12, fontWeight: 600,
}}
>
Alle annehmen
</button>
<button
onClick={() => pendingEdits.filter(e => e.status === 'pending').forEach(e => onRejectEdit(e.id))}
style={{
padding: '5px 14px', borderRadius: 4,
border: '1px solid var(--border-color, #ccc)',
background: '#fff', cursor: 'pointer', fontSize: 12,
}}
>
Alle ablehnen
</button>
</div>
</div>
)}
{/* Thinking / agent-progress indicator */}
{isProcessing && (
<div style={{
flexShrink: 0,
padding: '10px 16px',
borderRadius: 12,
alignSelf: 'flex-start',
maxWidth: '85%',
background: 'var(--assistant-bg, #ffffff)',
border: '1px solid var(--border-color, #e0e0e0)',
display: 'flex',
flexDirection: 'column',
gap: 6,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ fontSize: 11, color: '#888' }}>Assistant</div>
<div className="workspace-thinking-dots" style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<span className="workspace-dot workspace-dot-1" />
<span className="workspace-dot workspace-dot-2" />
<span className="workspace-dot workspace-dot-3" />
</div>
</div>
{agentProgress ? (
<div style={{ display: 'flex', gap: 10, alignItems: 'center', fontSize: 11, color: '#888' }}>
<span style={{ fontWeight: 600 }}>
Round {agentProgress.round}{agentProgress.maxRounds ? `/${agentProgress.maxRounds}` : ''}
</span>
<span>{agentProgress.totalToolCalls} tools</span>
<span>{agentProgress.costCHF?.toFixed(4) || '0'} CHF</span>
</div>
) : (
<div style={{ fontSize: 12, color: '#999' }}>
{t('Denkt nach…')}
</div>
)}
</div>
)}
<div ref={bottomRef} />
<style>{`
@keyframes workspace-dot-bounce {
0%, 80%, 100% { transform: translateY(0); opacity: 0.4; }
40% { transform: translateY(-5px); opacity: 1; }
}
.workspace-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--primary-color, #F25843);
animation: workspace-dot-bounce 1.4s ease-in-out infinite;
}
.workspace-dot-1 { animation-delay: 0s; }
.workspace-dot-2 { animation-delay: 0.2s; }
.workspace-dot-3 { animation-delay: 0.4s; }
.workspace-markdown p { margin: 4px 0; }
.workspace-markdown ul, .workspace-markdown ol { margin: 4px 0; padding-left: 20px; }
.workspace-markdown blockquote {
margin: 8px 0; padding: 4px 12px;
border-left: 3px solid #ddd; color: #666;
}
.workspace-markdown h1, .workspace-markdown h2, .workspace-markdown h3 {
margin: 8px 0 4px; line-height: 1.3;
}
.workspace-markdown img { max-width: 100%; border-radius: 4px; }
.workspace-markdown hr { border: none; border-top: 1px solid #e0e0e0; margin: 8px 0; }
`}</style>
</div>
);
};
function _getBubbleBackground(role: string): string {
switch (role) {
case 'user': return 'var(--primary-light, #e3f2fd)';
case 'status': return 'var(--status-bg, #fff3e0)';
case 'system': return 'var(--system-bg, #f5f5f5)';
default: return 'var(--assistant-bg, #ffffff)';
}
}
function _FileCard({ doc }: { doc: MessageDocument }) {
const { t } = useLanguage();
const _handleDownload = useCallback(async () => {
try {
const res = await api.get(`/api/files/${doc.fileId}/download`, {
responseType: 'blob',
});
const blob = new Blob([res.data], { type: doc.mimeType || 'application/octet-stream' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = doc.fileName || 'download';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err) {
console.error('Download failed:', err);
}
}, [doc]);
const ext = (doc.fileName || '').split('.').pop()?.toLowerCase() || '';
const icon = _getFileIcon(ext);
const sizeLabel = doc.fileSize ? formatBinaryDataSizeBytes(doc.fileSize) : '';
return (
<div
onClick={_handleDownload}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '8px 12px',
borderRadius: 6,
border: '1px solid var(--border-color, #e0e0e0)',
background: 'var(--file-card-bg, #f8f9fa)',
cursor: 'pointer',
transition: 'background 0.15s',
maxWidth: 340,
}}
title={`Download ${doc.fileName}`}
onMouseEnter={e => (e.currentTarget.style.background = '#e8f0fe')}
onMouseLeave={e => (e.currentTarget.style.background = 'var(--file-card-bg, #f8f9fa)')}
>
<span style={{ fontSize: 22 }}>{icon}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 13, fontWeight: 600, overflow: 'hidden',
textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{doc.fileName}
</div>
<div style={{ fontSize: 11, color: '#888' }}>
{ext.toUpperCase()}{sizeLabel ? ` \u00b7 ${sizeLabel}` : ''}
</div>
</div>
<span style={{ fontSize: 14, color: 'var(--primary-color, #F25843)' }} title={t('Download')}>&#x2B07;</span>
</div>
);
}
function _getFileIcon(ext: string): string {
const map: Record<string, string> = {
pdf: '\uD83D\uDCC4', csv: '\uD83D\uDCCA', xlsx: '\uD83D\uDCCA', xls: '\uD83D\uDCCA',
doc: '\uD83D\uDCC3', docx: '\uD83D\uDCC3', txt: '\uD83D\uDCC4', json: '\uD83D\uDCCB',
md: '\uD83D\uDCC4', xml: '\uD83D\uDCCB', yaml: '\uD83D\uDCCB', yml: '\uD83D\uDCCB',
html: '\uD83C\uDF10', css: '\uD83C\uDFA8', js: '\uD83D\uDCDC', ts: '\uD83D\uDCDC',
py: '\uD83D\uDC0D', sql: '\uD83D\uDDC3\uFE0F', log: '\uD83D\uDCDD',
png: '\uD83D\uDDBC\uFE0F', jpg: '\uD83D\uDDBC\uFE0F', jpeg: '\uD83D\uDDBC\uFE0F',
gif: '\uD83D\uDDBC\uFE0F', svg: '\uD83D\uDDBC\uFE0F', webp: '\uD83D\uDDBC\uFE0F',
zip: '\uD83D\uDCE6', rar: '\uD83D\uDCE6', '7z': '\uD83D\uDCE6', tar: '\uD83D\uDCE6',
pptx: '\uD83D\uDCCA', ppt: '\uD83D\uDCCA',
mp3: '\uD83C\uDFB5', wav: '\uD83C\uDFB5', ogg: '\uD83C\uDFB5',
mp4: '\uD83C\uDFAC', avi: '\uD83C\uDFAC', mov: '\uD83C\uDFAC', webm: '\uD83C\uDFAC',
eml: '\uD83D\uDCE7', msg: '\uD83D\uDCE7',
};
return map[ext] || '\uD83D\uDCC4';
}
/**
* Queue-aware audio player with replay support.
*
* During the initial queue pass the player shows queue/active/done state.
* Once an item is done, clicking Play starts an independent local replay
* so every message can be re-listened at any time.
*/
function _QueuedAudioPlayer({
msgId,
url,
language,
audioQueue,
}: {
msgId: string;
url: string;
language?: string;
audioQueue: AudioQueueApi;
}) {
const isActive = audioQueue.state.currentId === msgId;
const isQueued = audioQueue.isItemQueued(msgId);
const queueDone = !isActive && !isQueued;
const isQueuePlaying = isActive && audioQueue.state.isPlaying && !audioQueue.state.isPaused;
const isQueuePaused = isActive && audioQueue.state.isPaused;
const replayRef = useRef<HTMLAudioElement | null>(null);
const [replayPlaying, setReplayPlaying] = useState(false);
const [replayProgress, setReplayProgress] = useState(0);
const [replayDuration, setReplayDuration] = useState(0);
const [queueProgress, setQueueProgress] = useState(0);
const [queueDuration, setQueueDuration] = useState(0);
useEffect(() => {
return () => {
if (replayRef.current) { replayRef.current.pause(); replayRef.current = null; }
};
}, []);
useEffect(() => {
if (!isActive) return;
const interval = setInterval(() => {
setQueueProgress(audioQueue.getProgress());
setQueueDuration(audioQueue.getDuration());
}, 200);
return () => clearInterval(interval);
}, [isActive, audioQueue]);
const _startReplay = useCallback(() => {
if (replayRef.current) { replayRef.current.pause(); replayRef.current = null; }
const audio = new Audio(url);
replayRef.current = audio;
audio.addEventListener('loadedmetadata', () => setReplayDuration(audio.duration));
audio.addEventListener('timeupdate', () => {
if (audio.duration) setReplayProgress(audio.currentTime / audio.duration);
});
audio.addEventListener('ended', () => {
setReplayPlaying(false);
setReplayProgress(0);
replayRef.current = null;
});
audio.play()
.then(() => setReplayPlaying(true))
.catch(() => setReplayPlaying(false));
}, [url]);
const _toggleReplay = useCallback(() => {
const audio = replayRef.current;
if (!audio) { _startReplay(); return; }
if (audio.paused) {
audio.play().then(() => setReplayPlaying(true)).catch(() => {});
} else {
audio.pause();
setReplayPlaying(false);
}
}, [_startReplay]);
const _stopReplay = useCallback(() => {
if (replayRef.current) {
replayRef.current.pause();
replayRef.current.currentTime = 0;
replayRef.current = null;
}
setReplayPlaying(false);
setReplayProgress(0);
}, []);
const _handleMainButton = useCallback(() => {
if (isActive) {
if (isQueuePaused) audioQueue.resume();
else audioQueue.pause();
} else if (queueDone) {
_toggleReplay();
}
}, [isActive, isQueuePaused, queueDone, audioQueue, _toggleReplay]);
const _handleSkip = useCallback(() => {
if (isActive) audioQueue.skip();
}, [isActive, audioQueue]);
const _formatTime = (s: number) => {
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec.toString().padStart(2, '0')}`;
};
const isAnythingPlaying = isQueuePlaying || replayPlaying;
const showProgress = isActive ? queueProgress : replayProgress;
const showDuration = isActive ? queueDuration : replayDuration;
const isWaiting = isQueued && !isActive;
let statusLabel = '';
if (isWaiting) statusLabel = 'in Warteschlange';
let buttonIcon = '\u25B6';
let buttonTitle = 'Abspielen';
if (isAnythingPlaying) { buttonIcon = '\u275A\u275A'; buttonTitle = 'Pause'; }
else if (isQueuePaused) { buttonIcon = '\u25B6'; buttonTitle = 'Weiter'; }
else if (isWaiting) { buttonIcon = '\u25B6'; buttonTitle = 'Warten…'; }
const canInteract = isActive || queueDone;
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '8px 12px', borderRadius: 8,
background: isActive
? 'var(--audio-player-bg-active, #e8f0fe)'
: replayPlaying
? 'var(--audio-player-bg-active, #e8f0fe)'
: 'var(--audio-player-bg, #f0f4f8)',
border: (isActive || replayPlaying)
? '1px solid var(--primary-color, #F25843)'
: '1px solid var(--border-color, #e0e0e0)',
maxWidth: 360, marginTop: 6,
transition: 'border-color 0.3s, background 0.3s',
}}>
<button
onClick={_handleMainButton}
disabled={isWaiting}
style={{
width: 32, height: 32, borderRadius: '50%', border: 'none',
background: canInteract ? 'var(--primary-color, #F25843)' : '#bbb',
color: '#fff',
cursor: canInteract ? 'pointer' : 'default',
fontSize: 14,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}
title={buttonTitle}
>
{buttonIcon}
</button>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
height: 4, borderRadius: 2,
background: 'var(--border-color, #ddd)',
overflow: 'hidden',
}}>
<div style={{
height: '100%', borderRadius: 2,
background: 'var(--primary-color, #F25843)',
width: `${showProgress * 100}%`,
transition: 'width 0.2s',
}} />
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 2 }}>
<span style={{ fontSize: 10, color: '#888' }}>
{showDuration > 0 ? _formatTime(showProgress * showDuration) : '0:00'}
</span>
<span style={{ fontSize: 10, color: '#888' }}>
{statusLabel || (showDuration > 0 ? _formatTime(showDuration) : '--:--')}
</span>
</div>
</div>
{(isActive || replayPlaying) && (
<button
onClick={isActive ? _handleSkip : _stopReplay}
style={{
width: 28, height: 28, borderRadius: '50%', border: '1px solid #ccc',
background: 'transparent', color: '#888',
cursor: 'pointer', fontSize: 12,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}
title={isActive ? 'Skip' : 'Stop'}
>
{isActive ? '\u23ED' : '\u25A0'}
</button>
)}
{language && (
<span style={{ fontSize: 10, color: '#aaa', flexShrink: 0 }}>
{language}
</span>
)}
</div>
);
}
function _CodeBlock({
className,
children,
...props
}: React.HTMLAttributes<HTMLElement> & { inline?: boolean }) {
const match = /language-(\w+)/.exec(className || '');
const isInline = !match && !String(children).includes('\n');
const [copied, setCopied] = useState(false);
const _copyCode = useCallback(() => {
const text = String(children).replace(/\n$/, '');
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}).catch(() => {});
}, [children]);
if (isInline) {
return (
<code
style={{
background: '#f0f0f0',
padding: '1px 5px',
borderRadius: 3,
fontSize: '0.9em',
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
}}
{...props}
>
{children}
</code>
);
}
return (
<div style={{ position: 'relative', margin: '8px 0' }}>
<div style={{
position: 'absolute', top: 0, right: 0,
display: 'flex', alignItems: 'center', gap: 4,
background: '#2d2d2d', borderBottomLeftRadius: 4,
padding: '2px 4px',
}}>
{match && (
<span style={{ fontSize: 10, color: '#888', padding: '0 4px' }}>
{match[1]}
</span>
)}
<button
onClick={_copyCode}
title={copied ? 'Kopiert' : 'Kopieren'}
style={{
background: 'none', border: 'none', cursor: 'pointer',
padding: '2px 6px', display: 'flex', alignItems: 'center',
color: copied ? '#4caf50' : '#888',
transition: 'color 0.2s',
}}
onMouseEnter={e => { if (!copied) e.currentTarget.style.color = '#ccc'; }}
onMouseLeave={e => { if (!copied) e.currentTarget.style.color = '#888'; }}
>
{copied ? <FaCheck size={11} /> : <FaRegCopy size={11} />}
</button>
</div>
<pre style={{
background: '#1e1e1e',
color: '#d4d4d4',
padding: '12px 14px',
borderRadius: 6,
overflow: 'auto',
fontSize: 13,
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
lineHeight: 1.5,
margin: 0,
}}>
<code className={className} {...props}>
{children}
</code>
</pre>
</div>
);
}