25ed765a03
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
479 lines
20 KiB
React
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 />);
|