Files
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

296 lines
13 KiB
React

/* Sidebar: Spaces Rail + Rooms panel */
const SpacesRail = ({ spaces, activeSpace, onSelect, collapsed }) => {
if (collapsed) return null;
return (
<aside className="rail">
<div className="rail-home" onClick={() => onSelect('home')}>
<PyramidMark size={30} accent="#fff" />
<span className="rail-tooltip">Pyramid · Home</span>
</div>
<div className="rail-divider" />
{spaces.filter(s => s.kind !== 'home').map(space => (
<div
key={space.id}
className={`rail-item ${activeSpace === space.id ? 'active' : ''} ${space.unread ? 'unread' : ''}`}
onClick={() => onSelect(space.id)}
>
<PyramidGlyph size={30} color={space.color} short={space.short} />
{space.unread > 0 && (
<span className="rail-badge" style={space.mention ? {} : { background: 'var(--accent)', color: 'var(--accent-fg)' }}>
{space.unread > 99 ? '99+' : space.unread}
</span>
)}
<span className="rail-tooltip">{space.name}</span>
</div>
))}
<RailAddBtn />
<div style={{flex: 1}} />
</aside>
);
};
const RailAddBtn = () => {
const [open, setOpen] = React.useState(false);
const ref = React.useRef(null);
React.useEffect(() => {
if (!open) return;
const h = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
setTimeout(() => document.addEventListener('mousedown', h), 50);
return () => document.removeEventListener('mousedown', h);
}, [open]);
return (
<div style={{position: 'relative'}} ref={ref}>
<div className="rail-add" onClick={() => setOpen(v => !v)}>
<Icon name="plus" size={20} />
<span className="rail-tooltip">Create · Join · Discover</span>
</div>
{open && (
<div className="context-menu space-menu-wide" style={{ left: 60, top: 0 }}>
<div className="space-menu-section">Create</div>
<div className="context-item" onClick={() => setOpen(false)}><Icon name="plus" size={14} /> New space <span className="shortcut">N</span></div>
<div className="context-item" onClick={() => setOpen(false)}><Icon name="users" size={14} /> Group DM</div>
<div className="context-divider" />
<div className="space-menu-section">Join</div>
<div className="context-item" onClick={() => setOpen(false)}><Icon name="link" size={14} /> Paste invite link</div>
<div className="context-item" onClick={() => setOpen(false)}><Icon name="at" size={14} /> Join by space ID</div>
<div className="context-divider" />
<div className="space-menu-section">Discover</div>
<div className="context-item" onClick={() => setOpen(false)}><Icon name="globe" size={14} /> Browse public rooms</div>
<div className="context-item" onClick={() => setOpen(false)}><Icon name="sparkle" size={14} /> Featured communities</div>
</div>
)}
</div>
);
};
const roomIcon = (type) => {
if (type === 'voice') return <Icon name="voice" size={15} />;
if (type === 'announce') return <Icon name="announce" size={15} />;
if (type === 'lock') return <Icon name="lock" size={15} />;
return <Icon name="hash" size={15} />;
};
const RoomsPanel = ({ space, roomsData, activeRoom, onSelectRoom, user, onOpenProfile, onOpenSettings, call, onLeaveCall, onToggleStreamCollapse, onToggleFullscreen, onToggleMic, onToggleDeaf, onToggleScreen, onToggleCam, onJumpToCall }) => {
const [collapsed, setCollapsed] = React.useState({});
const [q, setQ] = React.useState('');
const [menuOpen, setMenuOpen] = React.useState(false);
const toggle = (id) => setCollapsed(c => ({ ...c, [id]: !c[id] }));
const matchesQ = (name) => !q || name.toLowerCase().includes(q.toLowerCase());
return (
<aside className="rooms">
<div className="rooms-header">
<div className="rooms-header-icon" />
<div className="rooms-header-title">{roomsData?.name || space}</div>
<button className="icon-btn" title="Notifications"><Icon name="bell" size={15} /></button>
<button className="icon-btn" title="Space menu" onClick={() => setMenuOpen(v => !v)}>
<Icon name="chevronDown" size={15} />
</button>
{menuOpen && <SpaceMenu onClose={() => setMenuOpen(false)} />}
</div>
<div className="rooms-search">
<Icon name="search" size={14} style={{position:'absolute',left:21,top:'50%',transform:'translateY(-50%)',color:'var(--fg-dim)',pointerEvents:'none'}} />
<input placeholder="Filter rooms" value={q} onChange={e => setQ(e.target.value)} />
</div>
<div className="rooms-list">
{roomsData?.sections.map(section => {
const visible = section.rooms.filter(r => matchesQ(r.name));
if (visible.length === 0 && q) return null;
const isCollapsed = collapsed[section.id];
return (
<div className="rooms-section" key={section.id}>
<div className={`rooms-section-head ${isCollapsed ? 'collapsed' : ''}`} onClick={() => toggle(section.id)}>
<Icon name="chevronDown" size={10} className="chevron" />
<span>{section.title}</span>
<span className="count">{section.rooms.length}</span>
<span className="add-btn" onClick={e => { e.stopPropagation(); }}><Icon name="plus" size={12} /></span>
</div>
{!isCollapsed && visible.map(room => (
<RoomItem key={room.id} room={room} active={activeRoom === room.id} onClick={() => onSelectRoom(room.id)} />
))}
</div>
);
})}
</div>
{call?.active && (
<MiniCall
call={call}
onLeave={onLeaveCall}
onToggleStreamCollapse={onToggleStreamCollapse}
onToggleFullscreen={onToggleFullscreen}
onToggleMic={onToggleMic}
onToggleDeaf={onToggleDeaf}
onToggleScreen={onToggleScreen}
onToggleCam={onToggleCam}
onJumpToCall={onJumpToCall}
/>
)}
<UserPanel user={user} onOpenProfile={onOpenProfile} onOpenSettings={onOpenSettings} />
</aside>
);
};
const MiniCall = ({ call, onLeave, onToggleStreamCollapse, onToggleFullscreen, onToggleMic, onToggleDeaf, onToggleScreen, onToggleCam, onJumpToCall }) => {
const [elapsed, setElapsed] = React.useState(0);
React.useEffect(() => {
const i = setInterval(() => setElapsed(e => e + 1), 1000);
return () => clearInterval(i);
}, []);
const fmt = (s) => {
const m = Math.floor(s/60), sec = s%60;
return `${String(m).padStart(2,'0')}:${String(sec).padStart(2,'0')}`;
};
return (
<div className="mini-call">
{call.streaming && (
<div className={`stream-mini ${call.streamCollapsed ? 'collapsed' : ''}`} style={{height: call.streamCollapsed ? 28 : 120}}>
<div className="stream-mini-bar">
<div className="streamer">
<span className="dot" />
<span className="name">{call.streamer}'s stream</span>
</div>
{!call.streamCollapsed && (
<>
<button className="stream-mini-btn" onClick={onToggleFullscreen} title="Fullscreen">
<Icon name="screen" size={12} />
</button>
</>
)}
<button className="chevron-btn" onClick={onToggleStreamCollapse} title={call.streamCollapsed ? 'Expand preview' : 'Collapse preview'}>
<Icon name="chevronDown" size={13} />
</button>
</div>
{!call.streamCollapsed && (
<div className="stream-mini-content" onClick={onJumpToCall}>
<div className="stream-mini-label">LIVE · 1080p · 60fps</div>
</div>
)}
</div>
)}
<div className="mini-call-connected" onClick={onJumpToCall} style={{cursor: 'pointer'}}>
<div className="mini-call-left">
<div className="mini-call-title"><span className="live-dot" /> Voice connected</div>
<div className="mini-call-room">
<Icon name="voice" size={11} /> {call.roomName}
</div>
<div className="mini-call-meta">{fmt(elapsed)} · {call.participants.length} here</div>
</div>
<div className="mini-call-actions" onClick={e => e.stopPropagation()}>
<button className="icon-btn" onClick={onJumpToCall} title="Open call">
<Icon name="arrowUp" size={14} />
</button>
<button className="icon-btn leave" onClick={onLeave} title="Leave call">
<Icon name="phoneOff" size={14} />
</button>
</div>
</div>
<div className="mini-call-controls" onClick={e => e.stopPropagation()}>
<button className={`mini-call-ctrl ${call.mic ? '' : 'danger active'}`} onClick={onToggleMic} title={call.mic ? 'Mute' : 'Unmute'}>
<Icon name={call.mic ? 'mic' : 'micOff'} size={15} />
</button>
<button className={`mini-call-ctrl ${call.cam ? 'active' : ''}`} onClick={onToggleCam} title="Camera">
<Icon name={call.cam ? 'video' : 'videoOff'} size={15} />
</button>
<button className={`mini-call-ctrl ${call.streaming ? 'active' : ''}`} onClick={onToggleScreen} title={call.streaming ? 'Stop stream' : 'Share screen'}>
<Icon name="screen" size={15} />
</button>
<button className={`mini-call-ctrl ${call.deaf ? 'danger active' : ''}`} onClick={onToggleDeaf} title="Deafen">
<Icon name="headphones" size={15} />
</button>
</div>
</div>
);
};
const RoomItem = ({ room, active, onClick }) => {
if (room.type === 'dm' || room.type === 'group') {
return (
<div className={`room-item ${active ? 'active' : ''} ${room.unread ? 'unread' : ''}`} onClick={onClick}>
<div className="avatar dm-avatar" style={{ background: room.color || 'var(--bg-3)' }}>
{room.avatar}
{room.presence && <span className={`presence-dot ${room.presence}`} />}
</div>
<div className="room-name">{room.name}</div>
{room.unread > 0 && <div className="room-badge mention">{room.unread}</div>}
<div className="hover-actions">
<div className="hover-action" title="Close"><Icon name="close" size={12} /></div>
</div>
</div>
);
}
return (
<div className={`room-item ${active ? 'active' : ''} ${room.unread ? 'unread' : ''}`} onClick={onClick}>
<div className="room-icon">{roomIcon(room.type)}</div>
<div className="room-name">{room.name}</div>
{room.type === 'voice' && room.users > 0 && (
<div className="badge" style={{ padding: '0 6px', fontSize: 10 }}>
<Icon name="users" size={10} /> <span style={{marginLeft: 3}}>{room.users}</span>
</div>
)}
{room.unread > 0 && <div className={`room-badge ${room.mention ? 'mention' : ''}`}>{room.unread}</div>}
<div className="hover-actions">
<div className="hover-action" title="Invite"><Icon name="plus" size={12} /></div>
<div className="hover-action" title="Settings"><Icon name="settings" size={12} /></div>
</div>
</div>
);
};
const SpaceMenu = ({ onClose }) => {
React.useEffect(() => {
const h = (e) => onClose();
setTimeout(() => document.addEventListener('click', h, { once: true }), 0);
return () => document.removeEventListener('click', h);
}, []);
return (
<div className="context-menu" style={{ top: 46, right: 10 }}>
<div className="context-item"><Icon name="users" size={14} /> Invite people <span className="shortcut">⌘I</span></div>
<div className="context-item"><Icon name="plus" size={14} /> Create room</div>
<div className="context-item"><Icon name="plus" size={14} /> Create category</div>
<div className="context-divider" />
<div className="context-item"><Icon name="settings" size={14} /> Space settings</div>
<div className="context-item"><Icon name="bell" size={14} /> Notification prefs</div>
<div className="context-item"><Icon name="link" size={14} /> Copy space link</div>
<div className="context-divider" />
<div className="context-item danger"><Icon name="close" size={14} /> Leave space</div>
</div>
);
};
const UserPanel = ({ user, onOpenProfile, onOpenSettings }) => {
const [mic, setMic] = React.useState(true);
const [head, setHead] = React.useState(true);
return (
<div className="user-panel">
<div className="avatar" onClick={onOpenProfile}>
{user.avatar}
<span className={`presence-dot ${user.status}`} />
</div>
<div className="user-info" onClick={onOpenProfile}>
<div className="user-name">{user.name}</div>
<div className="user-status">{user.statusText}</div>
</div>
<div className="user-actions">
<button className="icon-btn" title={mic ? 'Mute' : 'Unmute'} onClick={() => setMic(m => !m)}>
<Icon name={mic ? 'mic' : 'micOff'} size={15} />
</button>
<button className="icon-btn" title={head ? 'Deafen' : 'Undeafen'} onClick={() => setHead(h => !h)}>
<Icon name="headphones" size={15} />
</button>
<button className="icon-btn" title="Settings" onClick={onOpenSettings}>
<Icon name="settings" size={15} />
</button>
</div>
</div>
);
};
Object.assign(window, { SpacesRail, RoomsPanel, RoomItem, SpaceMenu, UserPanel, MiniCall });