195 lines
8.2 KiB
React
195 lines
8.2 KiB
React
// Call UI components — Discord-style, matched to Clean-Rail aesthetic
|
|
|
|
const CALL_PARTICIPANTS = [
|
|
{ id: 'dennis', name: 'dennis', initial: 'D', color: 'teal', speaking: false, muted: false, video: false, me: true, e2ee: true },
|
|
{ id: 'max', name: 'Max', initial: 'M', color: 'teal', speaking: true, muted: false, video: true, sharing: true, e2ee: true },
|
|
{ id: 'lena', name: 'Lena', initial: 'L', color: 'amber', speaking: false, muted: true, video: false, e2ee: true },
|
|
{ id: 'tom', name: 'Tom', initial: 'T', color: 'violet', speaking: false, muted: false, video: false, e2ee: true },
|
|
{ id: 'nico', name: 'nico', initial: 'N', color: 'teal', speaking: true, muted: false, video: false, e2ee: true },
|
|
];
|
|
|
|
const ParticipantTile = ({ p, focused, onClick, small }) => (
|
|
<div className={`participant-tile ${p.speaking ? 'speaking' : ''} ${focused ? 'focused' : ''} ${small ? 'small' : ''}`} onClick={onClick}>
|
|
{p.video || p.sharing ? (
|
|
<div className="participant-video">
|
|
{p.sharing ? (
|
|
<div className="screenshare-stub">
|
|
<div className="ss-window">
|
|
<div className="ss-chrome"><span></span><span></span><span></span></div>
|
|
<div className="ss-content">
|
|
<div className="ss-line" style={{width: '60%'}}></div>
|
|
<div className="ss-line" style={{width: '80%'}}></div>
|
|
<div className="ss-line" style={{width: '40%'}}></div>
|
|
<div className="ss-block"></div>
|
|
<div className="ss-line" style={{width: '70%'}}></div>
|
|
</div>
|
|
</div>
|
|
<div className="ss-badge">
|
|
<Icon.Monitor /> {p.name} teilt Bildschirm
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="video-stub">
|
|
<Avatar chat={{initial: p.initial, color: p.color}} size="lg" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="participant-audio">
|
|
<div className={`audio-ring ${p.speaking ? 'pulse' : ''}`}>
|
|
<Avatar chat={{initial: p.initial, color: p.color}} size="lg" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="participant-meta">
|
|
<div className="participant-name">
|
|
{p.me && <Icon.Crown />}
|
|
<span>{p.name}{p.me ? ' (du)' : ''}</span>
|
|
{p.e2ee && <span className="e2ee-dot" title="End-to-End verschlüsselt"><Icon.Lock /></span>}
|
|
</div>
|
|
<div className="participant-icons">
|
|
{p.muted && <span className="pi muted"><Icon.MicOff /></span>}
|
|
{p.sharing && <span className="pi sharing"><Icon.Monitor /></span>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const CallControls = ({ muted, onToggleMute, sharing, onToggleShare, video, onToggleVideo, onHangup, compact }) => (
|
|
<div className={`call-controls ${compact ? 'compact' : ''}`}>
|
|
<button className={`cc-btn ${muted ? 'active-danger' : ''}`} onClick={onToggleMute} title={muted ? 'Unmute' : 'Mute'}>
|
|
{muted ? <Icon.MicOff /> : <Icon.Mic />}
|
|
</button>
|
|
<button className={`cc-btn ${video ? 'active-brand' : ''}`} onClick={onToggleVideo} title="Video">
|
|
<Icon.Video />
|
|
</button>
|
|
<button className={`cc-btn ${sharing ? 'active-brand' : ''}`} onClick={onToggleShare} title="Bildschirm teilen">
|
|
<Icon.Monitor />
|
|
</button>
|
|
<button className="cc-btn" title="Teilnehmer"><Icon.Friends /></button>
|
|
<button className="cc-btn hangup" onClick={onHangup} title="Auflegen"><Icon.PhoneOff /></button>
|
|
</div>
|
|
);
|
|
|
|
const IncomingCallScreen = ({ chat, onAccept, onDecline }) => (
|
|
<div className="incoming-call">
|
|
<div className="incoming-card">
|
|
<div className="incoming-ringing">Eingehender Anruf</div>
|
|
<div className="incoming-avatar">
|
|
<div className="pulse-ring"></div>
|
|
<div className="pulse-ring d2"></div>
|
|
<Avatar chat={{initial: chat.initial, color: chat.color}} size="lg" />
|
|
</div>
|
|
<div className="incoming-name">{chat.name}</div>
|
|
<div className="incoming-sub">
|
|
<Icon.Lock /> E2E verschlüsselt · Voice Call
|
|
</div>
|
|
<div className="incoming-actions">
|
|
<button className="incoming-btn decline" onClick={onDecline}>
|
|
<Icon.PhoneOff /> Ablehnen
|
|
</button>
|
|
<button className="incoming-btn accept" onClick={onAccept}>
|
|
<Icon.Phone /> Annehmen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const ActiveCallScreen = ({ participants, mode, setMode, focused, setFocused, onHangup, callState, setCallState }) => {
|
|
const speaker = participants.find(p => p.id === focused) || participants.find(p => p.sharing) || participants[0];
|
|
const others = participants.filter(p => p.id !== speaker.id);
|
|
const sharingParticipant = participants.find(p => p.sharing);
|
|
|
|
return (
|
|
<div className={`active-call mode-${mode}`}>
|
|
<div className="call-topbar">
|
|
<div className="call-topbar-left">
|
|
<div className="call-title">
|
|
<Icon.Friends />
|
|
<span>Die Squad</span>
|
|
<span className="call-dot">·</span>
|
|
<span className="call-duration">{callState.duration}</span>
|
|
</div>
|
|
<div className="call-e2ee"><Icon.Lock /> E2E verschlüsselt</div>
|
|
</div>
|
|
<div className="call-topbar-right">
|
|
<button className={`cc-btn sm ${mode === 'grid' ? 'active-brand' : ''}`} onClick={() => setMode('grid')} title="Grid"><Icon.Grid /></button>
|
|
<button className={`cc-btn sm ${mode === 'focus' ? 'active-brand' : ''}`} onClick={() => setMode('focus')} title="Fokus"><Icon.Focus /></button>
|
|
<button className={`cc-btn sm ${mode === 'fullscreen' ? 'active-brand' : ''}`} onClick={() => setMode('fullscreen')} title="Vollbild"><Icon.Maximize /></button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="call-stage">
|
|
{mode === 'grid' && (
|
|
<div className={`grid grid-${participants.length}`}>
|
|
{participants.map(p => (
|
|
<ParticipantTile key={p.id} p={p} onClick={() => { setFocused(p.id); setMode('focus'); }} />
|
|
))}
|
|
</div>
|
|
)}
|
|
{mode === 'focus' && (
|
|
<div className="focus-layout">
|
|
<div className="focus-main">
|
|
<ParticipantTile p={speaker} focused />
|
|
</div>
|
|
<div className="focus-strip">
|
|
{others.map(p => (
|
|
<ParticipantTile key={p.id} p={p} small onClick={() => setFocused(p.id)} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{mode === 'fullscreen' && (
|
|
<div className="fullscreen-layout">
|
|
<ParticipantTile p={speaker} focused />
|
|
<div className="fs-strip">
|
|
{others.slice(0,4).map(p => (
|
|
<ParticipantTile key={p.id} p={p} small onClick={() => setFocused(p.id)} />
|
|
))}
|
|
</div>
|
|
<div className="fs-hint">Esc zum Verlassen</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<CallControls
|
|
muted={callState.muted}
|
|
onToggleMute={() => setCallState(s => ({...s, muted: !s.muted}))}
|
|
sharing={callState.sharing}
|
|
onToggleShare={() => setCallState(s => ({...s, sharing: !s.sharing}))}
|
|
video={callState.video}
|
|
onToggleVideo={() => setCallState(s => ({...s, video: !s.video}))}
|
|
onHangup={onHangup}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const PipCall = ({ onExpand, onHangup, participants }) => {
|
|
const speaker = participants.find(p => p.sharing) || participants.find(p => p.speaking) || participants[0];
|
|
return (
|
|
<div className="pip-call" onClick={onExpand}>
|
|
<div className="pip-preview">
|
|
{speaker.sharing ? (
|
|
<div className="screenshare-stub small"><div className="ss-window mini"></div></div>
|
|
) : (
|
|
<Avatar chat={{initial: speaker.initial, color: speaker.color}} size="sm" />
|
|
)}
|
|
</div>
|
|
<div className="pip-info">
|
|
<div className="pip-title">Die Squad · {participants.length}</div>
|
|
<div className="pip-sub">
|
|
<span className="live-dot"></span>
|
|
Live · tippe zum Öffnen
|
|
</div>
|
|
</div>
|
|
<button className="pip-hangup" onClick={(e) => { e.stopPropagation(); onHangup(); }}>
|
|
<Icon.PhoneOff />
|
|
</button>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
Object.assign(window, { CALL_PARTICIPANTS, ParticipantTile, CallControls, IncomingCallScreen, ActiveCallScreen, PipCall });
|