feat: floating glass navigation and action bars in modals
Replace inline Save/Discard/Reset buttons with sticky glass-bubble pills that float at the bottom of scrollable modal content. Make SpaceSettings tab sidebar sticky with glass material. Convert RoleEditView "Back to roles" into a sticky glass pill at the top. UserSettings Log Out + Save always visible in a glass pill with separator. Also includes floating position hook and popover/tooltip improvements from prior work.
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import React, { useMemo, useRef, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { MemberWithUser } from '@backspace/shared';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { useSpaceStore } from '../../stores/spaceStore';
|
||||
import { useFloatingPosition } from '../../hooks/useFloatingPosition';
|
||||
|
||||
const MAX_RESULTS = 8;
|
||||
|
||||
@@ -9,13 +11,15 @@ interface MentionPopoverProps {
|
||||
query: string;
|
||||
selectedIndex: number;
|
||||
onSelect: (member: MemberWithUser) => void;
|
||||
anchorRef: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
export function MentionPopover({ query, selectedIndex, onSelect }: MentionPopoverProps) {
|
||||
export function MentionPopover({ query, selectedIndex, onSelect, anchorRef }: MentionPopoverProps) {
|
||||
const members = useSpaceStore((s) => s.members);
|
||||
const spaces = useSpaceStore((s) => s.spaces);
|
||||
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
|
||||
const selectedRef = useRef<HTMLDivElement>(null);
|
||||
const floatingRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const ownerId = spaces.find((s) => s.id === currentSpaceId)?.ownerId;
|
||||
|
||||
@@ -30,6 +34,12 @@ export function MentionPopover({ query, selectedIndex, onSelect }: MentionPopove
|
||||
.slice(0, MAX_RESULTS);
|
||||
}, [members, query]);
|
||||
|
||||
const { style } = useFloatingPosition(anchorRef, floatingRef, {
|
||||
placement: 'top',
|
||||
offset: 4,
|
||||
enabled: filtered.length > 0,
|
||||
});
|
||||
|
||||
// Scroll selected item into view
|
||||
useEffect(() => {
|
||||
selectedRef.current?.scrollIntoView({ block: 'nearest' });
|
||||
@@ -46,8 +56,8 @@ export function MentionPopover({ query, selectedIndex, onSelect }: MentionPopove
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-full left-0 w-[280px] mb-1 z-50">
|
||||
return createPortal(
|
||||
<div ref={floatingRef} style={style} className="w-[280px]">
|
||||
<div className="bg-surface-elevated rounded-lg shadow-[0_0_0_1px_rgba(0,0,0,0.15),0_8px_16px_rgba(0,0,0,0.24)] overflow-hidden max-h-[320px] overflow-y-auto scrollbar-thin">
|
||||
<div className="px-2 py-1.5 text-[11px] font-bold text-txt-tertiary uppercase tracking-wider">
|
||||
Members
|
||||
@@ -88,6 +98,7 @@ export function MentionPopover({ query, selectedIndex, onSelect }: MentionPopove
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
const [mentionState, setMentionState] = useState<MentionState | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const inputContainerRef = useRef<HTMLDivElement>(null);
|
||||
const sendMessage = useChatStore((s) => s.sendMessage);
|
||||
const replyTo = useChatStore((s) => s.replyTo);
|
||||
const setReplyTo = useChatStore((s) => s.setReplyTo);
|
||||
@@ -249,6 +250,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={inputContainerRef}
|
||||
className={`relative bg-surface-input md:bg-transparent ${replyTo ? 'rounded-b-lg' : 'rounded-lg md:rounded-none'} overflow-visible`}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
@@ -259,6 +261,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
query={mentionState.query}
|
||||
selectedIndex={mentionState.selectedIndex}
|
||||
onSelect={selectMention}
|
||||
anchorRef={inputContainerRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -196,20 +196,24 @@ function StreamingLimitsPanel() {
|
||||
<div className="p-2 bg-status-online/10 border border-status-online/30 rounded text-status-online text-sm">Settings saved</div>
|
||||
)}
|
||||
{hasChanges && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-4 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="px-4 py-1.5 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<div className="sticky bottom-0 z-10 pointer-events-none">
|
||||
<div className="flex justify-center pt-3 pb-1">
|
||||
<div className="glass-bubble rounded-full px-4 py-2 flex items-center gap-2 animate-slide-up pointer-events-auto">
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -331,20 +335,24 @@ function DiscoveryPanel({ spaceId }: { spaceId: string }) {
|
||||
<div className="p-2 bg-status-online/10 border border-status-online/30 rounded text-status-online text-sm">Settings saved</div>
|
||||
)}
|
||||
{hasChanges && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-4 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="px-4 py-1.5 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<div className="sticky bottom-0 z-10 pointer-events-none">
|
||||
<div className="flex justify-center pt-3 pb-1">
|
||||
<div className="glass-bubble rounded-full px-4 py-2 flex items-center gap-2 animate-slide-up pointer-events-auto">
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -479,7 +487,7 @@ export function SpaceSettingsModal() {
|
||||
if (!space || !currentSpaceId) return null;
|
||||
|
||||
const tabClass = (t: typeof tab) =>
|
||||
`w-full text-left px-3 py-1.5 rounded text-sm transition-colors ${
|
||||
`w-full text-left px-2.5 py-1.5 rounded text-sm transition-colors ${
|
||||
tab === t ? 'bg-interactive-selected text-txt-primary' : 'text-txt-tertiary hover:text-txt-secondary hover:bg-interactive-hover'
|
||||
}`;
|
||||
|
||||
@@ -487,28 +495,30 @@ export function SpaceSettingsModal() {
|
||||
<Modal isOpen={isOpen} onClose={closeModal} title="Space Settings" maxWidth="max-w-xl">
|
||||
<div className="flex gap-4">
|
||||
{/* Tabs */}
|
||||
<div className="w-32 flex-shrink-0 space-y-1">
|
||||
<button onClick={() => setTab('overview')} className={tabClass('overview')}>
|
||||
Overview
|
||||
</button>
|
||||
{canManageSpace && (
|
||||
<button onClick={() => setTab('discovery')} className={tabClass('discovery')}>
|
||||
Discovery
|
||||
<div className="w-32 flex-shrink-0 sticky top-0 self-start z-10">
|
||||
<div className="glass-bubble rounded-lg p-1.5 space-y-0.5">
|
||||
<button onClick={() => setTab('overview')} className={tabClass('overview')}>
|
||||
Overview
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => setTab('members')} className={tabClass('members')}>
|
||||
Members
|
||||
</button>
|
||||
{canManageRoles && (
|
||||
<button onClick={() => setTab('roles')} className={tabClass('roles')}>
|
||||
Roles
|
||||
{canManageSpace && (
|
||||
<button onClick={() => setTab('discovery')} className={tabClass('discovery')}>
|
||||
Discovery
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => setTab('members')} className={tabClass('members')}>
|
||||
Members
|
||||
</button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<button onClick={() => setTab('streaming')} className={tabClass('streaming')}>
|
||||
Streaming
|
||||
</button>
|
||||
)}
|
||||
{canManageRoles && (
|
||||
<button onClick={() => setTab('roles')} className={tabClass('roles')}>
|
||||
Roles
|
||||
</button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<button onClick={() => setTab('streaming')} className={tabClass('streaming')}>
|
||||
Streaming
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
|
||||
@@ -172,20 +172,25 @@ export function UserSettingsModal() {
|
||||
{/* Connected Instances */}
|
||||
<ConnectedInstances />
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="px-4 py-2 text-sm text-txt-danger hover:bg-accent-rose/10 rounded transition-colors"
|
||||
>
|
||||
Log Out
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
<div className="sticky bottom-0 z-10 pointer-events-none">
|
||||
<div className="flex justify-center pt-3 pb-1">
|
||||
<div className="glass-bubble rounded-full px-3 py-2 flex items-center gap-3 pointer-events-auto">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="px-3 py-1 text-sm text-txt-danger hover:bg-accent-rose/10 rounded-full transition-colors"
|
||||
>
|
||||
Log Out
|
||||
</button>
|
||||
<div className="w-px h-5 bg-white/10" />
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isLoading}
|
||||
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -229,20 +229,24 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
||||
<div className="p-2 bg-status-online/10 border border-status-online/30 rounded text-status-online text-sm">Settings saved</div>
|
||||
)}
|
||||
{canManageSpace && hasChanges && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || uploadingIcon || !spaceName.trim()}
|
||||
className="px-4 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDiscard}
|
||||
className="px-4 py-1.5 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
<div className="sticky bottom-0 z-10 pointer-events-none">
|
||||
<div className="flex justify-center pt-3 pb-1">
|
||||
<div className="glass-bubble rounded-full px-4 py-2 flex items-center gap-2 animate-slide-up pointer-events-auto">
|
||||
<button
|
||||
onClick={handleDiscard}
|
||||
className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || uploadingIcon || !spaceName.trim()}
|
||||
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -242,15 +242,17 @@ function RoleEditView({ role, spaceId, onBack, onDeleted }: RoleEditViewProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Back button */}
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to roles
|
||||
</button>
|
||||
<div className="sticky top-0 z-10 pointer-events-none pb-3">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="glass-bubble rounded-full px-3 py-1.5 flex items-center gap-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors pointer-events-auto"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to roles
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Role Name (not editable for @everyone) */}
|
||||
{!isEveryone && (
|
||||
@@ -362,20 +364,24 @@ function RoleEditView({ role, spaceId, onBack, onDeleted }: RoleEditViewProps) {
|
||||
<div className="p-2 bg-status-online/10 border border-status-online/30 rounded text-status-online text-sm">Role saved</div>
|
||||
)}
|
||||
{hasChanges && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || (!isEveryone && !draftName.trim())}
|
||||
className="px-4 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDiscard}
|
||||
className="px-4 py-1.5 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
<div className="sticky bottom-0 z-10 pointer-events-none">
|
||||
<div className="flex justify-center pt-3 pb-1">
|
||||
<div className="glass-bubble rounded-full px-4 py-2 flex items-center gap-2 animate-slide-up pointer-events-auto">
|
||||
<button
|
||||
onClick={handleDiscard}
|
||||
className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || (!isEveryone && !draftName.trim())}
|
||||
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -49,6 +49,8 @@ export function ContextMenu({ items, children }: ContextMenuProps) {
|
||||
if (rect.bottom > window.innerHeight) {
|
||||
newPosition.y = window.innerHeight - rect.height - 8;
|
||||
}
|
||||
if (newPosition.x < 8) newPosition.x = 8;
|
||||
if (newPosition.y < 8) newPosition.y = 8;
|
||||
|
||||
if (newPosition.x !== position.x || newPosition.y !== position.y) {
|
||||
setPosition(newPosition);
|
||||
@@ -62,7 +64,7 @@ export function ContextMenu({ items, children }: ContextMenuProps) {
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed z-[200] min-w-[180px] py-1.5 bg-surface-elevated rounded-md shadow-elevation-high animate-fade-in"
|
||||
className="fixed z-[200] min-w-[180px] py-1.5 bg-surface-elevated rounded-md shadow-elevation-high animate-fade-in max-h-[calc(100vh-16px)] overflow-y-auto scrollbar-thin"
|
||||
style={{ left: position.x, top: position.y }}
|
||||
>
|
||||
{items.map((item, i) => (
|
||||
|
||||
@@ -30,9 +30,9 @@ export function Modal({ isOpen, onClose, title, children, maxWidth = 'max-w-md'
|
||||
className="absolute inset-0 bg-surface-overlay"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className={`relative ${maxWidth} w-full mx-4 bg-surface-elevated rounded-lg shadow-xl animate-slide-up`}>
|
||||
<div className={`relative ${maxWidth} w-full mx-4 max-h-[calc(100vh-2rem)] flex flex-col bg-surface-elevated rounded-lg shadow-xl animate-slide-up`}>
|
||||
{title && (
|
||||
<div className="flex items-center justify-between px-4 pt-4">
|
||||
<div className="flex items-center justify-between px-4 pt-4 flex-shrink-0">
|
||||
<h2 className="text-xl font-bold text-txt-primary">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
@@ -44,7 +44,7 @@ export function Modal({ isOpen, onClose, title, children, maxWidth = 'max-w-md'
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4">
|
||||
<div className="p-4 overflow-y-auto scrollbar-thin flex-1 min-h-0">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useFloatingPosition } from '../../hooks/useFloatingPosition';
|
||||
|
||||
interface TooltipProps {
|
||||
content: string;
|
||||
@@ -10,6 +12,14 @@ interface TooltipProps {
|
||||
export function Tooltip({ content, children, position = 'right', delay = 200 }: TooltipProps) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const anchorRef = useRef<HTMLDivElement>(null);
|
||||
const floatingRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { style } = useFloatingPosition(anchorRef, floatingRef, {
|
||||
placement: position,
|
||||
offset: 8,
|
||||
enabled: isVisible,
|
||||
});
|
||||
|
||||
const show = () => {
|
||||
timeoutRef.current = setTimeout(() => setIsVisible(true), delay);
|
||||
@@ -26,22 +36,18 @@ export function Tooltip({ content, children, position = 'right', delay = 200 }:
|
||||
};
|
||||
}, []);
|
||||
|
||||
const positionClasses: Record<string, string> = {
|
||||
top: 'bottom-full left-1/2 -translate-x-1/2 mb-2',
|
||||
right: 'left-full top-1/2 -translate-y-1/2 ml-2',
|
||||
bottom: 'top-full left-1/2 -translate-x-1/2 mt-2',
|
||||
left: 'right-full top-1/2 -translate-y-1/2 mr-2',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative inline-flex" onMouseEnter={show} onMouseLeave={hide}>
|
||||
<div ref={anchorRef} className="relative inline-flex" onMouseEnter={show} onMouseLeave={hide}>
|
||||
{children}
|
||||
{isVisible && (
|
||||
{isVisible && createPortal(
|
||||
<div
|
||||
className={`absolute z-[200] px-3 py-1.5 text-sm font-medium text-txt-primary bg-surface-elevated rounded-md shadow-elevation-high whitespace-nowrap pointer-events-none ${positionClasses[position]}`}
|
||||
ref={floatingRef}
|
||||
style={style}
|
||||
className="px-3 py-1.5 text-sm font-medium text-txt-primary bg-surface-elevated rounded-md shadow-elevation-high whitespace-nowrap pointer-events-none"
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTrackStats, AudioTrackStat, VideoTrackStat } from '../../hooks/useTrackStats';
|
||||
import { getActiveRoom } from '../../hooks/useLiveKit';
|
||||
import { useFloatingPosition } from '../../hooks/useFloatingPosition';
|
||||
|
||||
interface ConnectionInfoPopoverProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
anchorRef: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
function formatBitrate(kbps: number): string {
|
||||
@@ -110,10 +113,16 @@ function VideoTrackRow({ track }: { track: VideoTrackStat }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ConnectionInfoPopover({ open, onClose }: ConnectionInfoPopoverProps) {
|
||||
export function ConnectionInfoPopover({ open, onClose, anchorRef }: ConnectionInfoPopoverProps) {
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const stats = useTrackStats(open);
|
||||
|
||||
const { style } = useFloatingPosition(anchorRef, popoverRef, {
|
||||
placement: 'top',
|
||||
offset: 12,
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
// Click-outside to close
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -130,16 +139,17 @@ export function ConnectionInfoPopover({ open, onClose }: ConnectionInfoPopoverPr
|
||||
|
||||
const room = getActiveRoom();
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-3 w-[300px] glass rounded-lg z-50 overflow-hidden"
|
||||
style={style}
|
||||
className="w-[300px] glass rounded-lg overflow-hidden"
|
||||
>
|
||||
<div className="px-3 py-2 border-b border-border-hard">
|
||||
<span className="text-[14px] font-bold text-txt-primary">Connection Info</span>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2">
|
||||
<div className="px-3 py-2 max-h-[calc(100vh-32px)] overflow-y-auto scrollbar-thin">
|
||||
{!room ? (
|
||||
<div className="text-[12px] text-txt-tertiary py-2 text-center">Not connected</div>
|
||||
) : !stats ? (
|
||||
@@ -197,6 +207,7 @@ export function ConnectionInfoPopover({ open, onClose }: ConnectionInfoPopoverPr
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import type { ScreenShareConfig } from '../../stores/voiceStore';
|
||||
import { useSettingsStore } from '../../stores/settingsStore';
|
||||
import { buildScreenShareOptions } from '../../utils/screenShare';
|
||||
import { useFloatingPosition } from '../../hooks/useFloatingPosition';
|
||||
|
||||
interface ScreenShareSettingsPopoverProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
anchorRef: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
const ALL_RESOLUTIONS: { value: ScreenShareConfig['height']; label: string }[] = [
|
||||
@@ -45,12 +48,18 @@ function formatKbps(kbps: number): string {
|
||||
: `${kbps} kbps`;
|
||||
}
|
||||
|
||||
export function ScreenShareSettingsPopover({ open, onClose }: ScreenShareSettingsPopoverProps) {
|
||||
export function ScreenShareSettingsPopover({ open, onClose, anchorRef }: ScreenShareSettingsPopoverProps) {
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const config = useVoiceStore((s) => s.screenShareConfig);
|
||||
const setConfig = useVoiceStore((s) => s.setScreenShareConfig);
|
||||
const limits = useSettingsStore((s) => s.streamingLimits);
|
||||
|
||||
const { style } = useFloatingPosition(anchorRef, popoverRef, {
|
||||
placement: 'top',
|
||||
offset: 12,
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const BITRATE_MIN = limits?.minBitrateKbps ?? 500;
|
||||
const BITRATE_MAX = limits?.maxBitrateKbps ?? 20000;
|
||||
const BITRATE_STEP = limits?.bitrateStepKbps ?? 500;
|
||||
@@ -104,10 +113,11 @@ export function ScreenShareSettingsPopover({ open, onClose }: ScreenShareSetting
|
||||
const pillSelected = 'bg-accent-primary text-white';
|
||||
const pillUnselected = 'bg-surface-elevated text-txt-secondary hover:bg-interactive-hover';
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-3 w-[260px] glass rounded-lg z-50 overflow-hidden"
|
||||
style={style}
|
||||
className="w-[260px] glass rounded-lg overflow-hidden"
|
||||
>
|
||||
<div className="px-3 py-2 border-b border-border-hard">
|
||||
<span className="text-[14px] font-bold text-txt-primary">Stream Settings</span>
|
||||
@@ -215,6 +225,7 @@ export function ScreenShareSettingsPopover({ open, onClose }: ScreenShareSetting
|
||||
{formatBitrate(result.publish.videoEncoding.maxBitrate)} · {formatDegradation(result.overdrive.degradationPreference)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ export function StreamTile({ tile, large }: StreamTileProps) {
|
||||
// Context menu state
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const [qualityPopoverOpen, setQualityPopoverOpen] = useState(false);
|
||||
const qualityBtnRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// --- VIDEO --- use LiveKit's track.attach() to register the element
|
||||
// with the adaptive stream observer (enables SFU layer switching by viewport size)
|
||||
@@ -260,6 +261,7 @@ export function StreamTile({ tile, large }: StreamTileProps) {
|
||||
</div>
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={qualityBtnRef}
|
||||
onClick={() => setQualityPopoverOpen(!qualityPopoverOpen)}
|
||||
className="w-full flex items-center justify-between px-2 py-1.5 text-sm text-txt-secondary hover:bg-interactive-hover rounded transition-colors"
|
||||
>
|
||||
@@ -277,6 +279,7 @@ export function StreamTile({ tile, large }: StreamTileProps) {
|
||||
<ScreenShareSettingsPopover
|
||||
open={qualityPopoverOpen}
|
||||
onClose={() => setQualityPopoverOpen(false)}
|
||||
anchorRef={qualityBtnRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { getActiveRoom } from '../../hooks/useLiveKit';
|
||||
@@ -27,6 +27,7 @@ export function VoiceControlBar() {
|
||||
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
|
||||
const voiceOrigin = currentVoiceChannelId ? getChannelOrigin(currentVoiceChannelId) : '';
|
||||
const [qualityOpen, setQualityOpen] = useState(false);
|
||||
const qualityBtnRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const handleMute = React.useCallback(async () => {
|
||||
toggleMic();
|
||||
@@ -207,21 +208,20 @@ export function VoiceControlBar() {
|
||||
</button>
|
||||
|
||||
{/* Video Quality */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setQualityOpen(!qualityOpen)}
|
||||
className={qualityOpen
|
||||
? `${btnBase} bg-surface-channel text-txt-primary`
|
||||
: btnDefault
|
||||
}
|
||||
title="Video Quality"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
|
||||
</svg>
|
||||
</button>
|
||||
<ScreenShareSettingsPopover open={qualityOpen} onClose={() => setQualityOpen(false)} />
|
||||
</div>
|
||||
<button
|
||||
ref={qualityBtnRef}
|
||||
onClick={() => setQualityOpen(!qualityOpen)}
|
||||
className={qualityOpen
|
||||
? `${btnBase} bg-surface-channel text-txt-primary`
|
||||
: btnDefault
|
||||
}
|
||||
title="Video Quality"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
|
||||
</svg>
|
||||
</button>
|
||||
<ScreenShareSettingsPopover open={qualityOpen} onClose={() => setQualityOpen(false)} anchorRef={qualityBtnRef} />
|
||||
|
||||
{/* Separator */}
|
||||
<div className="w-[1px] h-6 bg-white/10 mx-0.5" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore';
|
||||
import { getActiveRoom } from '../../hooks/useLiveKit';
|
||||
@@ -24,6 +24,8 @@ export function VoiceControls() {
|
||||
const channels = useSpaceStore((s) => s.channels);
|
||||
const [showScreenShareSettings, setShowScreenShareSettings] = useState(false);
|
||||
const [showConnectionInfo, setShowConnectionInfo] = useState(false);
|
||||
const connectionBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const qualityBtnRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const activeDmCall = useVoiceStore((s) => s.activeDmCall);
|
||||
|
||||
@@ -109,6 +111,7 @@ export function VoiceControls() {
|
||||
{/* Row 1: Signal icon + status text + disconnect */}
|
||||
<div className="relative flex items-center gap-2 px-3 pt-3 pb-1">
|
||||
<button
|
||||
ref={connectionBtnRef}
|
||||
onClick={() => {
|
||||
setShowConnectionInfo(!showConnectionInfo);
|
||||
if (!showConnectionInfo) setShowScreenShareSettings(false);
|
||||
@@ -146,6 +149,7 @@ export function VoiceControls() {
|
||||
<ConnectionInfoPopover
|
||||
open={showConnectionInfo}
|
||||
onClose={() => setShowConnectionInfo(false)}
|
||||
anchorRef={connectionBtnRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -189,6 +193,7 @@ export function VoiceControls() {
|
||||
|
||||
{/* Video Quality */}
|
||||
<button
|
||||
ref={qualityBtnRef}
|
||||
onClick={() => {
|
||||
setShowScreenShareSettings(!showScreenShareSettings);
|
||||
if (!showScreenShareSettings) setShowConnectionInfo(false);
|
||||
@@ -238,6 +243,7 @@ export function VoiceControls() {
|
||||
<ScreenShareSettingsPopover
|
||||
open={showScreenShareSettings}
|
||||
onClose={() => setShowScreenShareSettings(false)}
|
||||
anchorRef={qualityBtnRef}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user