feat(ui): add Button, Modal, Spinner, Toast, and Tooltip components with styles
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
- Implemented Button component with various props for customization. - Created Modal component with header, content, and footer subcomponents. - Added Spinner component for loading indicators. - Developed Toast component for displaying notifications. - Introduced Tooltip component for contextual hints with keyboard shortcuts. - Added corresponding CSS modules for styling each component. - Updated index file to export new components. - Configured TypeScript settings for the UI package.
This commit is contained in:
133
packages/shared/src/components/voice/PiPOverlay.tsx
Normal file
133
packages/shared/src/components/voice/PiPOverlay.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* PiPOverlay — floating voice-call status widget shown when the user
|
||||
* is connected to a voice channel but has navigated to a different
|
||||
* channel / DM / route. Presents the channel name, a "Return to
|
||||
* channel" button, and a disconnect button. The widget is draggable
|
||||
* via pointer events.
|
||||
*
|
||||
* The new UI's screen-share video PiP required live LiveKit track
|
||||
* binding from a MobX store; our Convex/React voice context doesn't
|
||||
* expose that the same way, so this is a simpler status widget
|
||||
* focused on the common case: "I'm in a call, I want to get back."
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { PhoneDisconnect, ArrowRight } from '@phosphor-icons/react';
|
||||
import { useVoice } from '../../contexts/VoiceContext';
|
||||
import styles from './PiPOverlay.module.css';
|
||||
|
||||
interface Box {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const WIDTH = 260;
|
||||
const HEIGHT = 96;
|
||||
|
||||
function clamp(v: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, v));
|
||||
}
|
||||
|
||||
export function PiPOverlay() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const voice = useVoice() as any;
|
||||
|
||||
const [box, setBox] = useState<Box>(() => ({
|
||||
x: Math.max(16, window.innerWidth - WIDTH - 24),
|
||||
y: Math.max(16, window.innerHeight - HEIGHT - 24),
|
||||
}));
|
||||
const dragRef = useRef<{ startX: number; startY: number; startBox: Box } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMove = (e: PointerEvent) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag) return;
|
||||
const dx = e.clientX - drag.startX;
|
||||
const dy = e.clientY - drag.startY;
|
||||
setBox({
|
||||
x: clamp(drag.startBox.x + dx, 0, window.innerWidth - WIDTH),
|
||||
y: clamp(drag.startBox.y + dy, 0, window.innerHeight - HEIGHT),
|
||||
});
|
||||
};
|
||||
const handleUp = () => {
|
||||
dragRef.current = null;
|
||||
};
|
||||
window.addEventListener('pointermove', handleMove);
|
||||
window.addEventListener('pointerup', handleUp);
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handleMove);
|
||||
window.removeEventListener('pointerup', handleUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startDrag = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
// Don't initiate a drag when the pointer lands on a button.
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('button')) return;
|
||||
e.preventDefault();
|
||||
dragRef.current = {
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
startBox: { ...box },
|
||||
};
|
||||
},
|
||||
[box],
|
||||
);
|
||||
|
||||
const activeChannelId: string | null = voice?.activeChannelId ?? null;
|
||||
const activeChannelName: string | null = voice?.activeChannelName ?? null;
|
||||
|
||||
// Hide the overlay when not in a call or when the user is already
|
||||
// looking at the active voice channel.
|
||||
if (!activeChannelId) return null;
|
||||
const onActiveChannelRoute =
|
||||
location.pathname.includes(`/${activeChannelId}`);
|
||||
if (onActiveChannelRoute) return null;
|
||||
|
||||
const handleReturn = () => {
|
||||
navigate(`/channels/home/${activeChannelId}`);
|
||||
};
|
||||
const handleDisconnect = () => {
|
||||
voice?.disconnect?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.container}
|
||||
style={{
|
||||
transform: `translate(${box.x}px, ${box.y}px)`,
|
||||
width: WIDTH,
|
||||
height: HEIGHT,
|
||||
}}
|
||||
onPointerDown={startDrag}
|
||||
>
|
||||
<div className={styles.pipContent}>
|
||||
<div className={styles.pipLabel}>In voice</div>
|
||||
<div className={styles.pipChannel}>
|
||||
#{activeChannelName || 'channel'}
|
||||
</div>
|
||||
<div className={styles.pipActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pipPrimary}
|
||||
onClick={handleReturn}
|
||||
aria-label="Return to channel"
|
||||
>
|
||||
<ArrowRight size={14} weight="bold" />
|
||||
<span>Return</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pipDanger}
|
||||
onClick={handleDisconnect}
|
||||
aria-label="Disconnect"
|
||||
>
|
||||
<PhoneDisconnect size={14} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user