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

479 lines
20 KiB
React

/* Pyramid · main app */
const ACCENT_PRESETS = [
{ name: 'amber', h: 42, s: 95, l: 58 },
{ name: 'coral', h: 12, s: 85, l: 62 },
{ name: 'mint', h: 160, s: 65, l: 50 },
{ name: 'violet', h: 268, s: 78, l: 66 },
{ name: 'cobalt', h: 220, s: 85, l: 62 },
{ name: 'lime', h: 80, s: 70, l: 55 },
];
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"accentIdx": 0,
"theme": "dark",
"density": 1,
"motion": 1,
"radius": 12,
"view": "chat"
}/*EDITMODE-END*/;
const loadTweak = (k, d) => {
try { const v = localStorage.getItem('pyr_' + k); return v === null ? d : JSON.parse(v); } catch { return d; }
};
const saveTweak = (k, v) => { try { localStorage.setItem('pyr_' + k, JSON.stringify(v)); } catch {} };
const DEFAULT_CALL_PARTICIPANTS = [
{ name: 'juno', avatar: 'J', color: '#06b6d4', speaking: true, muted: false, cam: false },
{ name: 'sachi', avatar: 'S', color: '#a855f7', speaking: false, muted: false, cam: false },
{ name: 'mira (you)', avatar: 'M', color: '#ff6b9d', speaking: false, muted: false, cam: false, self: true },
{ name: 'ren', avatar: 'R', color: '#10b981', speaking: false, muted: true, cam: false },
];
const App = () => {
const [theme, setTheme] = React.useState(() => loadTweak('theme', TWEAK_DEFAULTS.theme));
const [accentIdx, setAccentIdx] = React.useState(() => loadTweak('accentIdx', TWEAK_DEFAULTS.accentIdx));
const [density, setDensity] = React.useState(() => loadTweak('density', TWEAK_DEFAULTS.density));
const [motion, setMotion] = React.useState(() => loadTweak('motion', TWEAK_DEFAULTS.motion));
const [radius, setRadius] = React.useState(() => loadTweak('radius', TWEAK_DEFAULTS.radius));
const [loggedIn, setLoggedIn] = React.useState(() => loadTweak('loggedIn', true));
const [view, setView] = React.useState(() => {
// never land on voice without an active call
const v = loadTweak('view', 'chat');
return v === 'voice' ? 'chat' : v;
});
const [activeSpace, setActiveSpace] = React.useState(() => loadTweak('space', 'atelier'));
const [activeRoom, setActiveRoom] = React.useState(() => loadTweak('room', 'general'));
const [railCollapsed, setRailCollapsed] = React.useState(false);
const [membersOpen, setMembersOpen] = React.useState(true);
const [tweaksMode, setTweaksMode] = React.useState(false);
const [showSettings, setShowSettings] = React.useState(false);
const [showSearch, setShowSearch] = React.useState(false);
const [profilePopover, setProfilePopover] = React.useState(null);
const [emojiPickerAt, setEmojiPickerAt] = React.useState(null);
const [messageActions, setMessageActions] = React.useState(null);
const [toast, setToast] = React.useState(null);
const [messagesState, setMessagesState] = React.useState(() => window.PYRAMID_DATA.messagesByRoom);
/* call state — persists across navigation */
const [call, setCall] = React.useState(null);
const [callReturnRoom, setCallReturnRoom] = React.useState(null);
React.useEffect(() => { saveTweak('theme', theme); }, [theme]);
React.useEffect(() => { saveTweak('accentIdx', accentIdx); }, [accentIdx]);
React.useEffect(() => { saveTweak('density', density); }, [density]);
React.useEffect(() => { saveTweak('motion', motion); }, [motion]);
React.useEffect(() => { saveTweak('radius', radius); }, [radius]);
React.useEffect(() => { saveTweak('view', view); }, [view]);
React.useEffect(() => { saveTweak('space', activeSpace); }, [activeSpace]);
React.useEffect(() => { saveTweak('room', activeRoom); }, [activeRoom]);
React.useEffect(() => { saveTweak('loggedIn', loggedIn); }, [loggedIn]);
React.useEffect(() => {
const a = ACCENT_PRESETS[accentIdx];
const r = document.documentElement;
r.setAttribute('data-theme', theme);
r.style.setProperty('--accent-h', a.h);
r.style.setProperty('--accent-s', a.s + '%');
r.style.setProperty('--accent-l', a.l + '%');
r.style.setProperty('--density', density);
r.style.setProperty('--motion', motion);
r.style.setProperty('--r-base', radius + 'px');
}, [theme, accentIdx, density, motion, radius]);
React.useEffect(() => {
const handler = (e) => {
if (e.data?.type === '__activate_edit_mode') setTweaksMode(true);
if (e.data?.type === '__deactivate_edit_mode') setTweaksMode(false);
};
window.addEventListener('message', handler);
window.parent?.postMessage({ type: '__edit_mode_available' }, '*');
return () => window.removeEventListener('message', handler);
}, []);
const pushEdits = (edits) => {
try { window.parent?.postMessage({ type: '__edit_mode_set_keys', edits }, '*'); } catch {}
};
React.useEffect(() => {
const h = (e) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); setShowSearch(true); }
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'b') { e.preventDefault(); setRailCollapsed(v => !v); }
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'u') { e.preventDefault(); setMembersOpen(v => !v); }
if (e.key === 'Escape') {
setShowSearch(false); setShowSettings(false);
setProfilePopover(null); setEmojiPickerAt(null); setMessageActions(null);
}
};
window.addEventListener('keydown', h);
return () => window.removeEventListener('keydown', h);
}, []);
const showToast = (msg, icon = 'check') => {
setToast({ msg, icon });
setTimeout(() => setToast(null), 2400);
};
const handleSelectSpace = (spaceId) => {
setActiveSpace(spaceId);
setView('chat');
const data = window.PYRAMID_DATA.rooms[spaceId];
if (data) {
const firstActive = data.sections.flatMap(s => s.rooms).find(r => r.active) || data.sections[0].rooms[0];
setActiveRoom(firstActive.id);
}
};
const handleSelectRoom = (roomId) => {
setActiveRoom(roomId);
const data = window.PYRAMID_DATA.rooms[activeSpace];
const roomDef = data?.sections.flatMap(s => s.rooms).find(r => r.id === roomId);
if (roomDef?.type === 'voice') {
// joining a voice room starts a call for that room
startCall({ roomName: roomDef.name, kind: 'voice', room: roomDef });
setView('voice');
} else {
setView('chat');
}
};
const startCall = ({ roomName, kind, room }) => {
setCall({
active: true,
roomName,
kind,
participants: DEFAULT_CALL_PARTICIPANTS,
mic: true, cam: kind === 'video', deaf: false,
streaming: false, streamCollapsed: false,
streamer: 'mira (you)',
roomId: room?.id || null,
});
setCallReturnRoom({ space: activeSpace, room: activeRoom });
};
/* called from DM header */
React.useEffect(() => {
window.__startDMCall = (roomDef, kind) => {
startCall({ roomName: roomDef?.name || 'DM', kind, room: roomDef });
setView('voice');
};
return () => { window.__startDMCall = null; };
}, [activeSpace, activeRoom]);
const handleLeaveCall = () => {
setCall(null);
if (view === 'voice') {
if (callReturnRoom) {
setActiveSpace(callReturnRoom.space);
setActiveRoom(callReturnRoom.room);
}
setView('chat');
}
setCallReturnRoom(null);
showToast('left the call', 'phoneOff');
};
const updateCall = (patch) => setCall(c => c ? { ...c, ...patch } : c);
const handleJumpToCall = () => {
setView('voice');
};
const handleSend = (text) => {
const newMsg = {
id: 'm' + Date.now(),
author: 'mira',
avatar: 'M',
color: '#ff6b9d',
time: new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }),
text,
};
setMessagesState(prev => ({
...prev,
[activeRoom]: [...(prev[activeRoom] || []), newMsg],
}));
};
const handleReact = (msgId, emoji) => {
setMessagesState(prev => {
const list = [...(prev[activeRoom] || [])];
const idx = list.findIndex(m => m.id === msgId);
if (idx < 0) return prev;
const msg = { ...list[idx] };
const reactions = [...(msg.reactions || [])];
const existing = reactions.findIndex(r => r.emoji === emoji);
if (existing >= 0) {
const r = { ...reactions[existing] };
if (r.mine) { r.mine = false; r.count--; } else { r.mine = true; r.count++; }
if (r.count <= 0) reactions.splice(existing, 1);
else reactions[existing] = r;
} else {
reactions.push({ emoji, count: 1, mine: true });
}
msg.reactions = reactions;
list[idx] = msg;
return { ...prev, [activeRoom]: list };
});
};
const handleOpenProfile = (name, e) => {
const rect = e?.currentTarget?.getBoundingClientRect();
const profiles = {
juno: { name: 'juno', handle: '@juno:pyramid.chat', avatar: 'J', color: '#06b6d4', status: 'online', about: 'risograph, zines, tea. teaches at the atelier on tuesdays.', roles: [{ name: 'founder', color: '#f59e0b' }, { name: 'print-nerd', color: '#06b6d4' }] },
sachi: { name: 'sachi', handle: '@sachi:pyramid.chat', avatar: 'S', color: '#a855f7', status: 'online', about: 'illustration, collage, usually reading', roles: [{ name: 'mod', color: '#a855f7' }] },
ren: { name: 'ren', handle: '@ren:pyramid.chat', avatar: 'R', color: '#10b981', status: 'busy', about: 'graphic design studio of one', roles: [] },
kai: { name: 'kai', handle: '@kai:pyramid.chat', avatar: 'K', color: '#f43f5e', status: 'away', about: '3d + motion. fancy renders upon request.', roles: [] },
ezra: { name: 'ezra', handle: '@ezra:pyramid.chat', avatar: 'E', color: '#3b82f6', status: 'online', about: 'keeps the lights on. dev-lab lurker.', roles: [{ name: 'dev', color: '#3b82f6' }] },
mira: { name: 'mira', handle: '@mira:pyramid.chat', avatar: 'M', color: '#ff6b9d', status: 'online', about: 'painter · printmaker · pastry enthusiast', roles: [{ name: 'founder', color: '#f59e0b' }] },
};
setProfilePopover({ anchor: rect, profile: profiles[name] || profiles.juno });
};
const handleOpenEmoji = (target, e) => {
const rect = e?.currentTarget?.getBoundingClientRect();
setEmojiPickerAt({ target, rect });
};
const handlePickEmoji = (emoji) => {
if (emojiPickerAt?.target === 'composer') {
showToast(`${emoji} added`, 'check');
} else if (emojiPickerAt?.target) {
handleReact(emojiPickerAt.target, emoji);
}
};
const handleOpenActions = (msgId, e) => {
const rect = e?.currentTarget?.getBoundingClientRect();
setMessageActions({ msgId, rect });
};
const handleMsgAction = (action) => {
const labels = {
reply: 'replying…', thread: 'thread opened', edit: 'editing message',
pin: 'pinned to room', bookmark: 'saved for later',
copy: 'copied', link: 'message link copied', delete: 'message deleted',
};
showToast(labels[action] || action);
};
if (!loggedIn) {
return <LoginScreen onLogin={() => setLoggedIn(true)} />;
}
const spaces = window.PYRAMID_DATA.spaces;
const roomsData = window.PYRAMID_DATA.rooms[activeSpace] || window.PYRAMID_DATA.rooms.atelier;
const messages = messagesState[activeRoom] || [];
const members = window.PYRAMID_DATA.membersByRoom[activeRoom] || window.PYRAMID_DATA.membersByRoom.general;
const showMembers = membersOpen && view !== 'voice';
const appClasses = [
'app',
railCollapsed && 'rail-collapsed',
showMembers && 'right-open',
].filter(Boolean).join(' ');
return (
<>
<div className={appClasses} data-screen-label={view === 'voice' ? 'Voice Channel' : activeRoom}>
<SpacesRail
spaces={spaces}
activeSpace={activeSpace}
onSelect={handleSelectSpace}
collapsed={railCollapsed}
/>
<RoomsPanel
space={activeSpace}
roomsData={roomsData}
activeRoom={activeRoom}
onSelectRoom={handleSelectRoom}
user={window.PYRAMID_DATA.user}
onOpenProfile={(e) => handleOpenProfile('mira', e)}
onOpenSettings={() => setShowSettings(true)}
call={call}
onLeaveCall={handleLeaveCall}
onToggleStreamCollapse={() => call && updateCall({ streamCollapsed: !call.streamCollapsed })}
onToggleMic={() => call && updateCall({ mic: !call.mic })}
onToggleDeaf={() => call && updateCall({ deaf: !call.deaf })}
onToggleScreen={() => call && updateCall({ streaming: !call.streaming, streamCollapsed: false })}
onToggleCam={() => call && updateCall({ cam: !call.cam })}
onJumpToCall={handleJumpToCall}
/>
{view === 'voice' ? (
<VoiceChannel
call={call}
onLeave={handleLeaveCall}
onToggleRail={() => setRailCollapsed(v => !v)}
onOpenProfile={handleOpenProfile}
onToggleMic={() => call && updateCall({ mic: !call.mic })}
onToggleDeaf={() => call && updateCall({ deaf: !call.deaf })}
onToggleCam={() => call && updateCall({ cam: !call.cam })}
onToggleScreen={() => call && updateCall({ streaming: !call.streaming, streamCollapsed: false })}
/>
) : (
<ChatView
room={activeRoom}
roomsData={roomsData}
messages={messages}
members={members}
onOpenProfile={handleOpenProfile}
onOpenActions={handleOpenActions}
onOpenEmoji={handleOpenEmoji}
onToggleMembers={() => setMembersOpen(v => !v)}
membersOpen={membersOpen}
onOpenSearch={() => setShowSearch(true)}
onToggleRail={() => setRailCollapsed(v => !v)}
railCollapsed={railCollapsed}
onSend={handleSend}
onReact={handleReact}
/>
)}
{showMembers && <MembersPanel room={activeRoom} members={members} />}
</div>
<DemoSwitcher
view={view}
setView={(v) => {
if (v === 'voice') {
if (!call) startCall({ roomName: 'The Lounge', kind: 'voice', room: { id: 'lounge', name: 'The Lounge' } });
setView('voice');
}
else if (v === 'dm') { setActiveSpace('friends'); setActiveRoom('dm-juno'); setView('chat'); }
else if (v === 'chat') { setActiveSpace('atelier'); setActiveRoom('general'); setView('chat'); }
}}
onLogin={() => setLoggedIn(false)}
onSearch={() => setShowSearch(true)}
onSettings={() => setShowSettings(true)}
onProfile={(e) => handleOpenProfile('juno', e)}
onEmoji={(e) => handleOpenEmoji('composer', e)}
onTheme={() => setTheme(t => t === 'dark' ? 'light' : 'dark')}
theme={theme}
/>
{showSettings && (
<SettingsModal
onClose={() => setShowSettings(false)}
theme={theme}
onTheme={setTheme}
accentPresets={ACCENT_PRESETS}
accent={ACCENT_PRESETS[accentIdx].h}
onAccent={(p) => setAccentIdx(ACCENT_PRESETS.findIndex(a => a.name === p.name))}
density={density}
onDensity={setDensity}
motion={motion}
onMotion={setMotion}
radius={radius}
onRadius={setRadius}
/>
)}
{showSearch && <SearchModal onClose={() => setShowSearch(false)} onJump={() => setShowSearch(false)} />}
{profilePopover && (
<ProfilePopover
anchor={profilePopover.anchor}
profile={profilePopover.profile}
onClose={() => setProfilePopover(null)}
/>
)}
{emojiPickerAt && (
<EmojiPicker
anchor={emojiPickerAt.rect}
onPick={handlePickEmoji}
onClose={() => setEmojiPickerAt(null)}
/>
)}
{messageActions && (
<MessageActionsMenu
anchor={messageActions.rect}
onAction={handleMsgAction}
onClose={() => setMessageActions(null)}
/>
)}
{toast && (
<div className="toast">
<Icon name={toast.icon} size={14} />
<span>{toast.msg}</span>
</div>
)}
{tweaksMode && (
<TweaksPanel
theme={theme} setTheme={(v) => { setTheme(v); pushEdits({ theme: v }); }}
accentIdx={accentIdx} setAccentIdx={(v) => { setAccentIdx(v); pushEdits({ accentIdx: v }); }}
density={density} setDensity={(v) => { setDensity(v); pushEdits({ density: v }); }}
motion={motion} setMotion={(v) => { setMotion(v); pushEdits({ motion: v }); }}
radius={radius} setRadius={(v) => { setRadius(v); pushEdits({ radius: v }); }}
/>
)}
</>
);
};
const DemoSwitcher = ({ view, setView, onLogin, onSearch, onSettings, onProfile, onEmoji, onTheme, theme }) => {
return (
<div className="demo-switcher">
<button className={view === 'chat' ? 'active' : ''} onClick={() => setView('chat')}>Chat</button>
<button className={view === 'dm' ? 'active' : ''} onClick={() => setView('dm')}>DM</button>
<button className={view === 'voice' ? 'active' : ''} onClick={() => setView('voice')}>Voice</button>
<div style={{width: 1, background: 'var(--border)', margin: '4px 2px'}} />
<button onClick={(e) => onProfile(e)}>Profile</button>
<button onClick={(e) => onEmoji(e)}>Emoji</button>
<button onClick={onSearch}>Search</button>
<button onClick={onSettings}>Settings</button>
<button onClick={onLogin}>Login</button>
<div style={{width: 1, background: 'var(--border)', margin: '4px 2px'}} />
<button onClick={onTheme} title="Toggle theme">
<Icon name={theme === 'dark' ? 'sun' : 'moon'} size={12} />
</button>
</div>
);
};
const TweaksPanel = ({ theme, setTheme, accentIdx, setAccentIdx, density, setDensity, motion, setMotion, radius, setRadius }) => (
<div className="tweaks-panel">
<div className="tweaks-header">
<div className="tweaks-title"><Icon name="sparkle" size={14} /> Tweaks</div>
<span style={{fontSize: 10, color: 'var(--fg-dim)', fontFamily: 'var(--font-mono)'}}>pyramid.sys</span>
</div>
<div className="tweaks-body">
<div className="tweak-row">
<div className="tweak-label"><span>Theme</span><span className="val">{theme}</span></div>
<div className="seg">
<button className={theme === 'dark' ? 'active' : ''} onClick={() => setTheme('dark')}>Dark</button>
<button className={theme === 'light' ? 'active' : ''} onClick={() => setTheme('light')}>Light</button>
</div>
</div>
<div className="tweak-row">
<div className="tweak-label"><span>Accent</span><span className="val">{ACCENT_PRESETS[accentIdx].name}</span></div>
<div className="tweak-swatches">
{ACCENT_PRESETS.map((p, i) => (
<div key={p.name} className={`tweak-swatch ${accentIdx === i ? 'active' : ''}`}
style={{ background: `hsl(${p.h} ${p.s}% ${p.l}%)` }}
onClick={() => setAccentIdx(i)} title={p.name} />
))}
</div>
</div>
<div className="tweak-row">
<div className="tweak-label"><span>Density</span><span className="val">{density === 0.85 ? 'compact' : density === 1.2 ? 'cozy' : 'default'}</span></div>
<div className="seg">
<button className={density === 0.85 ? 'active' : ''} onClick={() => setDensity(0.85)}>Compact</button>
<button className={density === 1 ? 'active' : ''} onClick={() => setDensity(1)}>Default</button>
<button className={density === 1.2 ? 'active' : ''} onClick={() => setDensity(1.2)}>Cozy</button>
</div>
</div>
<div className="tweak-row">
<div className="tweak-label"><span>Corner radius</span><span className="val">{radius}px</span></div>
<input type="range" min="4" max="20" value={radius} onChange={e => setRadius(+e.target.value)} />
</div>
<div className="tweak-row">
<div className="tweak-label"><span>Motion</span><span className="val">{Math.round(motion*100)}%</span></div>
<input type="range" min="0" max="100" value={motion*100} onChange={e => setMotion(+e.target.value/100)} />
</div>
</div>
</div>
);
ReactDOM.createRoot(document.getElementById('root')).render(<App />);