215 lines
7.3 KiB
TypeScript
215 lines
7.3 KiB
TypeScript
"use client";
|
|
|
|
import { TranscriptionTile } from "@/transcriptions/TranscriptionTile";
|
|
import { TrackReferenceOrPlaceholder } from "@livekit/components-react";
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
|
|
export interface ChatOverlayProps {
|
|
agentAudioTrack?: TrackReferenceOrPlaceholder;
|
|
accentColor: string;
|
|
inputDisabled?: boolean;
|
|
isVisible: boolean;
|
|
position: { x: number; y: number };
|
|
onPositionChange: (position: { x: number; y: number }) => void;
|
|
containerRef: React.RefObject<HTMLDivElement>;
|
|
onToggle: () => void;
|
|
}
|
|
|
|
export function ChatOverlay({
|
|
agentAudioTrack,
|
|
accentColor,
|
|
inputDisabled,
|
|
isVisible,
|
|
position,
|
|
onPositionChange,
|
|
containerRef,
|
|
onToggle,
|
|
}: ChatOverlayProps) {
|
|
const overlayRef = useRef<HTMLDivElement>(null);
|
|
const headerRef = useRef<HTMLDivElement>(null);
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const dragOffset = useRef({ x: 0, y: 0 });
|
|
|
|
// Responsive sizing based on container size
|
|
const [containerSize, setContainerSize] = useState({ width: 360, height: 500 });
|
|
|
|
useEffect(() => {
|
|
const updateSize = () => {
|
|
if (containerRef.current) {
|
|
const rect = containerRef.current.getBoundingClientRect();
|
|
setContainerSize({ width: rect.width, height: rect.height });
|
|
}
|
|
};
|
|
|
|
updateSize();
|
|
const resizeObserver = new ResizeObserver(updateSize);
|
|
if (containerRef.current) {
|
|
resizeObserver.observe(containerRef.current);
|
|
}
|
|
|
|
return () => {
|
|
resizeObserver.disconnect();
|
|
};
|
|
}, [containerRef]);
|
|
|
|
// Calculate overlay size as percentage of container, with min/max constraints
|
|
// Width: larger (up to 95% of container)
|
|
const overlayWidth = Math.min(
|
|
Math.max(containerSize.width * 0.9, 280),
|
|
containerSize.width * 0.95
|
|
);
|
|
// Height: smaller (reduced from 60-85% to 40-60%)
|
|
const overlayHeight = Math.min(
|
|
Math.max(containerSize.height * 0.4, 250),
|
|
containerSize.height * 0.6
|
|
);
|
|
|
|
// Position overlay at center (slightly moved up) when first shown
|
|
const hasPositionedRef = useRef(false);
|
|
useEffect(() => {
|
|
if (isVisible && containerRef.current && containerSize.width > 0 && overlayWidth > 0 && overlayHeight > 0) {
|
|
// Calculate center position, moved up by 15% of container height
|
|
const centerX = (containerSize.width - overlayWidth) / 2;
|
|
const centerY = (containerSize.height - overlayHeight) / 2 - (containerSize.height * 0.15);
|
|
|
|
// Only auto-position on first show (when position is at origin)
|
|
if (!hasPositionedRef.current && position.x === 0 && position.y === 0) {
|
|
onPositionChange({ x: Math.max(0, centerX), y: Math.max(0, centerY) });
|
|
hasPositionedRef.current = true;
|
|
}
|
|
}
|
|
}, [isVisible, containerSize.width, containerSize.height, overlayWidth, overlayHeight, containerRef, position, onPositionChange]);
|
|
|
|
const handleDragStart = (e: React.MouseEvent | React.TouchEvent) => {
|
|
if (!overlayRef.current || !headerRef.current) return;
|
|
|
|
// Only allow dragging from the header, but not from buttons
|
|
const target = e.target as HTMLElement;
|
|
if (!headerRef.current.contains(target)) return;
|
|
|
|
// Don't drag if clicking on the close button
|
|
if (target.closest('button') || target.closest('svg')) return;
|
|
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsDragging(true);
|
|
|
|
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
|
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
|
|
|
|
dragOffset.current = {
|
|
x: clientX - position.x,
|
|
y: clientY - position.y,
|
|
};
|
|
};
|
|
|
|
const handleDragMove = useCallback((e: MouseEvent | TouchEvent) => {
|
|
if (!isDragging || !containerRef.current || !overlayRef.current) return;
|
|
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const containerRect = containerRef.current.getBoundingClientRect();
|
|
const overlayRect = overlayRef.current.getBoundingClientRect();
|
|
|
|
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
|
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
|
|
|
|
let newX = clientX - dragOffset.current.x;
|
|
let newY = clientY - dragOffset.current.y;
|
|
|
|
// Constrain within container bounds
|
|
const maxX = containerRect.width - overlayRect.width;
|
|
const maxY = containerRect.height - overlayRect.height;
|
|
const minY = 0; // Allow dragging to top
|
|
|
|
newX = Math.max(0, Math.min(newX, maxX));
|
|
newY = Math.max(minY, Math.min(newY, maxY));
|
|
|
|
onPositionChange({ x: newX, y: newY });
|
|
}, [isDragging, containerRef, overlayRef, onPositionChange]);
|
|
|
|
const handleDragEnd = useCallback(() => {
|
|
setIsDragging(false);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (isDragging) {
|
|
window.addEventListener("mousemove", handleDragMove);
|
|
window.addEventListener("mouseup", handleDragEnd);
|
|
window.addEventListener("touchmove", handleDragMove, { passive: false });
|
|
window.addEventListener("touchend", handleDragEnd);
|
|
}
|
|
return () => {
|
|
window.removeEventListener("mousemove", handleDragMove);
|
|
window.removeEventListener("mouseup", handleDragEnd);
|
|
window.removeEventListener("touchmove", handleDragMove);
|
|
window.removeEventListener("touchend", handleDragEnd);
|
|
};
|
|
}, [isDragging, handleDragMove, handleDragEnd]);
|
|
|
|
return (
|
|
<div
|
|
ref={overlayRef}
|
|
className="absolute z-40 rounded-lg border border-white/20 shadow-2xl backdrop-blur-md transition-all duration-300 flex flex-col"
|
|
style={{
|
|
left: `${position.x}px`,
|
|
top: `${position.y}px`,
|
|
width: `${overlayWidth}px`,
|
|
height: `${overlayHeight}px`,
|
|
backgroundColor: 'rgba(0, 0, 0, 0.85)',
|
|
cursor: isDragging ? 'grabbing' : 'default',
|
|
display: isVisible ? 'flex' : 'none',
|
|
}}
|
|
onMouseDown={handleDragStart}
|
|
onTouchStart={handleDragStart}
|
|
>
|
|
{/* Header with drag handle and close button */}
|
|
<div
|
|
ref={headerRef}
|
|
className="flex items-center justify-between px-4 py-2 border-b border-white/10 cursor-move select-none"
|
|
style={{ backgroundColor: 'rgba(0, 0, 0, 0.3)' }}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-2 h-2 rounded-full bg-white/40"></div>
|
|
<span className="text-white text-xs font-medium">Chat</span>
|
|
</div>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
onToggle();
|
|
}}
|
|
className="text-white hover:text-white transition-colors p-2 rounded hover:bg-white/10 flex items-center justify-center"
|
|
aria-label="Close chat overlay"
|
|
style={{ minWidth: '32px', minHeight: '32px' }}
|
|
>
|
|
<svg
|
|
className="w-5 h-5"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
strokeWidth={2.5}
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M6 18L18 6M6 6l12 12"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Chat content with padding */}
|
|
<div className="overflow-hidden flex flex-col px-2 py-2" style={{ height: `calc(100% - 40px)` }}>
|
|
<TranscriptionTile
|
|
agentAudioTrack={agentAudioTrack}
|
|
accentColor={accentColor}
|
|
inputDisabled={inputDisabled}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|