Files
pyramid/mockup/Modals.jsx
T
Bernd Steckmeister 25ed765a03 wip: Sicherungs-Commit aller Änderungen seit April + Arbeitsstruktur (CLAUDE.md, ROADMAP.md, PROGRESS.md, Autopilot)
6 Wochen uncommittete Arbeit (Voice-Channels, LiveKit-Manager, Settings-Modal u.v.m.)
als ein WIP-Commit gesichert, damit nichts verloren geht und der Pi den aktuellen
Stand klonen kann. Thematische Aufarbeitung: siehe ROADMAP M0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPrAGBxBT6GfPXzeWQ4AXb
2026-07-03 05:47:18 +02:00

717 lines
37 KiB
React

/* Modals, popovers, voice channel, login */
const EmojiPicker = ({ anchor, onPick, onClose }) => {
const [tab, setTab] = React.useState('recent');
const [q, setQ] = React.useState('');
const [hover, setHover] = React.useState(null);
const ref = React.useRef(null);
const cats = window.PYRAMID_DATA.emojiCategories;
React.useEffect(() => {
const h = (e) => {
if (ref.current && !ref.current.contains(e.target)) onClose();
};
setTimeout(() => document.addEventListener('mousedown', h), 50);
return () => document.removeEventListener('mousedown', h);
}, []);
const style = anchor ? { bottom: window.innerHeight - anchor.top + 8, right: Math.max(20, window.innerWidth - anchor.right) } : { bottom: 80, right: 20 };
const filtered = q ? cats.flatMap(c => c.emojis.filter(e => c.key.includes(q.toLowerCase()))) : null;
return (
<div className="emoji-picker" ref={ref} style={style}>
<div className="emoji-picker-search">
<input placeholder="Search emoji…" autoFocus value={q} onChange={e => setQ(e.target.value)} />
</div>
<div className="emoji-picker-tabs">
{cats.map(c => (
<div key={c.key} className={`emoji-tab ${tab === c.key ? 'active' : ''}`} onClick={() => setTab(c.key)} title={c.title}>
{c.icon}
</div>
))}
</div>
<div className="emoji-grid-wrap">
{filtered ? (
<>
<div className="emoji-category-title">Results</div>
<div className="emoji-grid">
{filtered.map((e, i) => <div key={i} className="emoji-cell" onMouseEnter={() => setHover({ emoji: e, name: 'emoji' })} onClick={() => { onPick(e); onClose(); }}>{e}</div>)}
</div>
</>
) : cats.map(c => (
<div key={c.key} id={`cat-${c.key}`}>
<div className="emoji-category-title">{c.title}</div>
<div className="emoji-grid">
{c.emojis.map((e, i) => (
<div key={i} className="emoji-cell"
onMouseEnter={() => setHover({ emoji: e, name: c.title.toLowerCase() })}
onClick={() => { onPick(e); onClose(); }}>
{e}
</div>
))}
</div>
</div>
))}
</div>
<div className="emoji-picker-preview">
{hover ? (
<>
<span className="big">{hover.emoji}</span>
<div>
<div className="name">{hover.name}</div>
<div className="shortcode">:{hover.name.replace(/\s/g, '_')}:</div>
</div>
</>
) : (
<div className="shortcode" style={{fontSize: 12}}>pick an emoji · 😊</div>
)}
</div>
</div>
);
};
const MessageActionsMenu = ({ anchor, onClose, onAction }) => {
const ref = React.useRef(null);
React.useEffect(() => {
const h = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose(); };
setTimeout(() => document.addEventListener('mousedown', h), 50);
return () => document.removeEventListener('mousedown', h);
}, []);
const style = anchor ? { top: anchor.top, left: Math.min(anchor.right, window.innerWidth - 230) } : {};
return (
<div className="context-menu" ref={ref} style={style}>
<div className="context-item" onClick={() => { onAction('reply'); onClose(); }}><Icon name="reply" size={14} /> Reply <span className="shortcut">R</span></div>
<div className="context-item" onClick={() => { onAction('thread'); onClose(); }}><Icon name="inbox" size={14} /> Reply in thread</div>
<div className="context-item" onClick={() => { onAction('edit'); onClose(); }}><Icon name="edit" size={14} /> Edit <span className="shortcut">E</span></div>
<div className="context-divider" />
<div className="context-item" onClick={() => { onAction('pin'); onClose(); }}><Icon name="pin" size={14} /> Pin to room</div>
<div className="context-item" onClick={() => { onAction('bookmark'); onClose(); }}><Icon name="bookmark" size={14} /> Save for later</div>
<div className="context-item" onClick={() => { onAction('copy'); onClose(); }}><Icon name="copy" size={14} /> Copy text <span className="shortcut">C</span></div>
<div className="context-item" onClick={() => { onAction('link'); onClose(); }}><Icon name="link" size={14} /> Copy message link</div>
<div className="context-divider" />
<div className="context-item danger" onClick={() => { onAction('delete'); onClose(); }}><Icon name="trash" size={14} /> Delete message <span className="shortcut"></span></div>
</div>
);
};
const ProfilePopover = ({ anchor, profile, onClose }) => {
const ref = React.useRef(null);
React.useEffect(() => {
const h = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose(); };
setTimeout(() => document.addEventListener('mousedown', h), 50);
return () => document.removeEventListener('mousedown', h);
}, []);
const style = anchor
? { top: Math.min(anchor.top, window.innerHeight - 440), left: Math.min(anchor.right + 10, window.innerWidth - 340) }
: { top: '50%', left: '50%', transform: 'translate(-50%, -50%)' };
const p = profile || { name: 'juno', handle: '@juno:pyramid.chat', avatar: 'J', color: '#06b6d4', status: 'online', about: 'risograph + zines. teaching at the atelier tuesdays.', roles: [{ name: 'founder', color: '#f59e0b' }, { name: 'print-nerd', color: '#06b6d4' }] };
return (
<div className="profile-popover" ref={ref} style={style}>
<div className="profile-banner" />
<div className="profile-content">
<div className="profile-avatar" style={{ background: `linear-gradient(135deg, ${p.color}, ${p.color}cc)` }}>
{p.avatar}
<span className={`presence-dot ${p.status}`} style={{ width: 16, height: 16, borderWidth: 3 }} />
</div>
<div className="profile-name">{p.name}</div>
<div className="profile-handle">{p.handle}</div>
<div className="profile-actions">
<button className="profile-btn primary"><Icon name="send" size={13} /> Message</button>
<button className="profile-btn"><Icon name="voice" size={13} /> Call</button>
<button className="profile-btn" style={{ flex: 'none', padding: '7px 10px' }}><Icon name="dotsV" size={14} /></button>
</div>
<div className="profile-section">
<div className="profile-section-title">About</div>
<div className="profile-about">{p.about}</div>
</div>
<div className="profile-section">
<div className="profile-section-title">Roles</div>
<div className="profile-roles">
{(p.roles || []).map((r, i) => (
<span key={i} className="profile-role"><span className="dot" style={{ background: r.color }} /> {r.name}</span>
))}
</div>
</div>
<div className="profile-section">
<div className="profile-section-title">Member since</div>
<div className="profile-about">March 2024 · via pyramid.chat</div>
</div>
</div>
</div>
);
};
const SettingsModal = ({ onClose, theme, onTheme, accentPresets, accent, onAccent, density, onDensity, motion, onMotion, radius, onRadius }) => {
const [section, setSection] = React.useState('appearance');
return (
<div className="overlay" onClick={onClose}>
<div className="modal settings-modal" onClick={e => e.stopPropagation()}>
<nav className="settings-nav">
<div className="settings-nav-section">Account</div>
<div className={`settings-nav-item ${section === 'profile' ? 'active' : ''}`} onClick={() => setSection('profile')}><Icon name="users" size={14}/> My profile</div>
<div className={`settings-nav-item ${section === 'notifications' ? 'active' : ''}`} onClick={() => setSection('notifications')}><Icon name="bell" size={14}/> Notifications</div>
<div className="settings-nav-section" style={{marginTop: 14}}>App</div>
<div className={`settings-nav-item ${section === 'appearance' ? 'active' : ''}`} onClick={() => setSection('appearance')}><Icon name="palette" size={14}/> Appearance</div>
<div className={`settings-nav-item ${section === 'voice' ? 'active' : ''}`} onClick={() => setSection('voice')}><Icon name="voice" size={14}/> Voice &amp; video</div>
<div className={`settings-nav-item ${section === 'keys' ? 'active' : ''}`} onClick={() => setSection('keys')}><Icon name="settings" size={14}/> Keybinds</div>
<div className="settings-nav-section" style={{marginTop: 14}}>Privacy</div>
<div className={`settings-nav-item ${section === 'privacy' ? 'active' : ''}`} onClick={() => setSection('privacy')}><Icon name="lock" size={14}/> Privacy &amp; safety</div>
<div className={`settings-nav-item ${section === 'sessions' ? 'active' : ''}`} onClick={() => setSection('sessions')}><Icon name="globe" size={14}/> Sessions</div>
</nav>
<div className="settings-content">
<button className="modal-close" onClick={onClose}><Icon name="close" size={14} /></button>
{section === 'appearance' && (
<>
<h2>Appearance</h2>
<div className="settings-subtitle">Make Pyramid feel the way you want. Changes apply immediately.</div>
<div className="settings-group">
<div className="settings-group-title">Theme</div>
<div className="settings-row">
<div className="settings-row-label">
<div className="settings-row-title">Mode</div>
<div className="settings-row-desc">Switch between dark and light</div>
</div>
<div className="seg" style={{ width: 200 }}>
<button className={theme === 'dark' ? 'active' : ''} onClick={() => onTheme('dark')}>Dark</button>
<button className={theme === 'light' ? 'active' : ''} onClick={() => onTheme('light')}>Light</button>
</div>
</div>
<div className="settings-row">
<div className="settings-row-label">
<div className="settings-row-title">Accent color</div>
<div className="settings-row-desc">Applies to highlights, buttons, active states</div>
</div>
<div className="tweak-swatches">
{accentPresets.map(p => (
<div key={p.name} className={`tweak-swatch ${accent === p.h ? 'active' : ''}`}
style={{ background: `hsl(${p.h} ${p.s}% ${p.l}%)` }}
title={p.name}
onClick={() => onAccent(p)} />
))}
</div>
</div>
</div>
<div className="settings-group">
<div className="settings-group-title">Density &amp; shape</div>
<div className="settings-row">
<div className="settings-row-label">
<div className="settings-row-title">Density</div>
<div className="settings-row-desc">Cozy = more breathing room · compact = more on screen</div>
</div>
<div className="seg" style={{ width: 220 }}>
<button className={density === 0.85 ? 'active' : ''} onClick={() => onDensity(0.85)}>Compact</button>
<button className={density === 1 ? 'active' : ''} onClick={() => onDensity(1)}>Default</button>
<button className={density === 1.2 ? 'active' : ''} onClick={() => onDensity(1.2)}>Cozy</button>
</div>
</div>
<div className="settings-row">
<div className="settings-row-label">
<div className="settings-row-title">Corner roundness</div>
<div className="settings-row-desc">{radius}px</div>
</div>
<input type="range" min="4" max="20" value={radius} onChange={e => onRadius(+e.target.value)} style={{ width: 180, accentColor: 'var(--accent)' }} />
</div>
<div className="settings-row">
<div className="settings-row-label">
<div className="settings-row-title">Motion intensity</div>
<div className="settings-row-desc">How bouncy transitions feel ({Math.round(motion*100)}%)</div>
</div>
<input type="range" min="0" max="100" value={motion*100} onChange={e => onMotion(+e.target.value/100)} style={{ width: 180, accentColor: 'var(--accent)' }} />
</div>
</div>
<div className="settings-group">
<div className="settings-group-title">Message display</div>
<div className="settings-row">
<div className="settings-row-label">
<div className="settings-row-title">Show avatars on every message</div>
<div className="settings-row-desc">Off groups consecutive messages from the same person</div>
</div>
<div className="toggle" />
</div>
<div className="settings-row">
<div className="settings-row-label">
<div className="settings-row-title">Animated emoji &amp; stickers</div>
<div className="settings-row-desc">Play animations on hover</div>
</div>
<div className="toggle on" />
</div>
</div>
</>
)}
{section === 'profile' && (
<>
<h2>My profile</h2>
<div className="settings-subtitle">How you appear across pyramid.</div>
<div className="account-card">
<div className="avatar">M<span className="presence-dot online" style={{width:16,height:16,borderWidth:3}}/></div>
<div>
<div className="account-name">mira</div>
<div className="account-handle">@mira:pyramid.chat</div>
<div className="profile-about" style={{marginTop: 4}}>in the zone 🎨</div>
</div>
<button className="profile-btn" style={{ flex: 'none', marginLeft: 'auto' }}><Icon name="edit" size={13}/> Edit</button>
</div>
<div className="settings-row">
<div className="settings-row-label">
<div className="settings-row-title">Display name</div>
<div className="settings-row-desc">mira</div>
</div>
<button className="profile-btn" style={{ flex: 'none' }}>Change</button>
</div>
<div className="settings-row">
<div className="settings-row-label">
<div className="settings-row-title">Avatar</div>
<div className="settings-row-desc">Upload an image or pick a gradient</div>
</div>
<button className="profile-btn" style={{ flex: 'none' }}>Upload</button>
</div>
<div className="settings-row">
<div className="settings-row-label">
<div className="settings-row-title">Status message</div>
<div className="settings-row-desc">Shown next to your name to friends</div>
</div>
<button className="profile-btn" style={{ flex: 'none' }}>Edit</button>
</div>
</>
)}
{section === 'notifications' && (
<>
<h2>Notifications</h2>
<div className="settings-subtitle">Tune what pings you and when.</div>
<div className="settings-row">
<div className="settings-row-label">
<div className="settings-row-title">Enable desktop notifications</div>
<div className="settings-row-desc">Lets pyramid show OS-level alerts</div>
</div>
<div className="toggle on" />
</div>
<div className="settings-row">
<div className="settings-row-label">
<div className="settings-row-title">Mentions only</div>
<div className="settings-row-desc">Suppress everything except @-mentions and DMs</div>
</div>
<div className="toggle" />
</div>
<div className="settings-row">
<div className="settings-row-label">
<div className="settings-row-title">Notification sound</div>
<div className="settings-row-desc">A soft little chime</div>
</div>
<select className="settings-select">
<option>Gentle</option><option>Classic</option><option>Silent</option>
</select>
</div>
<div className="settings-row">
<div className="settings-row-label">
<div className="settings-row-title">Do not disturb</div>
<div className="settings-row-desc">Mute notifications on a schedule</div>
</div>
<div className="toggle" />
</div>
</>
)}
{section === 'voice' && (
<>
<h2>Voice &amp; video</h2>
<div className="settings-subtitle">Mic, camera, and call preferences.</div>
<div className="settings-row">
<div className="settings-row-label">
<div className="settings-row-title">Input device</div>
<div className="settings-row-desc">Your mic</div>
</div>
<select className="settings-select"><option>Built-in microphone</option><option>AirPods Pro</option></select>
</div>
<div className="settings-row">
<div className="settings-row-label">
<div className="settings-row-title">Noise suppression</div>
<div className="settings-row-desc">Smart mode based on what you're saying</div>
</div>
<div className="toggle on" />
</div>
<div className="settings-row">
<div className="settings-row-label">
<div className="settings-row-title">Echo cancellation</div>
<div className="settings-row-desc">Strips feedback from speaker bleed</div>
</div>
<div className="toggle on" />
</div>
<div className="settings-row">
<div className="settings-row-label">
<div className="settings-row-title">Push to talk</div>
<div className="settings-row-desc">Hold a key to transmit</div>
</div>
<div className="toggle" />
</div>
</>
)}
{section === 'privacy' && (
<>
<h2>Privacy &amp; safety</h2>
<div className="settings-subtitle">Pyramid is end-to-end encrypted by default.</div>
<div className="settings-row">
<div className="settings-row-label">
<div className="settings-row-title">Who can DM me</div>
<div className="settings-row-desc">Control who starts a direct message with you</div>
</div>
<select className="settings-select"><option>Everyone</option><option>Friends &amp; shared spaces</option><option>Only friends</option></select>
</div>
<div className="settings-row">
<div className="settings-row-label">
<div className="settings-row-title">Read receipts</div>
<div className="settings-row-desc">Let others see when you've read their messages</div>
</div>
<div className="toggle on" />
</div>
<div className="settings-row">
<div className="settings-row-label">
<div className="settings-row-title">Sent typing indicators</div>
<div className="settings-row-desc">Others see when you're composing</div>
</div>
<div className="toggle on" />
</div>
</>
)}
{section === 'keys' && (
<>
<h2>Keybinds</h2>
<div className="settings-subtitle">Quick movements.</div>
{[
['Quick switcher', 'K'],
['Mark room as read', ''],
['Jump to unread', 'J'],
['Toggle left rail', 'B'],
['Toggle members', 'U'],
['Emoji picker', 'E'],
].map(([label, key]) => (
<div className="settings-row" key={label}>
<div className="settings-row-label">
<div className="settings-row-title">{label}</div>
</div>
<kbd style={{padding: '3px 8px', background: 'var(--bg-2)', border: '1px solid var(--border)', borderRadius: 6, fontFamily: 'var(--font-mono)', fontSize: 12}}>{key}</kbd>
</div>
))}
</>
)}
{section === 'sessions' && (
<>
<h2>Sessions</h2>
<div className="settings-subtitle">Devices where you're currently signed in.</div>
{[
{ dev: 'MacBook Pro · Safari', loc: 'Berlin, DE · now', current: true },
{ dev: 'iPhone 15 · Pyramid iOS', loc: 'Berlin, DE · 2h ago' },
{ dev: 'Chrome · ThinkPad', loc: 'Lisbon, PT · 3d ago' },
].map((s, i) => (
<div className="settings-row" key={i}>
<div className="settings-row-label">
<div className="settings-row-title">{s.dev}{s.current && <span className="badge" style={{marginLeft:8, background:'var(--accent-soft)', color:'var(--accent)'}}>this device</span>}</div>
<div className="settings-row-desc">{s.loc}</div>
</div>
{!s.current && <button className="profile-btn" style={{flex:'none', color:'var(--danger)'}}>Revoke</button>}
</div>
))}
</>
)}
</div>
</div>
</div>
);
};
/* ===== Search ===== */
const SearchModal = ({ onClose, onJump }) => {
const [q, setQ] = React.useState('');
const [filter, setFilter] = React.useState('all');
const allResults = [
{ kind: 'message', room: '#general', author: 'juno', avatar: 'J', color: '#06b6d4', snippet: 'turned out way better than expected — the mis-registration is doing a LOT of heavy lifting', time: 'today · 2:14 PM' },
{ kind: 'message', room: '#resources', author: 'juno', avatar: 'J', color: '#06b6d4', snippet: 'risograph studio on baker street — 4hr blocks for $30', time: 'yesterday' },
{ kind: 'room', name: 'critique-circle', snippet: 'Atelier · 142 members', avatar: '#', color: '#f59e0b' },
{ kind: 'person', name: 'juno', snippet: '@juno:pyramid.chat · online', avatar: 'J', color: '#06b6d4' },
{ kind: 'message', room: 'juno', author: 'juno', avatar: 'J', color: '#06b6d4', snippet: 'bringing pastries — riso prints later', time: '3d ago' },
{ kind: 'file', name: 'zine-layout-final.pdf', snippet: 'Shared by juno · Atelier #wip · 2MB', avatar: '📄' },
];
const filtered = allResults.filter(r => filter === 'all' || r.kind === filter);
return (
<div className="overlay" onClick={onClose}>
<div className="modal search-modal" onClick={e => e.stopPropagation()}>
<div className="search-input-row">
<Icon name="search" size={18} />
<input autoFocus placeholder="Search messages, rooms, people, files…" value={q} onChange={e => setQ(e.target.value)} />
<span className="esc">esc</span>
</div>
<div className="search-filters">
{[
{ k: 'all', label: 'All', icon: 'sparkle' },
{ k: 'message', label: 'Messages', icon: 'hash' },
{ k: 'person', label: 'People', icon: 'users' },
{ k: 'room', label: 'Rooms', icon: 'hash' },
{ k: 'file', label: 'Files', icon: 'paperclip' },
].map(f => (
<button key={f.k} className={`search-chip ${filter === f.k ? 'active' : ''}`} onClick={() => setFilter(f.k)}>
<Icon name={f.icon} size={11} /> {f.label}
</button>
))}
<button className="search-chip"><Icon name="filter" size={11} /> From: anyone</button>
<button className="search-chip"><Icon name="filter" size={11} /> In: any room</button>
<button className="search-chip"><Icon name="filter" size={11} /> Has: all</button>
</div>
<div className="search-results">
{filtered.map((r, i) => (
<div key={i} className={`search-result ${i === 0 ? 'selected' : ''}`} onClick={onClose}>
<div className="avatar" style={{background: r.color || 'var(--bg-3)'}}>{r.avatar}</div>
<div className="search-result-content">
<div className="search-result-top">
<span className="search-result-name">{r.name || r.author}</span>
<span className="search-result-meta">{r.room || r.kind}{r.time ? ` · ${r.time}` : ''}</span>
</div>
<div className="search-result-snippet" dangerouslySetInnerHTML={{
__html: q
? r.snippet.replace(new RegExp(`(${q})`, 'ig'), '<mark>$1</mark>')
: r.snippet
}} />
</div>
</div>
))}
{filtered.length === 0 && (
<div style={{padding: 40, textAlign: 'center', color: 'var(--fg-dim)'}}>nothing here try a different filter?</div>
)}
</div>
<div className="search-footer">
<span><kbd></kbd><kbd></kbd> navigate</span>
<span><kbd></kbd> select</span>
<span><kbd></kbd><kbd></kbd> open in new pane</span>
<span style={{marginLeft:'auto'}}>{filtered.length} results</span>
</div>
</div>
</div>
);
};
/* ===== Voice Channel ===== */
const VoiceChannel = ({ call, onLeave, onToggleRail, onOpenProfile, onToggleMic, onToggleDeaf, onToggleCam, onToggleScreen }) => {
const [fullscreen, setFullscreen] = React.useState(false);
const [qualityOpen, setQualityOpen] = React.useState(false);
const [quality, setQuality] = React.useState('1080p60');
React.useEffect(() => {
if (!qualityOpen) return;
const h = (e) => { if (!e.target.closest('.stream-quality-menu') && !e.target.closest('[data-quality-trigger]')) setQualityOpen(false); };
setTimeout(() => document.addEventListener('mousedown', h), 50);
return () => document.removeEventListener('mousedown', h);
}, [qualityOpen]);
const participants = call?.participants || [];
const streaming = call?.streaming;
const streamer = call?.streamer || 'mira';
if (!call?.active) {
return (
<main className="chat">
<div className="chat-header">
<button className="icon-btn" onClick={onToggleRail}><Icon name="sidebarLeft" size={16} /></button>
<div className="chat-header-title"><Icon name="voice" size={16} /><span>No active call</span></div>
</div>
<div className="voice-hero" style={{alignItems: 'center', justifyContent: 'center'}}>
<div style={{color: 'var(--fg-muted)', fontSize: 14, textAlign: 'center'}}>
<div style={{fontSize: 32, marginBottom: 12, opacity: 0.4}}>𐃏</div>
<div>Join a voice room to start a call</div>
</div>
</div>
</main>
);
}
return (
<main className="chat">
<div className="chat-header">
<button className="icon-btn" onClick={onToggleRail}><Icon name="sidebarLeft" size={16} /></button>
<div className="chat-header-title">
<Icon name="voice" size={16} />
<span>{call?.roomName || 'The Lounge'}</span>
<span className="voice-live-pill" style={{marginLeft: 8}}>
<span className="live-dot" /> Live · {participants.length}
</span>
</div>
<div className="chat-header-actions">
<button className="icon-btn"><Icon name="users" size={16} /></button>
<button className="icon-btn"><Icon name="settings" size={16} /></button>
</div>
</div>
<div className="voice-hero">
<div className="voice-layout">
{streaming ? (
<>
<div className={`stream-hero ${fullscreen ? 'fullscreen' : ''}`}>
<div className="stream-hero-content">
<div className="stream-hero-center">
<div className="big-icon"><Icon name="screen" size={28} /></div>
<div className="big-label">{streamer}'s screen · {quality.toUpperCase()}</div>
</div>
</div>
<div className="stream-hero-topbar">
<span className="stream-live-pill"><span className="dot" /> Live</span>
<div className="stream-hero-title">{streamer}'s stream</div>
<div style={{flex: 1}} />
<div className="stream-hero-viewers"><Icon name="users" size={11} /> {participants.length} watching</div>
<div className="stream-hero-viewers"><Icon name="sparkle" size={11} /> 2.3 mbps</div>
</div>
<div className="stream-hero-bottombar">
<button className="stream-hero-btn primary" onClick={onToggleScreen} title="Stop stream">
<Icon name="screen" size={16} />
</button>
<button className="stream-hero-btn" data-quality-trigger onClick={() => setQualityOpen(v => !v)} title="Quality">
<Icon name="settings" size={16} />
</button>
<div className="stream-hero-spacer" />
<button className="stream-hero-btn" title="Picture-in-picture">
<Icon name="sidebarLeft" size={16} />
</button>
<button className="stream-hero-btn" onClick={() => setFullscreen(f => !f)} title={fullscreen ? 'Exit fullscreen' : 'Fullscreen'}>
<Icon name={fullscreen ? 'close' : 'globe'} size={16} />
</button>
</div>
{qualityOpen && (
<div className="stream-quality-menu">
<div className="stream-quality-title">Video quality</div>
{[
{ k: 'source', label: 'Source', hint: 'as-is' },
{ k: '1080p60', label: '1080p', hint: '60 fps' },
{ k: '1080p30', label: '1080p', hint: '30 fps' },
{ k: '720p60', label: '720p', hint: '60 fps' },
{ k: '480p', label: '480p', hint: 'data-saver' },
].map(q => (
<div key={q.k} className="stream-quality-item" onClick={() => { setQuality(q.k); setQualityOpen(false); }}>
<span className="check-col">{quality === q.k && <Icon name="check" size={12} />}</span>
<span className="label">{q.label}</span>
<span className="hint">{q.hint}</span>
</div>
))}
<div className="stream-quality-divider" />
<div className="stream-quality-title">Audio</div>
<div className="stream-quality-item"><span className="check-col"><Icon name="check" size={12} /></span><span className="label">Stereo · 256 kbps</span></div>
<div className="stream-quality-item"><span className="check-col"></span><span className="label">Mono · 96 kbps</span></div>
</div>
)}
</div>
<div className="voice-participants-row">
{participants.map((p, i) => (
<div key={i} className={`voice-tile small ${p.speaking ? 'speaking' : ''}`}>
<div className="voice-tile-avatar" style={{ background: `linear-gradient(135deg, ${p.color}, ${p.color}99)` }} onClick={() => onOpenProfile?.(p.name)}>{p.avatar}</div>
<div className="voice-tile-name">
{p.speaking && <span className="voice-wave"><span /><span /><span /><span /></span>}
{p.muted && <Icon name="micOff" size={10} />}
<span>{p.name}</span>
</div>
<div className="voice-status-icons">
{p.muted && <div className="voice-status-icon muted"><Icon name="micOff" size={10} /></div>}
</div>
</div>
))}
</div>
</>
) : (
<div className="voice-grid">
{participants.map((p, i) => (
<div key={i} className={`voice-tile ${p.speaking ? 'speaking' : ''}`}>
<div className="voice-tile-avatar" style={{ background: `linear-gradient(135deg, ${p.color}, ${p.color}99)` }} onClick={() => onOpenProfile?.(p.name)}>{p.avatar}</div>
<div className="voice-tile-name">
{p.speaking && <span className="voice-wave"><span /><span /><span /><span /></span>}
{p.muted && <Icon name="micOff" size={11} />}
<span>{p.name}</span>
</div>
<div className="voice-status-icons">
{p.muted && <div className="voice-status-icon muted"><Icon name="micOff" size={12} /></div>}
{!p.cam && <div className="voice-status-icon"><Icon name="videoOff" size={12} /></div>}
</div>
</div>
))}
</div>
)}
</div>
<div className="voice-controls">
<button className={`voice-ctrl-btn ${call?.mic ? '' : 'danger'}`} onClick={onToggleMic}>
<Icon name={call?.mic ? 'mic' : 'micOff'} size={20} />
</button>
<button className={`voice-ctrl-btn ${call?.cam ? 'on' : ''}`} onClick={onToggleCam}>
<Icon name={call?.cam ? 'video' : 'videoOff'} size={20} />
</button>
<button className={`voice-ctrl-btn ${streaming ? 'on' : ''}`} onClick={onToggleScreen} title={streaming ? 'Stop stream' : 'Share screen'}>
<Icon name="screen" size={20} />
</button>
<button className={`voice-ctrl-btn ${call?.deaf ? 'danger' : ''}`} onClick={onToggleDeaf}>
<Icon name="headphones" size={20} />
</button>
<div style={{ width: 1, height: 32, background: 'var(--border)' }} />
<button className="voice-ctrl-btn danger" onClick={onLeave}>
<Icon name="phoneOff" size={20} />
</button>
</div>
</div>
</main>
);
};
/* ===== Login ===== */
const LoginScreen = ({ onLogin }) => {
const [handle, setHandle] = React.useState('mira');
const [pw, setPw] = React.useState('••••••••••');
const [server, setServer] = React.useState('pyramid.chat');
const pyramids = Array.from({ length: 6 }, (_, i) => i);
return (
<div className="login-shell">
{pyramids.map(i => {
const s = 40 + Math.random() * 80;
return (
<div key={i} className="login-float" style={{
left: `${10 + i * 15}%`,
top: `${10 + (i % 3) * 30}%`,
'--rot': `${(i * 23) % 360}deg`,
animationDelay: `${i * 0.7}s`,
}}>
<PyramidMark size={s} />
</div>
);
})}
<form className="login-card" onSubmit={e => { e.preventDefault(); onLogin(); }}>
<div className="login-brand">
<div className="login-brand-mark"><PyramidMark size={56} /></div>
<div>
<div className="login-brand-name" style={{textAlign:'center'}}>pyramid</div>
<div className="login-brand-tag">the chat that stacks. encrypted, decentralized, yours.</div>
</div>
</div>
<div className="login-field">
<div className="login-field-label">Handle</div>
<input value={handle} onChange={e => setHandle(e.target.value)} placeholder="you" />
</div>
<div className="login-field">
<div className="login-field-label">Password</div>
<input type="password" value={pw} onChange={e => setPw(e.target.value)} />
</div>
<div className="login-field">
<div className="login-field-label">Homeserver</div>
<input value={server} onChange={e => setServer(e.target.value)} />
</div>
<button type="submit" className="login-submit">
Enter pyramid <Icon name="arrowUp" size={14} style={{transform:'rotate(90deg)'}} />
</button>
<div className="login-divider">or</div>
<div className="login-alt-row">
<button type="button" className="login-alt-btn"><Icon name="link" size={13} /> Magic link</button>
<button type="button" className="login-alt-btn"><Icon name="lock" size={13} /> Security key</button>
</div>
<div className="login-foot">
new here? <a onClick={onLogin}>make an account </a>
</div>
</form>
</div>
);
};
Object.assign(window, { EmojiPicker, MessageActionsMenu, ProfilePopover, SettingsModal, SearchModal, VoiceChannel, LoginScreen });