feat: profile avatar upload + share_conv_keys rpc + favicon + smtp tweaks
This commit is contained in:
@@ -0,0 +1,439 @@
|
||||
// Main ChatApp component — layout shared across all variants
|
||||
|
||||
const Avatar = ({ chat, size = '' }) => (
|
||||
<div className={`avatar ${size}`} data-color={chat.color}>
|
||||
{chat.initial}
|
||||
{chat.presence && <div className={`presence ${chat.presence}`}></div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
const ChatItem = ({ chat, onClick }) => (
|
||||
<div className={`chat-item ${chat.active ? 'active' : ''}`} onClick={onClick}>
|
||||
<Avatar chat={chat} />
|
||||
<div className="chat-item-body">
|
||||
<div className="chat-item-name">
|
||||
<span>{chat.name}</span>
|
||||
<span className="chat-item-time">{chat.time}</span>
|
||||
</div>
|
||||
<div className="chat-item-preview">
|
||||
{chat.preview}
|
||||
{chat.unread && <span className="unread-dot" style={{background: 'var(--accent, #a78bfa)', display: 'inline-block'}}></span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Reactions = ({ reactions }) => (
|
||||
<div className="reactions">
|
||||
{reactions.map((r, i) => (
|
||||
<div key={i} className={`reaction ${r.mine ? 'mine' : ''}`}>
|
||||
<span>{r.emoji}</span>
|
||||
<span className="count">{r.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const Bubble = ({ msg }) => (
|
||||
<div className={`bubble ${msg.from}`}>
|
||||
{msg.text}
|
||||
<span className="ts">{msg.time}{msg.read && msg.from === 'me' ? ' · Gelesen' : ''}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const MediaBubble = ({ msg }) => (
|
||||
<div className="bubble me" style={{padding: 4}}>
|
||||
<div className="media-preview">
|
||||
<div className="media-image">{msg.label}</div>
|
||||
<div className="media-meta">
|
||||
<span>{msg.filename}</span>
|
||||
<span>{msg.size}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="ts" style={{paddingLeft: 8, paddingBottom: 4}}>{msg.time}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const EventPill = ({ evt }) => {
|
||||
const IconComp = evt.icon === 'phone' ? Icon.Phone : Icon.PhoneOff;
|
||||
return (
|
||||
<div className="event-row">
|
||||
<div className={`event-pill ${evt.kind === 'rejected' ? 'rejected' : ''}`}>
|
||||
<IconComp />
|
||||
<span>{evt.text}</span>
|
||||
{evt.duration && <span>· {evt.duration}</span>}
|
||||
<span>· {evt.time}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TypingIndicator = () => (
|
||||
<div className="bubble them" style={{padding: '10px 14px'}}>
|
||||
<div className="typing"><span></span><span></span><span></span></div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const MessageItem = ({ msg, showAvatar, chat, myChat }) => {
|
||||
if (msg.type === 'event') return <EventPill evt={msg} />;
|
||||
|
||||
const wrapWithAvatar = (inner) => (
|
||||
<div className={`message-row ${msg.from}`}>
|
||||
{msg.from === 'them' && (
|
||||
<div className="message-avatar-slot">
|
||||
{showAvatar && <Avatar chat={{...chat, presence: null}} size="sm" />}
|
||||
</div>
|
||||
)}
|
||||
<div className={`message-group ${msg.from}`}>
|
||||
{inner}
|
||||
</div>
|
||||
{msg.from === 'me' && (
|
||||
<div className="message-avatar-slot">
|
||||
{showAvatar && <Avatar chat={{...myChat, presence: null}} size="sm" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (msg.type === 'typing') {
|
||||
return wrapWithAvatar(<TypingIndicator />);
|
||||
}
|
||||
if (msg.type === 'media') {
|
||||
return wrapWithAvatar(<MediaBubble msg={msg} />);
|
||||
}
|
||||
return wrapWithAvatar(
|
||||
<>
|
||||
<Bubble msg={msg} />
|
||||
{msg.reactions && <Reactions reactions={msg.reactions} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ProfileCard = ({ chat, position }) => (
|
||||
<div className="profile-card" style={{top: position.top, left: position.left}}>
|
||||
<div className="profile-card-banner"></div>
|
||||
<div className="profile-card-body">
|
||||
<div className="profile-card-avatar" data-color={chat.color} style={{background: 'var(--accent, #a78bfa)', color: '#fff'}}>
|
||||
{chat.initial}
|
||||
</div>
|
||||
<div className="profile-card-name">{chat.name}</div>
|
||||
<div className="profile-card-handle">{chat.handle}</div>
|
||||
<div className="profile-card-meta">
|
||||
<div>🟢 Online · seit 2h</div>
|
||||
<div>📍 Beigetreten am 14. Mär 2026</div>
|
||||
<div>🎮 Zockt gerade: Valorant</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const CallOverlay = ({ chat }) => (
|
||||
<div className="call-overlay">
|
||||
<Avatar chat={{...chat, presence: null}} size="lg" />
|
||||
<div className="call-info">
|
||||
<div className="call-name">Max ruft an…</div>
|
||||
<div className="call-sub">Voice Call · klingelt</div>
|
||||
</div>
|
||||
<div className="call-actions">
|
||||
<button className="call-btn decline"><Icon.PhoneOff /></button>
|
||||
<button className="call-btn accept"><Icon.Phone /></button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ChatApp = ({ variant, showCall, showProfile, isDark, onToggleDark }) => {
|
||||
const [activeChat, setActiveChat] = React.useState(CHATS[0]);
|
||||
const [activeTab, setActiveTab] = React.useState('chats');
|
||||
|
||||
// Call state: 'none' | 'incoming' | 'active' | 'pip'
|
||||
const [callScreen, setCallScreen] = React.useState('none');
|
||||
const [callMode, setCallMode] = React.useState('grid'); // grid | focus | fullscreen
|
||||
const [focusedId, setFocusedId] = React.useState('max');
|
||||
const [callState, setCallState] = React.useState({
|
||||
muted: false, sharing: false, video: false, duration: '00:12:47'
|
||||
});
|
||||
|
||||
// Esc exits fullscreen
|
||||
React.useEffect(() => {
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape' && callScreen === 'active' && callMode === 'fullscreen') {
|
||||
setCallMode('grid');
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [callScreen, callMode]);
|
||||
|
||||
const chats = CHATS.map(c => ({...c, active: c.id === activeChat.id}));
|
||||
const messagesEndRef = React.useRef(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (messagesEndRef.current) {
|
||||
messagesEndRef.current.parentElement.scrollTop = messagesEndRef.current.parentElement.scrollHeight;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`variant variant-${variant.baseKey || variant.key} ${isDark ? 'dark' : ''} ${variant.layout === 'rail' ? 'layout-rail' : 'layout-topnav'} active`} data-screen-label={`0${variant.idx+1} ${variant.label}`}>
|
||||
<div className="app-window" style={{position: 'relative'}}>
|
||||
<div className="window-bar">
|
||||
<div className="dots">
|
||||
<div className="dot" style={{background: '#ff5f57'}}></div>
|
||||
<div className="dot" style={{background: '#febc2e'}}></div>
|
||||
<div className="dot" style={{background: '#28c840'}}></div>
|
||||
</div>
|
||||
<div className="title">ChatApp — {variant.label}</div>
|
||||
<div style={{width: 52}}></div>
|
||||
</div>
|
||||
|
||||
<div className="app-body">
|
||||
{variant.layout === 'rail' ? (
|
||||
<div className="rail-layout">
|
||||
{/* Icon rail left */}
|
||||
<div className="icon-rail">
|
||||
<div className="rail-brand" title="Netralax"><Icon.Logo /></div>
|
||||
<div className="rail-divider"></div>
|
||||
<button className={`rail-btn ${activeTab === 'chats' ? 'active' : ''}`} onClick={() => setActiveTab('chats')} title="Chats">
|
||||
<Icon.Chat />
|
||||
{activeTab === 'chats' && <div className="rail-pill"></div>}
|
||||
</button>
|
||||
<button className={`rail-btn ${activeTab === 'friends' ? 'active' : ''}`} onClick={() => setActiveTab('friends')} title="Freunde">
|
||||
<Icon.Friends />
|
||||
{activeTab === 'friends' && <div className="rail-pill"></div>}
|
||||
</button>
|
||||
<button className={`rail-btn ${activeTab === 'admin' ? 'active' : ''}`} onClick={() => setActiveTab('admin')} title="Admin">
|
||||
<Icon.Shield />
|
||||
{activeTab === 'admin' && <div className="rail-pill"></div>}
|
||||
</button>
|
||||
<div style={{flex: 1}}></div>
|
||||
<button className={`rail-btn ${activeTab === 'settings' ? 'active' : ''}`} onClick={() => setActiveTab('settings')} title="Einstellungen">
|
||||
<Icon.Settings />
|
||||
</button>
|
||||
<button className="rail-btn" onClick={onToggleDark} title={isDark ? 'Light mode' : 'Dark mode'}>
|
||||
{isDark ? <Icon.Sun /> : <Icon.Moon />}
|
||||
</button>
|
||||
</div>
|
||||
{/* Chatlist + user profile at bottom */}
|
||||
<div className="chat-list rail-chatlist">
|
||||
<div className="chat-list-header">
|
||||
<div className="chat-list-title">Chats</div>
|
||||
<button className="icon-btn" title="Neuer Chat"><Icon.AddUser /></button>
|
||||
</div>
|
||||
<input className="chat-search" placeholder="Suche…" />
|
||||
<div className="chat-items">
|
||||
{chats.map(c => (
|
||||
<ChatItem key={c.id} chat={c} onClick={() => setActiveChat(c)} />
|
||||
))}
|
||||
</div>
|
||||
<div className="rail-user">
|
||||
<Avatar chat={{initial: 'D', color: 'teal', presence: 'online'}} size="sm" />
|
||||
<div style={{fontSize: 12, flex: 1, minWidth: 0}}>
|
||||
<div style={{fontWeight: 600}}>dennis</div>
|
||||
<div style={{opacity: 0.6, fontSize: 10}}>● Online</div>
|
||||
</div>
|
||||
<button className="icon-btn" title="Mute"><Icon.Mic /></button>
|
||||
<button className="icon-btn" title="Einstellungen"><Icon.Settings /></button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Chat area (unchanged below) */}
|
||||
<div className="chat-area">
|
||||
{!(callScreen === 'active' && callMode !== 'fullscreen') && (
|
||||
<div className="chat-header">
|
||||
<Avatar chat={activeChat} />
|
||||
<div className="chat-header-info">
|
||||
<div className="chat-header-name">{activeChat.name}</div>
|
||||
<div className="chat-header-status">
|
||||
{activeChat.presence === 'online' ? '● Online' :
|
||||
activeChat.presence === 'idle' ? '● Abwesend' :
|
||||
activeChat.presence === 'dnd' ? '● Nicht stören' :
|
||||
'● Offline · zuletzt vor 2h'}
|
||||
{' · '}{activeChat.handle}
|
||||
</div>
|
||||
</div>
|
||||
<div className="chat-header-actions">
|
||||
<button className="icon-btn" title="Suche"><Icon.Search /></button>
|
||||
<button className="icon-btn" title="Voice" onClick={() => setCallScreen('active')}><Icon.Phone /></button>
|
||||
<button className="icon-btn" title="Video" onClick={() => { setCallState(s => ({...s, video: true})); setCallScreen('active'); }}><Icon.Video /></button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Call dock — rendered above messages, Discord-style */}
|
||||
{callScreen === 'active' && callMode !== 'fullscreen' && (
|
||||
<div className="call-dock">
|
||||
<ActiveCallScreen
|
||||
participants={CALL_PARTICIPANTS}
|
||||
mode={callMode}
|
||||
setMode={setCallMode}
|
||||
focused={focusedId}
|
||||
setFocused={setFocusedId}
|
||||
onHangup={() => setCallScreen('none')}
|
||||
callState={callState}
|
||||
setCallState={setCallState}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="messages">
|
||||
{MESSAGES.map((m, i) => {
|
||||
const next = MESSAGES[i+1];
|
||||
const isLastOfRun = (m.from === 'them' || m.from === 'me') && (!next || next.from !== m.from || next.type === 'event');
|
||||
const myChat = { initial: 'D', color: 'teal' };
|
||||
return <MessageItem key={i} msg={m} showAvatar={isLastOfRun} chat={activeChat} myChat={myChat} />;
|
||||
})}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
<div className="composer">
|
||||
<button className="composer-btn" title="Attach"><Icon.Plus /></button>
|
||||
<div className="composer-box">
|
||||
<input placeholder="Nachricht schreiben…" />
|
||||
<button className="icon-btn"><Icon.Smile /></button>
|
||||
<button className="icon-btn"><Icon.Mic /></button>
|
||||
</div>
|
||||
<button className="composer-btn"><Icon.Send /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="top-nav">
|
||||
<div className="nav-brand">
|
||||
<Icon.Logo />
|
||||
<span>Netralax</span>
|
||||
</div>
|
||||
<div className="nav-tabs">
|
||||
<button className={`nav-tab ${activeTab === 'chats' ? 'active' : ''}`} onClick={() => setActiveTab('chats')}>
|
||||
<Icon.Chat /> Chats
|
||||
</button>
|
||||
<button className={`nav-tab ${activeTab === 'friends' ? 'active' : ''}`} onClick={() => setActiveTab('friends')}>
|
||||
<Icon.Friends /> Freunde
|
||||
</button>
|
||||
<button className={`nav-tab ${activeTab === 'settings' ? 'active' : ''}`} onClick={() => setActiveTab('settings')}>
|
||||
<Icon.Settings /> Einstellungen
|
||||
</button>
|
||||
<button className={`nav-tab ${activeTab === 'admin' ? 'active' : ''}`} onClick={() => setActiveTab('admin')}>
|
||||
<Icon.Shield /> Admin
|
||||
</button>
|
||||
</div>
|
||||
<div className="nav-user">
|
||||
<Avatar chat={{initial: 'D', color: 'teal', presence: 'online'}} size="sm" />
|
||||
<div style={{fontSize: 12}}>
|
||||
<div style={{fontWeight: 600}}>dennis</div>
|
||||
<div style={{opacity: 0.6, fontSize: 10}}>@dennis</div>
|
||||
</div>
|
||||
<Icon.Chevron />
|
||||
</div>
|
||||
{variant.key === 'clean' && (
|
||||
<button className="icon-btn theme-toggle" onClick={onToggleDark} title={isDark ? 'Light mode' : 'Dark mode'} style={{marginLeft: 6}}>
|
||||
{isDark ? <Icon.Sun /> : <Icon.Moon />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="main-area">
|
||||
<div className="chat-list">
|
||||
<div className="chat-list-header">
|
||||
<div className="chat-list-title">Chats</div>
|
||||
<button className="icon-btn" title="Neuer Chat"><Icon.AddUser /></button>
|
||||
</div>
|
||||
<input className="chat-search" placeholder="Suche…" />
|
||||
<div className="chat-items">
|
||||
{chats.map(c => (
|
||||
<ChatItem key={c.id} chat={c} onClick={() => setActiveChat(c)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="chat-area">
|
||||
<div className="chat-header">
|
||||
<Avatar chat={activeChat} />
|
||||
<div className="chat-header-info">
|
||||
<div className="chat-header-name">{activeChat.name}</div>
|
||||
<div className="chat-header-status">
|
||||
{activeChat.presence === 'online' ? '● Online' :
|
||||
activeChat.presence === 'idle' ? '● Abwesend' :
|
||||
activeChat.presence === 'dnd' ? '● Nicht stören' :
|
||||
'● Offline · zuletzt vor 2h'}
|
||||
{' · '}{activeChat.handle}
|
||||
</div>
|
||||
</div>
|
||||
<div className="chat-header-actions">
|
||||
<button className="icon-btn" title="Suche"><Icon.Search /></button>
|
||||
<button className="icon-btn" title="Voice"><Icon.Phone /></button>
|
||||
<button className="icon-btn" title="Video"><Icon.Video /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="messages">
|
||||
{MESSAGES.map((m, i) => {
|
||||
const next = MESSAGES[i+1];
|
||||
const isLastOfRun = (m.from === 'them' || m.from === 'me') && (!next || next.from !== m.from || next.type === 'event');
|
||||
const myChat = { initial: 'D', color: 'teal' };
|
||||
return <MessageItem key={i} msg={m} showAvatar={isLastOfRun} chat={activeChat} myChat={myChat} />;
|
||||
})}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
<div className="composer">
|
||||
<button className="composer-btn" title="Attach"><Icon.Plus /></button>
|
||||
<div className="composer-box">
|
||||
<input placeholder="Nachricht schreiben…" />
|
||||
<button className="icon-btn"><Icon.Smile /></button>
|
||||
<button className="icon-btn"><Icon.Mic /></button>
|
||||
</div>
|
||||
<button className="composer-btn"><Icon.Send /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Call screen switcher — only in Clean Rail */}
|
||||
{variant.layout === 'rail' && (
|
||||
<div className="screen-switcher">
|
||||
<button className={callScreen === 'none' ? 'active' : ''} onClick={() => setCallScreen('none')}>Chat</button>
|
||||
<button className={callScreen === 'incoming' ? 'active' : ''} onClick={() => setCallScreen('incoming')}>Incoming</button>
|
||||
<button className={callScreen === 'active' ? 'active' : ''} onClick={() => setCallScreen('active')}>In-Call</button>
|
||||
<button className={callScreen === 'pip' ? 'active' : ''} onClick={() => setCallScreen('pip')}>PiP</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Incoming call is a full overlay */}
|
||||
{callScreen === 'incoming' && (
|
||||
<div className="call-screen">
|
||||
<IncomingCallScreen
|
||||
chat={activeChat}
|
||||
onAccept={() => setCallScreen('active')}
|
||||
onDecline={() => setCallScreen('none')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Fullscreen call hides chat entirely */}
|
||||
{callScreen === 'active' && callMode === 'fullscreen' && (
|
||||
<div className="call-screen">
|
||||
<ActiveCallScreen
|
||||
participants={CALL_PARTICIPANTS}
|
||||
mode={callMode}
|
||||
setMode={setCallMode}
|
||||
focused={focusedId}
|
||||
setFocused={setFocusedId}
|
||||
onHangup={() => setCallScreen('none')}
|
||||
callState={callState}
|
||||
setCallState={setCallState}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{callScreen === 'pip' && (
|
||||
<PipCall
|
||||
participants={CALL_PARTICIPANTS}
|
||||
onExpand={() => setCallScreen('active')}
|
||||
onHangup={() => setCallScreen('none')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showCall && callScreen === 'none' && <CallOverlay chat={activeChat} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Object.assign(window, { ChatApp, Avatar, ChatItem, MessageItem, CallOverlay });
|
||||
@@ -0,0 +1,194 @@
|
||||
// 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 });
|
||||
@@ -0,0 +1,28 @@
|
||||
// Sample data
|
||||
|
||||
const CHATS = [
|
||||
{ id: 'test', name: 'test', handle: '@test', color: 'violet', initial: 'T', preview: 'Hallo 👋', time: '21:13', unread: false, presence: 'offline', active: true },
|
||||
{ id: 'max', name: 'Max', handle: '@maxx', color: 'teal', initial: 'M', preview: 'bro du musst das sehen', time: '20:44', unread: true, presence: 'online' },
|
||||
{ id: 'squad', name: 'Die Squad', handle: '5 Mitglieder', color: 'rose', initial: 'S', preview: 'leon: bin gleich da', time: '19:12', unread: true, presence: 'online', isGroup: true },
|
||||
{ id: 'lena', name: 'Lena', handle: '@lenae', color: 'amber', initial: 'L', preview: 'Du: ok passt', time: 'Gestern', unread: false, presence: 'idle' },
|
||||
{ id: 'tom', name: 'Tom', handle: '@tomg', color: 'violet', initial: 'T', preview: 'sent a photo', time: 'Gestern', unread: false, presence: 'dnd' },
|
||||
{ id: 'nico', name: 'nico', handle: '@nicoo', color: 'teal', initial: 'N', preview: 'gg wp', time: 'Mo.', unread: false, presence: 'offline' },
|
||||
];
|
||||
|
||||
const MESSAGES = [
|
||||
{ type: 'message', from: 'them', text: 'Hi', time: '20:12' },
|
||||
{ type: 'message', from: 'them', text: 'Lol', time: '20:14' },
|
||||
{ type: 'message', from: 'them', text: 'Wie gehts', time: '20:24' },
|
||||
{ type: 'message', from: 'them', text: 'einer da?', time: '20:25' },
|
||||
{ type: 'message', from: 'them', text: 'Hallo', time: '21:03', reactions: [{emoji: '👋', count: 1, mine: true}] },
|
||||
{ type: 'event', kind: 'call', icon: 'phone', text: 'Eingehender Anruf', duration: '1:01', time: '21:06' },
|
||||
{ type: 'event', kind: 'rejected', icon: 'phone-off', text: 'Anruf abgelehnt', time: '21:06' },
|
||||
{ type: 'event', kind: 'rejected', icon: 'phone-off', text: 'Anruf abgelehnt', time: '21:06' },
|
||||
{ type: 'message', from: 'me', text: 'sry war afk', time: '21:09', reactions: [{emoji: '😅', count: 2}, {emoji: '❤️', count: 1, mine: true}] },
|
||||
{ type: 'media', from: 'me', filename: 'IMG_4120.jpg', size: '2.4 MB', label: '[ PLACEHOLDER IMAGE ]', time: '21:10' },
|
||||
{ type: 'message', from: 'them', text: 'pushhh sick', time: '21:12' },
|
||||
{ type: 'message', from: 'me', text: 'Hallo', time: '21:13', read: true },
|
||||
{ type: 'typing', from: 'them' },
|
||||
];
|
||||
|
||||
Object.assign(window, { CHATS, MESSAGES });
|
||||
@@ -0,0 +1,87 @@
|
||||
// Shared SVG icon components
|
||||
const Icon = {
|
||||
Logo: () => (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M4 6 L12 2 L20 6 L20 18 L12 22 L4 18 Z" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round"/>
|
||||
<path d="M4 6 L12 10 L20 6" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round"/>
|
||||
<path d="M12 10 L12 22" stroke="currentColor" strokeWidth="1.8"/>
|
||||
</svg>
|
||||
),
|
||||
Chat: () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
),
|
||||
Friends: () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
),
|
||||
Settings: () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
),
|
||||
Shield: () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||
),
|
||||
Phone: () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
|
||||
),
|
||||
PhoneOff: () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7 2 2 0 0 1 1.72 2v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.42 19.42 0 0 1-3.33-2.67m-2.67-3.34a19.79 19.79 0 0 1-3.07-8.63A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91"/><line x1="23" y1="1" x2="1" y2="23"/></svg>
|
||||
),
|
||||
Video: () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg>
|
||||
),
|
||||
Search: () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
),
|
||||
Plus: () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
),
|
||||
Send: () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
||||
),
|
||||
AddUser: () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg>
|
||||
),
|
||||
Chevron: () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
),
|
||||
Paperclip: () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||
),
|
||||
Smile: () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>
|
||||
),
|
||||
Mic: () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
|
||||
),
|
||||
Sun: () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
|
||||
),
|
||||
Moon: () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||||
),
|
||||
MicOff: () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><line x1="1" y1="1" x2="23" y2="23"/><path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"/><path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"/><line x1="12" y1="19" x2="12" y2="23"/></svg>
|
||||
),
|
||||
Monitor: () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||||
),
|
||||
Lock: () => (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||
),
|
||||
Crown: () => (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M5 16l-2-8 5 4 4-7 4 7 5-4-2 8z"/></svg>
|
||||
),
|
||||
Grid: () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
|
||||
),
|
||||
Focus: () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="12" rx="1"/><rect x="3" y="18" width="4" height="3" rx="0.5"/><rect x="9" y="18" width="4" height="3" rx="0.5"/><rect x="15" y="18" width="4" height="3" rx="0.5"/></svg>
|
||||
),
|
||||
Maximize: () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg>
|
||||
),
|
||||
Minimize: () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 14h6v6M20 10h-6V4M14 10l7-7M3 21l7-7"/></svg>
|
||||
),
|
||||
};
|
||||
|
||||
Object.assign(window, { Icon });
|
||||
Reference in New Issue
Block a user