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
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
/* 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 });
|
||||
Reference in New Issue
Block a user