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:
+478
@@ -0,0 +1,478 @@
|
||||
/* 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 />);
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
/* Chat view — messages, composer, hover actions */
|
||||
|
||||
const renderText = (text, onMention) => {
|
||||
if (!text) return null;
|
||||
const parts = [];
|
||||
const regex = /(@\w+|#[\w-]+|https?:\/\/\S+|`[^`]+`)/g;
|
||||
let last = 0;
|
||||
text.replace(regex, (m, _, idx) => {
|
||||
if (idx > last) parts.push(text.slice(last, idx));
|
||||
if (m.startsWith('@')) parts.push(<span key={idx} className="mention" onClick={() => onMention?.(m)}>{m}</span>);
|
||||
else if (m.startsWith('#')) parts.push(<a key={idx}>{m}</a>);
|
||||
else if (m.startsWith('http')) parts.push(<a key={idx}>{m}</a>);
|
||||
else if (m.startsWith('`')) parts.push(<code key={idx}>{m.slice(1, -1)}</code>);
|
||||
last = idx + m.length;
|
||||
return m;
|
||||
});
|
||||
if (last < text.length) parts.push(text.slice(last));
|
||||
return parts;
|
||||
};
|
||||
|
||||
const Message = ({ msg, onOpenProfile, onReact, onOpenActions, onOpenEmoji }) => {
|
||||
const hasAvatar = !msg.continuation;
|
||||
return (
|
||||
<div className={`msg-group ${hasAvatar ? 'has-avatar' : ''}`} data-msg-id={msg.id}>
|
||||
<div className="msg-avatar-col">
|
||||
{hasAvatar ? (
|
||||
<div className="msg-avatar avatar" style={{ background: msg.color }} onClick={(e) => onOpenProfile?.(msg.author, e)}>
|
||||
{msg.avatar}
|
||||
</div>
|
||||
) : (
|
||||
<div className="msg-timestamp-col">{msg.time}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="msg-body">
|
||||
{hasAvatar && (
|
||||
<div className="msg-header">
|
||||
<span className="msg-author" onClick={(e) => onOpenProfile?.(msg.author, e)}>{msg.author}</span>
|
||||
<span className="msg-time">{msg.time}</span>
|
||||
</div>
|
||||
)}
|
||||
{msg.text && <div className="msg-text">{renderText(msg.text)}</div>}
|
||||
{msg.image && (
|
||||
<div className="msg-image" style={{
|
||||
background: 'repeating-linear-gradient(45deg, var(--bg-3), var(--bg-3) 8px, var(--bg-2) 8px, var(--bg-2) 16px)'
|
||||
}}>
|
||||
<span className="msg-image-label">{msg.image}</span>
|
||||
</div>
|
||||
)}
|
||||
{msg.reactions && msg.reactions.length > 0 && (
|
||||
<div className="reactions">
|
||||
{msg.reactions.map((r, i) => (
|
||||
<button key={i} className={`reaction ${r.mine ? 'mine' : ''}`} onClick={() => onReact?.(msg.id, r.emoji)}>
|
||||
<span className="emoji">{r.emoji}</span>
|
||||
<span className="count">{r.count}</span>
|
||||
</button>
|
||||
))}
|
||||
<button className="reaction-add" onClick={(e) => onOpenEmoji?.(msg.id, e)}>
|
||||
<Icon name="smile" size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="msg-actions">
|
||||
<button className="msg-action emoji-btn" title="Reaction" onClick={(e) => onOpenEmoji?.(msg.id, e)}>
|
||||
<Icon name="smile" size={16} />
|
||||
</button>
|
||||
<button className="msg-action" title="Reply in thread"><Icon name="reply" size={16} /></button>
|
||||
<button className="msg-action" title="Pin"><Icon name="pin" size={16} /></button>
|
||||
<button className="msg-action" title="More" onClick={(e) => onOpenActions?.(msg.id, e)}>
|
||||
<Icon name="dots" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ChatHeader = ({ room, roomsData, onToggleMembers, membersOpen, onOpenSearch, onToggleRail, railCollapsed }) => {
|
||||
const roomDef = findRoom(roomsData, room);
|
||||
const isDM = roomDef?.type === 'dm';
|
||||
const title = roomDef?.name || room;
|
||||
const topic = isDM
|
||||
? `direct message with ${roomDef.name}`
|
||||
: 'hi-fi corner for paintings, zines, riso, and whatever else';
|
||||
|
||||
return (
|
||||
<div className="chat-header">
|
||||
<button className="icon-btn" onClick={onToggleRail} title={railCollapsed ? 'Show spaces' : 'Hide spaces'}>
|
||||
<Icon name="sidebarLeft" size={16} />
|
||||
</button>
|
||||
<div className="chat-header-title">
|
||||
{isDM ? (
|
||||
<div className="avatar" style={{ width: 26, height: 26, borderRadius: 8, background: roomDef.color, fontSize: 11, position: 'relative' }}>
|
||||
{roomDef.avatar}
|
||||
<span className={`presence-dot ${roomDef.presence}`} style={{ width: 8, height: 8, borderWidth: 2 }} />
|
||||
</div>
|
||||
) : (
|
||||
<span style={{color: 'var(--fg-dim)'}}>{roomIcon(roomDef?.type || 'text')}</span>
|
||||
)}
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
{!isDM && <div className="chat-header-topic">{topic}</div>}
|
||||
{isDM && <div style={{flex: 1}} />}
|
||||
<div className="chat-header-actions">
|
||||
{isDM && (
|
||||
<>
|
||||
<button className="icon-btn call-btn voice" title="Start voice call" onClick={() => window.__startDMCall?.(roomDef, 'voice')}>
|
||||
<Icon name="voice" size={16} />
|
||||
</button>
|
||||
<button className="icon-btn call-btn video" title="Start video call" onClick={() => window.__startDMCall?.(roomDef, 'video')}>
|
||||
<Icon name="video" size={16} />
|
||||
</button>
|
||||
<div style={{width: 1, height: 20, background: 'var(--border)', margin: '0 4px'}} />
|
||||
</>
|
||||
)}
|
||||
<button className="icon-btn" title="Threads"><Icon name="inbox" size={16} /></button>
|
||||
<button className="icon-btn" title="Pinned"><Icon name="pin" size={16} /></button>
|
||||
<button className="icon-btn" title="Search" onClick={onOpenSearch}><Icon name="search" size={16} /></button>
|
||||
<button className={`icon-btn ${membersOpen ? 'active' : ''}`} onClick={onToggleMembers} title="Members"
|
||||
style={membersOpen ? { background: 'var(--bg-hover)', color: 'var(--fg)'} : {}}>
|
||||
<Icon name="users" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const findRoom = (roomsData, id) => {
|
||||
if (!roomsData) return null;
|
||||
for (const section of roomsData.sections) {
|
||||
const r = section.rooms.find(r => r.id === id);
|
||||
if (r) return r;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const Composer = ({ room, onSend, onOpenEmoji }) => {
|
||||
const [text, setText] = React.useState('');
|
||||
const ref = React.useRef(null);
|
||||
|
||||
const submit = () => {
|
||||
if (text.trim()) {
|
||||
onSend(text);
|
||||
setText('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleKey = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
};
|
||||
|
||||
const roomName = room?.name || room;
|
||||
const placeholder = typeof roomName === 'string' && roomName.startsWith('dm-')
|
||||
? `Message…`
|
||||
: `Message #${typeof roomName === 'string' ? roomName.replace('dm-', '') : 'room'}`;
|
||||
|
||||
return (
|
||||
<div className="composer-wrap">
|
||||
<div className="composer">
|
||||
<div className="composer-input-row">
|
||||
<button className="icon-btn composer-plus" title="Attach"><Icon name="plus" size={16} /></button>
|
||||
<textarea
|
||||
ref={ref}
|
||||
className="composer-input"
|
||||
placeholder={placeholder}
|
||||
value={text}
|
||||
onChange={e => setText(e.target.value)}
|
||||
onKeyDown={handleKey}
|
||||
rows={1}
|
||||
/>
|
||||
<div className="composer-inline-tools">
|
||||
<button className="icon-btn" title="GIF"><Icon name="gif" size={14} /></button>
|
||||
<button className="icon-btn" title="Emoji" onClick={(e) => onOpenEmoji('composer', e)}>
|
||||
<Icon name="smile" size={14} />
|
||||
</button>
|
||||
<button className="icon-btn" title="Mention"><Icon name="at" size={13} /></button>
|
||||
</div>
|
||||
<button className={`composer-send ${text.trim() ? 'active' : ''}`} onClick={submit} title="Send">
|
||||
<Icon name="send" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MembersPanel = ({ room, members }) => {
|
||||
const data = members || { online: [], offline: [] };
|
||||
return (
|
||||
<aside className="members">
|
||||
<div className="members-header">
|
||||
<Icon name="users" size={16} />
|
||||
<span>Members</span>
|
||||
<span className="count">{data.online.length + data.offline.length}</span>
|
||||
</div>
|
||||
<div className="members-list">
|
||||
{data.online.length > 0 && (
|
||||
<>
|
||||
<div className="member-section-head">Online — {data.online.length}</div>
|
||||
{data.online.map((m, i) => (
|
||||
<div className="member-item" key={'o'+i}>
|
||||
<div className="avatar" style={{ background: m.color }}>
|
||||
{m.avatar}
|
||||
<span className={`presence-dot ${m.status}`} />
|
||||
</div>
|
||||
<div className="member-info">
|
||||
<div className="member-name">{m.name}{m.self ? ' (you)' : ''}</div>
|
||||
{m.note && <div className="member-note">{m.note}</div>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{data.offline.length > 0 && (
|
||||
<>
|
||||
<div className="member-section-head" style={{marginTop: 14}}>Offline — {data.offline.length}</div>
|
||||
{data.offline.map((m, i) => (
|
||||
<div className="member-item offline" key={'f'+i}>
|
||||
<div className="avatar" style={{ background: m.color }}>{m.avatar}</div>
|
||||
<div className="member-info">
|
||||
<div className="member-name">{m.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
const DateDivider = ({ label }) => <div className="date-divider"><span>{label}</span></div>;
|
||||
|
||||
const ChatView = ({ room, roomsData, messages, members, onOpenProfile, onOpenActions, onOpenEmoji, onToggleMembers, membersOpen, onOpenSearch, onToggleRail, railCollapsed, onSend, onReact }) => {
|
||||
const messagesRef = React.useRef(null);
|
||||
React.useEffect(() => {
|
||||
if (messagesRef.current) messagesRef.current.scrollTop = messagesRef.current.scrollHeight;
|
||||
}, [room, messages?.length]);
|
||||
|
||||
const roomDef = findRoom(roomsData, room);
|
||||
|
||||
return (
|
||||
<main className="chat">
|
||||
<ChatHeader
|
||||
room={room}
|
||||
roomsData={roomsData}
|
||||
onToggleMembers={onToggleMembers}
|
||||
membersOpen={membersOpen}
|
||||
onOpenSearch={onOpenSearch}
|
||||
onToggleRail={onToggleRail}
|
||||
railCollapsed={railCollapsed}
|
||||
/>
|
||||
<div className="messages" ref={messagesRef}>
|
||||
<DateDivider label="Today · April 19" />
|
||||
{messages?.map(m => (
|
||||
<Message
|
||||
key={m.id}
|
||||
msg={m}
|
||||
onOpenProfile={onOpenProfile}
|
||||
onReact={onReact}
|
||||
onOpenActions={onOpenActions}
|
||||
onOpenEmoji={onOpenEmoji}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Composer room={roomDef || room} onSend={onSend} onOpenEmoji={onOpenEmoji} />
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
Object.assign(window, { ChatView, Message, Composer, ChatHeader, MembersPanel, DateDivider, findRoom, roomIcon });
|
||||
@@ -0,0 +1,716 @@
|
||||
/* 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 });
|
||||
@@ -0,0 +1,26 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Pyramid · Matrix Client</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
<link rel="stylesheet" href="components.css" />
|
||||
<link rel="stylesheet" href="modals.css" />
|
||||
<link rel="stylesheet" href="callsystem.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
|
||||
<script src="data.js"></script>
|
||||
<script type="text/babel" src="icons.jsx"></script>
|
||||
<script type="text/babel" src="Sidebar.jsx"></script>
|
||||
<script type="text/babel" src="Chat.jsx"></script>
|
||||
<script type="text/babel" src="Modals.jsx"></script>
|
||||
<script type="text/babel" src="App.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 });
|
||||
@@ -0,0 +1,410 @@
|
||||
/* ============================================
|
||||
PYRAMID · Call system styles
|
||||
Mini panel, stream preview, in-call tiles
|
||||
============================================ */
|
||||
|
||||
/* Mini call panel (above user profile) */
|
||||
.mini-call {
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg-2);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
animation: slide-up 0.28s var(--spring-bounce);
|
||||
}
|
||||
.mini-call-connected {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: linear-gradient(90deg, hsl(142 60% 30% / 0.18), transparent 60%);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.mini-call-left { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
|
||||
.mini-call-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--online);
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
}
|
||||
.mini-call-title .live-dot {
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: var(--online);
|
||||
animation: pulse-ring 1.4s infinite;
|
||||
box-shadow: 0 0 0 0 hsl(142 76% 50% / 0.6);
|
||||
}
|
||||
.mini-call-room {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.mini-call-meta {
|
||||
font-size: 10px;
|
||||
color: var(--fg-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.mini-call-actions { display: flex; gap: 2px; }
|
||||
.mini-call-actions .icon-btn { width: 26px; height: 26px; }
|
||||
.mini-call-actions .icon-btn.leave { color: var(--danger); }
|
||||
.mini-call-actions .icon-btn.leave:hover { background: rgba(229,72,77,0.14); color: var(--danger); }
|
||||
|
||||
.mini-call-controls {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 8px 10px;
|
||||
background: var(--bg-2);
|
||||
}
|
||||
.mini-call-ctrl {
|
||||
flex: 1;
|
||||
height: 32px;
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--bg-3);
|
||||
color: var(--fg);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: all 0.18s var(--spring-bounce);
|
||||
}
|
||||
.mini-call-ctrl:hover { background: var(--bg-hover); transform: translateY(calc(-1px * var(--motion))); }
|
||||
.mini-call-ctrl.active { background: var(--accent); color: var(--accent-fg); }
|
||||
.mini-call-ctrl.danger.active { background: var(--danger); color: white; }
|
||||
|
||||
/* Stream mini preview (above mini-call) */
|
||||
.stream-mini {
|
||||
position: relative;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid var(--border);
|
||||
transition: height 0.3s var(--spring-bounce);
|
||||
}
|
||||
.stream-mini.collapsed {
|
||||
height: 28px !important;
|
||||
}
|
||||
.stream-mini.collapsed .stream-mini-content { opacity: 0; }
|
||||
|
||||
.stream-mini-content {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
repeating-linear-gradient(45deg, #1a1a2e 0 10px, #16213e 10px 20px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.stream-mini-content::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(ellipse at center, rgba(99,102,241,0.25), transparent 70%);
|
||||
animation: shimmer 4s infinite linear;
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
.stream-mini-label {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
color: white;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
opacity: 0.7;
|
||||
padding: 3px 8px;
|
||||
background: rgba(0,0,0,0.4);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.stream-mini-bar {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: linear-gradient(180deg, rgba(0,0,0,0.85), rgba(0,0,0,0.55) 70%, transparent);
|
||||
z-index: 2;
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.stream-mini.collapsed .stream-mini-bar {
|
||||
background: var(--bg-3);
|
||||
color: var(--fg);
|
||||
}
|
||||
.stream-mini-bar .streamer {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.stream-mini-bar .streamer .dot {
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: #ef4444;
|
||||
animation: pulse-ring 1.4s infinite;
|
||||
}
|
||||
.stream-mini-bar .name {
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.stream-mini-bar .chevron-btn {
|
||||
width: 18px; height: 18px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: inherit;
|
||||
opacity: 0.7;
|
||||
transition: transform 0.3s var(--spring-bounce), opacity 0.15s;
|
||||
}
|
||||
.stream-mini-bar .chevron-btn:hover { opacity: 1; }
|
||||
.stream-mini.collapsed .stream-mini-bar .chevron-btn { transform: rotate(180deg); }
|
||||
|
||||
.stream-mini-btn {
|
||||
width: 22px; height: 22px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(255,255,255,0.12);
|
||||
border-radius: 5px;
|
||||
color: white;
|
||||
transition: all 0.18s;
|
||||
}
|
||||
.stream-mini-btn:hover { background: rgba(255,255,255,0.22); }
|
||||
.stream-mini.collapsed .stream-mini-btn { display: none; }
|
||||
|
||||
/* ==== Voice channel: stream-as-hero layout ==== */
|
||||
.voice-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.stream-hero {
|
||||
flex: 1;
|
||||
min-height: 280px;
|
||||
border-radius: var(--r-lg);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: #000;
|
||||
border: 1.5px solid var(--border);
|
||||
transition: all 0.3s var(--spring-soft);
|
||||
}
|
||||
.stream-hero.fullscreen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.stream-hero-content {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
repeating-linear-gradient(45deg, #1a1a2e 0 14px, #16213e 14px 28px);
|
||||
}
|
||||
.stream-hero-content::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(ellipse at 30% 40%, rgba(99,102,241,0.35), transparent 60%),
|
||||
radial-gradient(ellipse at 70% 70%, rgba(236,72,153,0.25), transparent 60%);
|
||||
}
|
||||
.stream-hero-center {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: rgba(255,255,255,0.75);
|
||||
}
|
||||
.stream-hero-center .big-icon {
|
||||
width: 64px; height: 64px;
|
||||
border-radius: var(--r-lg);
|
||||
background: rgba(255,255,255,0.08);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.stream-hero-center .big-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.stream-hero-topbar {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
color: white;
|
||||
z-index: 3;
|
||||
background: linear-gradient(180deg, rgba(0,0,0,0.55), transparent);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
.stream-hero:hover .stream-hero-topbar,
|
||||
.stream-hero.fullscreen .stream-hero-topbar { opacity: 1; pointer-events: auto; }
|
||||
|
||||
.stream-live-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 9px;
|
||||
border-radius: var(--r-pill);
|
||||
background: rgba(239,68,68,0.9);
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.stream-live-pill .dot {
|
||||
width: 5px; height: 5px; border-radius: 50%;
|
||||
background: white;
|
||||
animation: pulse-ring 1.4s infinite;
|
||||
}
|
||||
|
||||
.stream-hero-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.stream-hero-viewers {
|
||||
font-size: 11px;
|
||||
opacity: 0.8;
|
||||
font-variant-numeric: tabular-nums;
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
}
|
||||
|
||||
.stream-hero-bottombar {
|
||||
position: absolute;
|
||||
bottom: 0; left: 0; right: 0;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
z-index: 3;
|
||||
background: linear-gradient(0deg, rgba(0,0,0,0.7), transparent);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
.stream-hero:hover .stream-hero-bottombar,
|
||||
.stream-hero.fullscreen .stream-hero-bottombar { opacity: 1; pointer-events: auto; }
|
||||
|
||||
.stream-hero-btn {
|
||||
width: 36px; height: 36px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(255,255,255,0.12);
|
||||
backdrop-filter: blur(6px);
|
||||
color: white;
|
||||
border-radius: var(--r-sm);
|
||||
transition: all 0.18s var(--spring-bounce);
|
||||
}
|
||||
.stream-hero-btn:hover {
|
||||
background: rgba(255,255,255,0.22);
|
||||
transform: scale(calc(1 + 0.08 * var(--motion)));
|
||||
}
|
||||
|
||||
.stream-hero-btn.primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
}
|
||||
.stream-hero-btn.primary:hover { background: var(--accent); filter: brightness(1.08); }
|
||||
|
||||
.stream-hero-spacer { flex: 1; }
|
||||
|
||||
.stream-quality-menu {
|
||||
position: absolute;
|
||||
bottom: 62px;
|
||||
right: 16px;
|
||||
background: rgba(15,15,22,0.96);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
border-radius: var(--r-base);
|
||||
padding: 4px;
|
||||
min-width: 200px;
|
||||
z-index: 5;
|
||||
animation: pop-in 0.18s var(--spring-bounce);
|
||||
transform-origin: bottom right;
|
||||
}
|
||||
.stream-quality-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 10px;
|
||||
border-radius: var(--r-sm);
|
||||
color: rgba(255,255,255,0.85);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.stream-quality-item:hover { background: rgba(255,255,255,0.1); }
|
||||
.stream-quality-item .check-col { width: 14px; display: flex; color: var(--accent); }
|
||||
.stream-quality-item .label { flex: 1; }
|
||||
.stream-quality-item .hint {
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono);
|
||||
opacity: 0.5;
|
||||
}
|
||||
.stream-quality-divider { height: 1px; background: rgba(255,255,255,0.1); margin: 4px 4px; }
|
||||
.stream-quality-title {
|
||||
padding: 6px 10px 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255,255,255,0.5);
|
||||
}
|
||||
|
||||
/* participants row below stream */
|
||||
.voice-participants-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 10px;
|
||||
max-height: 170px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.voice-tile.small {
|
||||
aspect-ratio: 16/10;
|
||||
min-height: 100px;
|
||||
}
|
||||
.voice-tile.small .voice-tile-avatar {
|
||||
width: 44px; height: 44px;
|
||||
font-size: 18px;
|
||||
}
|
||||
.voice-tile.small .voice-tile-name {
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
bottom: 6px; left: 6px;
|
||||
}
|
||||
|
||||
/* DM header call buttons */
|
||||
.chat-header-actions .call-btn {
|
||||
position: relative;
|
||||
}
|
||||
.chat-header-actions .call-btn.video:hover { color: var(--accent); }
|
||||
.chat-header-actions .call-btn.voice:hover { color: var(--online); }
|
||||
|
||||
/* Create/Join menu (expanded) */
|
||||
.space-menu-wide { min-width: 240px; }
|
||||
.space-menu-section {
|
||||
padding: 6px 10px 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
|
||||
/* Align user-panel baseline with composer bottom */
|
||||
.user-panel {
|
||||
padding: 8px 10px !important;
|
||||
min-height: 52px;
|
||||
}
|
||||
.composer-wrap {
|
||||
padding: 0 16px 12px !important;
|
||||
}
|
||||
@@ -0,0 +1,808 @@
|
||||
/* ============================================
|
||||
PYRAMID · Component styles
|
||||
============================================ */
|
||||
|
||||
/* ==== Spaces Rail ==== */
|
||||
.rail {
|
||||
background: var(--bg-0);
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 12px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.rail-home {
|
||||
width: 48px; height: 48px;
|
||||
border-radius: var(--r-lg);
|
||||
background: linear-gradient(135deg, var(--accent) 0%, hsl(calc(var(--accent-h) + 20) var(--accent-s) calc(var(--accent-l) - 10%)) 100%);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s var(--spring-bounce);
|
||||
position: relative;
|
||||
box-shadow: 0 4px 16px -4px var(--accent-glow);
|
||||
}
|
||||
.rail-home:hover { transform: translateY(calc(-2px * var(--motion))) rotate(calc(-3deg * var(--motion))); }
|
||||
.rail-home:active { transform: scale(0.94); }
|
||||
|
||||
.rail-divider {
|
||||
width: 32px; height: 2px;
|
||||
background: var(--border);
|
||||
border-radius: 2px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.rail-item {
|
||||
width: 48px; height: 48px;
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--bg-2);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: border-radius 0.28s var(--spring-bounce),
|
||||
background 0.2s var(--spring-soft),
|
||||
transform 0.2s var(--spring-bounce);
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: var(--fg);
|
||||
overflow: visible;
|
||||
}
|
||||
.rail-item:hover {
|
||||
border-radius: calc(var(--r-lg) * 0.7);
|
||||
background: var(--bg-3);
|
||||
transform: scale(calc(1 + 0.04 * var(--motion)));
|
||||
}
|
||||
.rail-item.active {
|
||||
border-radius: calc(var(--r-lg) * 0.6);
|
||||
}
|
||||
.rail-item.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 4px;
|
||||
height: 28px;
|
||||
background: var(--accent);
|
||||
border-radius: 0 3px 3px 0;
|
||||
animation: slide-right 0.3s var(--spring-bounce);
|
||||
}
|
||||
|
||||
/* Pyramid space icon: triangular shape */
|
||||
.rail-item .pyramid-shape {
|
||||
width: 30px; height: 28px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.rail-item.unread::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 4px;
|
||||
height: 8px;
|
||||
background: var(--fg);
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
|
||||
.rail-badge {
|
||||
position: absolute;
|
||||
bottom: -2px; right: -4px;
|
||||
min-width: 18px; height: 18px;
|
||||
padding: 0 5px;
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
border-radius: var(--r-pill);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border: 2px solid var(--bg-0);
|
||||
animation: bounce-in 0.4s var(--spring-bounce);
|
||||
}
|
||||
|
||||
.rail-add {
|
||||
width: 48px; height: 48px;
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--bg-2);
|
||||
color: var(--accent);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 22px;
|
||||
transition: all 0.25s var(--spring-bounce);
|
||||
border: 1.5px dashed var(--border-strong);
|
||||
}
|
||||
.rail-add:hover {
|
||||
border-radius: calc(var(--r-lg) * 0.7);
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
transform: scale(calc(1 + 0.06 * var(--motion))) rotate(calc(90deg * var(--motion)));
|
||||
}
|
||||
|
||||
/* Tooltip on rail items */
|
||||
.rail-tooltip {
|
||||
position: absolute;
|
||||
left: 64px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%) translateX(-4px);
|
||||
background: var(--bg-0);
|
||||
color: var(--fg);
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, transform 0.15s var(--spring-bounce);
|
||||
z-index: 100;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.rail-item:hover .rail-tooltip,
|
||||
.rail-home:hover .rail-tooltip,
|
||||
.rail-add:hover .rail-tooltip {
|
||||
opacity: 1;
|
||||
transform: translateY(-50%) translateX(0);
|
||||
}
|
||||
|
||||
/* ==== Rooms panel ==== */
|
||||
.rooms {
|
||||
background: var(--bg-1);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rooms-header {
|
||||
height: 52px;
|
||||
padding: 0 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rooms-header-title {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rooms-header-icon {
|
||||
width: 24px; height: 24px;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(135deg, var(--accent), hsl(calc(var(--accent-h) + 30) var(--accent-s) calc(var(--accent-l) - 8%)));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rooms-search {
|
||||
padding: 10px 12px;
|
||||
position: relative;
|
||||
}
|
||||
.rooms-search input {
|
||||
width: 100%;
|
||||
padding: 7px 10px 7px 30px;
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--bg-2);
|
||||
border: 1px solid transparent;
|
||||
color: var(--fg);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: all 0.2s var(--spring-soft);
|
||||
}
|
||||
.rooms-search input:focus {
|
||||
background: var(--bg-0);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
.rooms-search-icon {
|
||||
position: absolute;
|
||||
left: 21px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--fg-dim);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.rooms-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 8px 12px;
|
||||
}
|
||||
|
||||
.rooms-section {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.rooms-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 8px 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-dim);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.rooms-section-head:hover { color: var(--fg-muted); }
|
||||
.rooms-section-head .chevron {
|
||||
transition: transform 0.25s var(--spring-bounce);
|
||||
}
|
||||
.rooms-section-head.collapsed .chevron { transform: rotate(-90deg); }
|
||||
.rooms-section-head .count {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: var(--fg-dim);
|
||||
font-weight: 500;
|
||||
}
|
||||
.rooms-section-head .add-btn {
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
width: 18px; height: 18px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border-radius: 4px;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
.rooms-section-head:hover .add-btn { opacity: 1; }
|
||||
.rooms-section-head .add-btn:hover { background: var(--bg-hover); color: var(--accent); }
|
||||
|
||||
.room-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: var(--row-pad-y) var(--row-pad-x);
|
||||
border-radius: var(--r-sm);
|
||||
cursor: pointer;
|
||||
color: var(--fg-muted);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.18s var(--spring-soft);
|
||||
position: relative;
|
||||
min-height: 32px;
|
||||
}
|
||||
.room-item:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--fg);
|
||||
}
|
||||
.room-item.active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--fg);
|
||||
font-weight: 600;
|
||||
}
|
||||
.room-item.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 3px; height: 18px;
|
||||
background: var(--accent);
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
.room-item.unread { color: var(--fg); font-weight: 600; }
|
||||
.room-item.unread .room-icon { color: var(--fg); }
|
||||
|
||||
.room-icon {
|
||||
width: 16px; height: 16px;
|
||||
color: var(--fg-dim);
|
||||
flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.room-item.active .room-icon { color: var(--accent); }
|
||||
.room-item:hover .room-icon { color: var(--fg); }
|
||||
|
||||
.room-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.room-badge {
|
||||
min-width: 18px; height: 18px;
|
||||
padding: 0 5px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
border-radius: var(--r-pill);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.room-badge.mention { background: var(--danger); color: white; }
|
||||
|
||||
.room-item .hover-actions {
|
||||
display: none;
|
||||
gap: 2px;
|
||||
}
|
||||
.room-item:hover .hover-actions { display: flex; }
|
||||
.room-item:hover .room-badge { display: none; }
|
||||
|
||||
.room-item .hover-action {
|
||||
width: 20px; height: 20px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: var(--fg-dim);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.room-item .hover-action:hover { background: var(--bg-active); color: var(--fg); }
|
||||
|
||||
/* DM avatar in rooms list */
|
||||
.room-item .dm-avatar {
|
||||
width: 22px; height: 22px;
|
||||
border-radius: 50%;
|
||||
font-size: 10px;
|
||||
position: relative;
|
||||
}
|
||||
.room-item .dm-avatar .presence-dot {
|
||||
width: 8px; height: 8px;
|
||||
border-width: 2px;
|
||||
bottom: -1px; right: -1px;
|
||||
}
|
||||
|
||||
/* ==== User panel at bottom of rooms ==== */
|
||||
.user-panel {
|
||||
padding: 8px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--bg-2);
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
.user-panel .avatar {
|
||||
width: 32px; height: 32px;
|
||||
border-radius: var(--r-sm);
|
||||
background: linear-gradient(135deg, #ff6b9d, #c471f5);
|
||||
font-size: 13px;
|
||||
position: relative;
|
||||
}
|
||||
.user-info { flex: 1; min-width: 0; cursor: pointer; }
|
||||
.user-info:hover .user-name { color: var(--accent); }
|
||||
.user-name {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.user-status {
|
||||
font-size: 11px;
|
||||
color: var(--fg-muted);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
}
|
||||
|
||||
.user-actions { display: flex; gap: 2px; }
|
||||
|
||||
/* ==== Chat main ==== */
|
||||
.chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-1);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
height: 52px;
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-1);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.chat-header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
min-width: 0;
|
||||
}
|
||||
.chat-header-title .room-icon { color: var(--fg-dim); }
|
||||
|
||||
.chat-header-topic {
|
||||
color: var(--fg-muted);
|
||||
font-size: 13px;
|
||||
padding-left: 12px;
|
||||
border-left: 1px solid var(--border);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-header-actions { display: flex; gap: 2px; margin-left: auto; }
|
||||
|
||||
/* Messages */
|
||||
.messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.date-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 20px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--fg-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.date-divider::before,
|
||||
.date-divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.msg-group {
|
||||
padding: 4px 20px;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
position: relative;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.msg-group:hover { background: var(--bg-2); }
|
||||
|
||||
.msg-group + .msg-group { margin-top: 2px; }
|
||||
.msg-group.has-avatar { margin-top: var(--msg-gap); }
|
||||
|
||||
.msg-avatar-col {
|
||||
width: 38px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.msg-avatar {
|
||||
width: 38px; height: 38px;
|
||||
border-radius: var(--r-base);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s var(--spring-bounce);
|
||||
}
|
||||
.msg-avatar:hover { transform: scale(calc(1 + 0.06 * var(--motion))); }
|
||||
|
||||
.msg-timestamp-col {
|
||||
width: 38px;
|
||||
font-size: 10px;
|
||||
color: var(--fg-dim);
|
||||
text-align: center;
|
||||
padding-top: 4px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.msg-group:hover .msg-timestamp-col { opacity: 1; }
|
||||
|
||||
.msg-body { flex: 1; min-width: 0; }
|
||||
|
||||
.msg-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.msg-author {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.msg-author:hover { text-decoration: underline; }
|
||||
.msg-time {
|
||||
font-size: 11px;
|
||||
color: var(--fg-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.msg-text {
|
||||
font-size: 14px;
|
||||
color: var(--fg);
|
||||
word-wrap: break-word;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.msg-text code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12.5px;
|
||||
background: var(--bg-3);
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.msg-text a { color: var(--accent); text-decoration: none; }
|
||||
.msg-text a:hover { text-decoration: underline; }
|
||||
.msg-text .mention {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.msg-text .mention:hover {
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
}
|
||||
|
||||
.msg-image {
|
||||
margin-top: 6px;
|
||||
border-radius: var(--r-base);
|
||||
overflow: hidden;
|
||||
max-width: 420px;
|
||||
height: 220px;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--border);
|
||||
position: relative;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
cursor: zoom-in;
|
||||
transition: transform 0.2s var(--spring-soft);
|
||||
}
|
||||
.msg-image:hover { transform: translateY(calc(-2px * var(--motion))); }
|
||||
.msg-image-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--fg-dim);
|
||||
background: rgba(0,0,0,0.5);
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
position: absolute;
|
||||
bottom: 10px; left: 10px;
|
||||
}
|
||||
|
||||
/* Reactions */
|
||||
.reactions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.reaction {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-pill);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--fg-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s var(--spring-bounce);
|
||||
user-select: none;
|
||||
}
|
||||
.reaction:hover {
|
||||
transform: translateY(calc(-1px * var(--motion))) scale(calc(1 + 0.04 * var(--motion)));
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.reaction.mine {
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.reaction .emoji { font-size: 14px; }
|
||||
.reaction .count { font-variant-numeric: tabular-nums; }
|
||||
|
||||
.reaction-add {
|
||||
width: 24px; height: 22px;
|
||||
padding: 0;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
background: var(--bg-3);
|
||||
border: 1px dashed var(--border-strong);
|
||||
border-radius: var(--r-pill);
|
||||
color: var(--fg-dim);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: all 0.2s var(--spring-bounce);
|
||||
}
|
||||
.msg-group:hover .reaction-add { opacity: 1; }
|
||||
.reaction-add:hover { color: var(--accent); border-color: var(--accent); transform: rotate(calc(90deg * var(--motion))); }
|
||||
|
||||
/* Hover actions popup on message */
|
||||
.msg-actions {
|
||||
position: absolute;
|
||||
top: -14px;
|
||||
right: 32px;
|
||||
background: var(--bg-0);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-base);
|
||||
padding: 2px;
|
||||
display: none;
|
||||
gap: 1px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 3;
|
||||
}
|
||||
.msg-group:hover .msg-actions {
|
||||
display: flex;
|
||||
animation: pop-in 0.18s var(--spring-bounce);
|
||||
}
|
||||
.msg-action {
|
||||
width: 32px; height: 32px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--fg-muted);
|
||||
transition: all 0.15s;
|
||||
position: relative;
|
||||
}
|
||||
.msg-action:hover { background: var(--bg-hover); color: var(--fg); }
|
||||
.msg-action.emoji-btn:hover { color: var(--accent); }
|
||||
|
||||
/* Composer */
|
||||
.composer-wrap {
|
||||
padding: 0 16px 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.composer {
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: calc(var(--r-base) + 4px);
|
||||
transition: all 0.2s var(--spring-soft);
|
||||
}
|
||||
.composer:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
|
||||
.composer-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 6px 4px 4px;
|
||||
min-height: 40px;
|
||||
}
|
||||
.composer-input-row .composer-plus {
|
||||
width: 30px; height: 30px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.composer-inline-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
flex-shrink: 0;
|
||||
padding-right: 2px;
|
||||
border-right: 1px solid var(--border);
|
||||
margin-right: 4px;
|
||||
}
|
||||
.composer-inline-tools .icon-btn {
|
||||
width: 26px; height: 26px;
|
||||
}
|
||||
.composer-input {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--fg);
|
||||
font-size: 14px;
|
||||
resize: none;
|
||||
min-height: 22px;
|
||||
max-height: 200px;
|
||||
padding: 4px 2px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.composer-input::placeholder { color: var(--fg-dim); }
|
||||
|
||||
.composer-send {
|
||||
width: 30px; height: 30px;
|
||||
border-radius: calc(var(--r-sm) - 2px);
|
||||
background: var(--bg-3);
|
||||
color: var(--fg-dim);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: all 0.2s var(--spring-bounce);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.composer-send.active {
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
transform: scale(calc(1 + 0.05 * var(--motion)));
|
||||
}
|
||||
.composer-send.active:hover {
|
||||
transform: scale(calc(1 + 0.1 * var(--motion))) rotate(calc(-15deg * var(--motion)));
|
||||
box-shadow: 0 4px 12px -2px var(--accent-glow);
|
||||
}
|
||||
|
||||
.composer-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 0 10px 8px;
|
||||
}
|
||||
.composer-toolbar .icon-btn { width: 28px; height: 28px; }
|
||||
.composer-hint {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: var(--fg-dim);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* ==== Members panel (right) ==== */
|
||||
.members {
|
||||
background: var(--bg-1);
|
||||
border-left: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.members-header {
|
||||
height: 52px;
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
.members-header .count { color: var(--fg-dim); font-weight: 500; }
|
||||
.members-list { flex: 1; overflow-y: auto; padding: 8px; }
|
||||
|
||||
.member-section-head {
|
||||
padding: 8px 8px 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
|
||||
.member-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--r-sm);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.member-item:hover { background: var(--bg-hover); }
|
||||
.member-item .avatar {
|
||||
width: 28px; height: 28px;
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 11px;
|
||||
position: relative;
|
||||
}
|
||||
.member-info { flex: 1; min-width: 0; }
|
||||
.member-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.member-note {
|
||||
font-size: 11px;
|
||||
color: var(--fg-dim);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.member-item.offline .avatar { opacity: 0.5; }
|
||||
.member-item.offline .member-name { color: var(--fg-muted); }
|
||||
|
||||
/* ==== Toolbar (top-right in chat) ==== */
|
||||
.topic-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
/* Shared data for the Pyramid mockup */
|
||||
|
||||
window.PYRAMID_DATA = {
|
||||
user: {
|
||||
name: 'mira',
|
||||
handle: '@mira:pyramid.chat',
|
||||
avatar: 'M',
|
||||
status: 'online',
|
||||
statusText: 'in the zone 🎨',
|
||||
},
|
||||
|
||||
spaces: [
|
||||
{ id: 'home', kind: 'home', name: 'Home', unread: 0 },
|
||||
{ id: 'atelier', name: 'Atelier', short: 'A', color: '#f59e0b', unread: 3, mention: false, active: true },
|
||||
{ id: 'friends', name: 'Friends', short: 'F', color: '#ec4899', unread: 12, mention: true },
|
||||
{ id: 'music', name: 'soundscape', short: 'S', color: '#8b5cf6', unread: 0 },
|
||||
{ id: 'readclub', name: 'Read Club', short: 'R', color: '#06b6d4', unread: 0 },
|
||||
{ id: 'devlab', name: 'Dev Lab', short: 'D', color: '#10b981', unread: 1 },
|
||||
],
|
||||
|
||||
rooms: {
|
||||
atelier: {
|
||||
name: 'Atelier',
|
||||
sections: [
|
||||
{
|
||||
id: 'info',
|
||||
title: 'Info',
|
||||
rooms: [
|
||||
{ id: 'welcome', name: 'welcome', type: 'text' },
|
||||
{ id: 'rules', name: 'rules-and-vibes', type: 'text' },
|
||||
{ id: 'announcements', name: 'announcements', type: 'announce', unread: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'chat',
|
||||
title: 'Rooms',
|
||||
rooms: [
|
||||
{ id: 'general', name: 'general', type: 'text', active: true },
|
||||
{ id: 'crit', name: 'critique-circle', type: 'text', unread: 5, mention: true },
|
||||
{ id: 'wip', name: 'work-in-progress', type: 'text', unread: 1 },
|
||||
{ id: 'resources', name: 'resources', type: 'text' },
|
||||
{ id: 'offtopic', name: 'off-topic', type: 'text' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'voice',
|
||||
title: 'Voice',
|
||||
rooms: [
|
||||
{ id: 'lounge', name: 'The Lounge', type: 'voice', users: 4 },
|
||||
{ id: 'focus', name: 'Focus Room', type: 'voice' },
|
||||
{ id: 'movie', name: 'Movie Night', type: 'voice' },
|
||||
],
|
||||
},
|
||||
],
|
||||
dms: [],
|
||||
},
|
||||
friends: {
|
||||
name: 'Friends & DMs',
|
||||
sections: [
|
||||
{
|
||||
id: 'dms',
|
||||
title: 'Direct Messages',
|
||||
rooms: [
|
||||
{ id: 'dm-juno', name: 'juno', type: 'dm', avatar: 'J', color: '#06b6d4', presence: 'online', unread: 2, active: true },
|
||||
{ id: 'dm-kai', name: 'kai', type: 'dm', avatar: 'K', color: '#f43f5e', presence: 'away' },
|
||||
{ id: 'dm-ren', name: 'ren', type: 'dm', avatar: 'R', color: '#10b981', presence: 'busy' },
|
||||
{ id: 'dm-sachi', name: 'sachi ✨', type: 'dm', avatar: 'S', color: '#a855f7', presence: 'online' },
|
||||
{ id: 'dm-lo', name: 'lo', type: 'dm', avatar: 'L', color: '#f59e0b', presence: 'offline' },
|
||||
{ id: 'dm-ezra', name: 'ezra', type: 'dm', avatar: 'E', color: '#3b82f6', presence: 'offline' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'groups',
|
||||
title: 'Group Chats',
|
||||
rooms: [
|
||||
{ id: 'g-tues', name: 'tuesday night', type: 'group', avatar: '🌙' },
|
||||
{ id: 'g-trip', name: 'lisbon trip ✈️', type: 'group', avatar: '✈️', unread: 8 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
messagesByRoom: {
|
||||
general: [
|
||||
{
|
||||
id: 'm1',
|
||||
author: 'juno',
|
||||
avatar: 'J',
|
||||
color: '#06b6d4',
|
||||
time: '2:14 PM',
|
||||
text: 'morning! just wrapped that risograph print you all were curious about last week',
|
||||
reactions: [
|
||||
{ emoji: '🌿', count: 4, mine: true },
|
||||
{ emoji: '👀', count: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'm2',
|
||||
author: 'juno',
|
||||
continuation: true,
|
||||
time: '2:14 PM',
|
||||
text: 'turned out way better than expected — the mis-registration is doing a LOT of heavy lifting',
|
||||
},
|
||||
{
|
||||
id: 'm3',
|
||||
author: 'juno',
|
||||
continuation: true,
|
||||
time: '2:15 PM',
|
||||
image: 'risograph print · 2 colors · A3',
|
||||
reactions: [
|
||||
{ emoji: '🔥', count: 7, mine: true },
|
||||
{ emoji: '💚', count: 3 },
|
||||
{ emoji: '🫠', count: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'm4',
|
||||
author: 'sachi',
|
||||
avatar: 'S',
|
||||
color: '#a855f7',
|
||||
time: '2:18 PM',
|
||||
text: 'ok the teal bleeding into the coral here is *chefskiss* — what paper did you end up using?',
|
||||
},
|
||||
{
|
||||
id: 'm5',
|
||||
author: 'ren',
|
||||
avatar: 'R',
|
||||
color: '#10b981',
|
||||
time: '2:22 PM',
|
||||
text: 'this is making me want to finally try riso. @juno do you know anywhere nearby that rents time on one?',
|
||||
mentions: ['juno'],
|
||||
},
|
||||
{
|
||||
id: 'm6',
|
||||
author: 'juno',
|
||||
avatar: 'J',
|
||||
color: '#06b6d4',
|
||||
time: '2:24 PM',
|
||||
text: 'there\'s a little studio on baker street — they do 4hr blocks for 30 bucks, i can drop the link in #resources',
|
||||
reactions: [{ emoji: '🙏', count: 2 }],
|
||||
},
|
||||
{
|
||||
id: 'm7',
|
||||
author: 'mira',
|
||||
avatar: 'M',
|
||||
color: '#ff6b9d',
|
||||
time: '2:31 PM',
|
||||
text: 'oh dope, add me to that excursion whenever you two go',
|
||||
reactions: [{ emoji: '✋', count: 3, mine: true }],
|
||||
},
|
||||
{
|
||||
id: 'm8',
|
||||
author: 'kai',
|
||||
avatar: 'K',
|
||||
color: '#f43f5e',
|
||||
time: '2:40 PM',
|
||||
text: 'side note — anyone else\'s composer showing `typing…` ghosts that won\'t go away? third time this week',
|
||||
},
|
||||
{
|
||||
id: 'm9',
|
||||
author: 'kai',
|
||||
continuation: true,
|
||||
time: '2:40 PM',
|
||||
text: 'not a huge deal, just curious if it\'s a me problem',
|
||||
},
|
||||
{
|
||||
id: 'm10',
|
||||
author: 'ezra',
|
||||
avatar: 'E',
|
||||
color: '#3b82f6',
|
||||
time: '2:44 PM',
|
||||
text: 'confirmed on my end too — filed a tiny bug report in #dev-lab yesterday',
|
||||
},
|
||||
],
|
||||
'dm-juno': [
|
||||
{
|
||||
id: 'dm1',
|
||||
author: 'juno',
|
||||
avatar: 'J',
|
||||
color: '#06b6d4',
|
||||
time: '11:02 AM',
|
||||
text: 'heyy — you around later? wanted to pick your brain about the studio visit',
|
||||
},
|
||||
{
|
||||
id: 'dm2',
|
||||
author: 'mira',
|
||||
avatar: 'M',
|
||||
color: '#ff6b9d',
|
||||
time: '11:14 AM',
|
||||
text: 'yes!! free after 4, should i come by or zoom?',
|
||||
reactions: [{ emoji: '💛', count: 1 }],
|
||||
},
|
||||
{
|
||||
id: 'dm3',
|
||||
author: 'juno',
|
||||
avatar: 'J',
|
||||
color: '#06b6d4',
|
||||
time: '11:16 AM',
|
||||
text: 'come by — i finally got the kettle working so tea is officially on the table',
|
||||
},
|
||||
{
|
||||
id: 'dm4',
|
||||
author: 'juno',
|
||||
continuation: true,
|
||||
time: '11:16 AM',
|
||||
text: 'also i want to show you the new zine layout in person, screenshots don\'t do it justice',
|
||||
},
|
||||
{
|
||||
id: 'dm5',
|
||||
author: 'mira',
|
||||
avatar: 'M',
|
||||
color: '#ff6b9d',
|
||||
time: '11:18 AM',
|
||||
text: 'sold. bringing pastries',
|
||||
reactions: [{ emoji: '🥐', count: 1 }, { emoji: '🥹', count: 1, mine: true }],
|
||||
},
|
||||
{
|
||||
id: 'dm6',
|
||||
author: 'juno',
|
||||
avatar: 'J',
|
||||
color: '#06b6d4',
|
||||
time: '1:45 PM',
|
||||
text: 'btw — thought you\'d like this piece about marginalia in printing. very you.',
|
||||
},
|
||||
{
|
||||
id: 'dm7',
|
||||
author: 'juno',
|
||||
continuation: true,
|
||||
time: '1:46 PM',
|
||||
text: 'https://types.place/marginalia-as-protest',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
membersByRoom: {
|
||||
general: {
|
||||
online: [
|
||||
{ name: 'juno', color: '#06b6d4', avatar: 'J', note: 'playing with inks', status: 'online' },
|
||||
{ name: 'sachi', color: '#a855f7', avatar: 'S', note: 'reading', status: 'online' },
|
||||
{ name: 'mira', color: '#ff6b9d', avatar: 'M', note: 'in the zone 🎨', status: 'online', self: true },
|
||||
{ name: 'ren', color: '#10b981', avatar: 'R', status: 'busy' },
|
||||
{ name: 'kai', color: '#f43f5e', avatar: 'K', status: 'away' },
|
||||
{ name: 'ezra', color: '#3b82f6', avatar: 'E', note: 'debugging', status: 'online' },
|
||||
],
|
||||
offline: [
|
||||
{ name: 'lo', color: '#f59e0b', avatar: 'L', status: 'offline' },
|
||||
{ name: 'theo', color: '#64748b', avatar: 'T', status: 'offline' },
|
||||
{ name: 'nia', color: '#84cc16', avatar: 'N', status: 'offline' },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
emojiCategories: [
|
||||
{
|
||||
key: 'recent', icon: '🕒', title: 'Frequently used',
|
||||
emojis: ['🔥', '💛', '🥹', '👀', '🙏', '✨', '🌿', '🫠']
|
||||
},
|
||||
{
|
||||
key: 'smileys', icon: '😀', title: 'Smileys & People',
|
||||
emojis: ['😀','😃','😄','😁','😆','😅','🤣','😂','🙂','🙃','😉','😊','😇','🥰','😍','🤩','😘','😗','🥲','☺️','😚','😙','🥲','😋','😛','😜','🤪','😝','🤑','🤗','🫡','🤫','🤔','🫢','🫠','🤐','🤨','😐','😑','😶']
|
||||
},
|
||||
{
|
||||
key: 'nature', icon: '🌿', title: 'Nature',
|
||||
emojis: ['🌿','🌱','🌵','🌴','🌳','🌲','🍀','🌾','🌷','🌸','🌼','🌻','🌺','🌹','🥀','💐','🌞','🌝','🌚','🌛','🌜','⭐','🌟','✨','💫','☄️','☀️']
|
||||
},
|
||||
{
|
||||
key: 'food', icon: '🥐', title: 'Food & Drink',
|
||||
emojis: ['🥐','🥖','🍞','🥨','🧀','🥚','🍳','🧈','🥞','🧇','🥓','🥩','🍗','🍖','🌭','🍔','🍟','🍕','🥪','🥙','🧆','🌮','🌯','🥗','🥘','🫕']
|
||||
},
|
||||
{
|
||||
key: 'activity', icon: '🎨', title: 'Activity',
|
||||
emojis: ['🎨','🖌️','🖊️','✏️','📝','📚','📖','🎭','🎬','🎤','🎧','🎼','🎹','🥁','🎸','🎺','🎻','🎮','🕹️','🎲','🧩']
|
||||
},
|
||||
{
|
||||
key: 'symbols', icon: '💛', title: 'Symbols',
|
||||
emojis: ['❤️','🧡','💛','💚','💙','💜','🖤','🤍','🤎','💔','❣️','💕','💞','💓','💗','💖','💘','💝']
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
# Pyramid · Media Players (Flutter)
|
||||
|
||||
Player-Skelette im Pyramid-Stil — **Audio (4 Varianten)** und **Video (3 Varianten)** als wiederverwendbare Flutter-Widgets. Dies ist ein **UI-Gerüst ohne echte Medienwiedergabe** — `position`, `duration`, `buffered`, `playing` sind reine Props. Zum Anschluss an echte Medien siehe „Mit echtem Playback verbinden" weiter unten.
|
||||
|
||||
---
|
||||
|
||||
## 📁 Dateien
|
||||
|
||||
```
|
||||
flutter_export/
|
||||
├── pubspec.yaml
|
||||
└── lib/
|
||||
├── pyramid_theme.dart ← Farb-, Typo-, Radius-Tokens (Single Source of Truth)
|
||||
├── pyramid_audio_player.dart ← PyramidAudioPlayer + AudioPlayerVariant
|
||||
├── pyramid_video_player.dart ← PyramidVideoPlayer + VideoPlayerVariant
|
||||
└── example_app.dart ← Showcase mit allen Varianten (zum Reinkopieren)
|
||||
```
|
||||
|
||||
Kopiere die drei `pyramid_*.dart` Dateien in dein Projekt (z. B. `lib/widgets/players/`) und importiere sie wie üblich.
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Theme
|
||||
|
||||
Alle Player ziehen Farben/Typo aus `PyramidTheme`. Default ist **Dark + Amber**.
|
||||
|
||||
```dart
|
||||
PyramidTheme.dark = true; // false → Light
|
||||
PyramidTheme.setAccent(42, 95, 58); // HSL — Default Pyramid Amber
|
||||
// Weitere Akzente: 280/70/60 (Violet), 160/65/50 (Mint), 0/75/60 (Coral)
|
||||
```
|
||||
|
||||
**Fonts:** Theme erwartet `Geist` und `GeistMono`. Ohne Asset-Einbindung in `pubspec.yaml` fällt Flutter auf System-Sans/Mono zurück (immer noch ok). Die Font-Asset-Konfig ist in `pubspec.yaml` auskommentiert vorbereitet.
|
||||
|
||||
---
|
||||
|
||||
## 🎧 Audio Player
|
||||
|
||||
```dart
|
||||
PyramidAudioPlayer(
|
||||
variant: AudioPlayerVariant.gracile,
|
||||
title: 'test track',
|
||||
artist: 'pyramid · demo',
|
||||
duration: const Duration(minutes: 3, seconds: 24),
|
||||
position: _position, // ValueNotifier oder State
|
||||
playing: _playing,
|
||||
onTogglePlay: () => setState(() => _playing = !_playing),
|
||||
onSeek: (Duration to) => setState(() => _position = to),
|
||||
onSkipBack: () { /* ‒15s */ },
|
||||
onSkipForward: () { /* +15s */ },
|
||||
onSpeedChange: (double s) { /* 1.0 / 1.5 / 2.0 */ },
|
||||
)
|
||||
```
|
||||
|
||||
### Wann welche Variante?
|
||||
|
||||
| Variante | Wann benutzen | Größe |
|
||||
|---|---|---|
|
||||
| **`AudioPlayerVariant.defaultBars`** | Standard-Audio-Anhang in einer Nachricht. Robuste Bar-Waveform — wirkt „solide", für Musik-Snippets. | ≥ 380px breit |
|
||||
| **`AudioPlayerVariant.gracile`** ⭐ | **Empfehlung für Voice-Notes und längere Audio-Dateien.** Filigrane Hairline-Waveform — passt am besten zum Pyramid-Stil. | ≥ 380px breit |
|
||||
| **`AudioPlayerVariant.compact`** | **In-Chat Inline-Embed**, wenn Platz knapp ist (Reply-Quote, Thread-Vorschau, Side-Panel). Eine Zeile. | ≥ 320px breit |
|
||||
| **`AudioPlayerVariant.mono`** | Spezielle Räume mit Terminal-/Editorial-Identität (z. B. `#announce`, „archive", Devs-Spaces). Mono-Font, harte Kanten. Sparsam einsetzen. | ≥ 380px breit |
|
||||
|
||||
---
|
||||
|
||||
## 🎬 Video Player
|
||||
|
||||
```dart
|
||||
PyramidVideoPlayer(
|
||||
variant: VideoPlayerVariant.defaultHover,
|
||||
title: 'test clip',
|
||||
subtitle: 'pyramid · 1080p',
|
||||
duration: const Duration(minutes: 4, seconds: 12),
|
||||
position: _videoPos,
|
||||
buffered: 0.65, // 0..1
|
||||
playing: _playing,
|
||||
onTogglePlay: () => setState(() => _playing = !_playing),
|
||||
onSeek: (Duration to) => setState(() => _videoPos = to),
|
||||
onSpeedChange: (double s) { /* 0.5 / 1 / 1.25 / 1.5 / 2 */ },
|
||||
onQualityChange: (String q) { /* Auto / 1080p / 720p / 480p / 360p */ },
|
||||
onFullscreen: () { /* Navigator push fullscreen route */ },
|
||||
)
|
||||
```
|
||||
|
||||
### Wann welche Variante?
|
||||
|
||||
| Variante | Wann benutzen | Aspect | Chrome |
|
||||
|---|---|---|---|
|
||||
| **`VideoPlayerVariant.defaultHover`** ⭐ | **Standard-Video-Embed im Chat.** Player nimmt volle Breite ein, Controls erscheinen bei Hover/Tap. | 16:10 | hover/auto-hide |
|
||||
| **`VideoPlayerVariant.minimal`** | **Inline-Vorschau** in dichten Layouts (Reply, Thread-Liste). Controls immer sichtbar, kompakter. | 16:9 | always-on |
|
||||
| **`VideoPlayerVariant.framed`** | **Galerie-/Karten-Layouts** oder Posts mit Caption darunter. Stage in Card, Controls auf hellem Card-BG außerhalb des schwarzen Stages. | 16:9 + Card | always-on (Card) |
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Mit echtem Playback verbinden
|
||||
|
||||
Diese Widgets sind reine UI-Mockups. Für echte Wiedergabe binde z. B. das offizielle [`video_player`](https://pub.dev/packages/video_player) (Video) oder [`just_audio`](https://pub.dev/packages/just_audio) (Audio) Package an:
|
||||
|
||||
```dart
|
||||
// Video-Beispiel mit video_player
|
||||
final controller = VideoPlayerController.networkUrl(Uri.parse(url))..initialize();
|
||||
controller.addListener(() => setState(() {}));
|
||||
|
||||
// im build():
|
||||
PyramidVideoPlayer(
|
||||
variant: VideoPlayerVariant.defaultHover,
|
||||
title: 'remote clip',
|
||||
duration: controller.value.duration,
|
||||
position: controller.value.position,
|
||||
buffered: controller.value.buffered.isEmpty ? 0
|
||||
: controller.value.buffered.last.end.inMilliseconds /
|
||||
controller.value.duration.inMilliseconds,
|
||||
playing: controller.value.isPlaying,
|
||||
onTogglePlay: () => controller.value.isPlaying ? controller.pause() : controller.play(),
|
||||
onSeek: (to) => controller.seekTo(to),
|
||||
onSpeedChange: (s) => controller.setPlaybackSpeed(s),
|
||||
)
|
||||
```
|
||||
|
||||
**Stage rendern:** Aktuell zeigt der Player einen Pyramid-Triangle als Platzhalter. Ersetze in `pyramid_video_player.dart` die `_stage()`-Methode durch dein `VideoPlayer(controller)` Widget.
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Showcase
|
||||
|
||||
`example_app.dart` zeigt alle 7 Player-Varianten in einem scrollbaren Dark-Layout. Direkt ausführbar via `flutter run -t lib/example_app.dart`.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checklist beim Einbau
|
||||
|
||||
- [ ] `pyramid_theme.dart` + Audio + Video kopiert
|
||||
- [ ] `PyramidTheme.dark` und `setAccent(...)` in App-State gesetzt
|
||||
- [ ] (optional) Geist + Geist Mono in `pubspec.yaml` eingebunden
|
||||
- [ ] Player mit echtem Audio-/Video-Controller verdrahtet
|
||||
- [ ] Skip-Intervall an Produkt-Konvention angepasst (Material hat `replay_10` / `forward_10` — wenn du wirklich ±15s willst, die Icons mit `Stack(text)` ersetzen)
|
||||
@@ -0,0 +1,159 @@
|
||||
// example_app.dart
|
||||
// Pyramid Players · Showcase / Test-Harness
|
||||
//
|
||||
// Zeigt alle Player-Varianten in einem scrollbaren Dark-Layout.
|
||||
// Drop-in-fähig: füge die drei Pyramid-Dateien (theme + audio + video)
|
||||
// in dein Projekt und referenziere sie wie unten.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'pyramid_theme.dart';
|
||||
import 'pyramid_audio_player.dart';
|
||||
import 'pyramid_video_player.dart';
|
||||
|
||||
void main() => runApp(const PyramidShowcaseApp());
|
||||
|
||||
class PyramidShowcaseApp extends StatelessWidget {
|
||||
const PyramidShowcaseApp({super.key});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
title: 'Pyramid · Players',
|
||||
theme: ThemeData(
|
||||
scaffoldBackgroundColor: PyramidTheme.bg0,
|
||||
fontFamily: PyramidTheme.fontSans,
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: const _Showcase(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Showcase extends StatefulWidget {
|
||||
const _Showcase();
|
||||
@override
|
||||
State<_Showcase> createState() => _ShowcaseState();
|
||||
}
|
||||
|
||||
class _ShowcaseState extends State<_Showcase> {
|
||||
bool _playing = true;
|
||||
Duration _audioPos = const Duration(minutes: 1, seconds: 11);
|
||||
final _audioDur = const Duration(minutes: 3, seconds: 24);
|
||||
Duration _videoPos = const Duration(minutes: 1, seconds: 28);
|
||||
final _videoDur = const Duration(minutes: 4, seconds: 12);
|
||||
|
||||
void _toggle() => setState(() => _playing = !_playing);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(48, 48, 48, 120),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text('PYRAMID · MEDIA PLAYERS',
|
||||
style: TextStyle(fontFamily: PyramidTheme.fontMono, fontSize: 10, letterSpacing: 1.4, color: PyramidTheme.fgDim)),
|
||||
const SizedBox(height: 6),
|
||||
Text('Audio + Video — Flutter', style: TextStyle(color: PyramidTheme.fg, fontSize: 28, fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 40),
|
||||
|
||||
_section('AUDIO'),
|
||||
_grid([
|
||||
_frame('01 · Default', 'chunky bars',
|
||||
PyramidAudioPlayer(
|
||||
variant: AudioPlayerVariant.defaultBars,
|
||||
title: 'test track', artist: 'pyramid · demo',
|
||||
duration: _audioDur, position: _audioPos,
|
||||
playing: _playing, onTogglePlay: _toggle,
|
||||
onSeek: (p) => setState(() => _audioPos = p),
|
||||
)),
|
||||
_frame('02 · Gracile', 'hairline wave',
|
||||
PyramidAudioPlayer(
|
||||
variant: AudioPlayerVariant.gracile,
|
||||
title: 'test track', artist: 'pyramid · demo',
|
||||
duration: _audioDur, position: _audioPos,
|
||||
playing: _playing, onTogglePlay: _toggle,
|
||||
onSeek: (p) => setState(() => _audioPos = p),
|
||||
)),
|
||||
_frame('03 · Compact', 'in-chat',
|
||||
PyramidAudioPlayer(
|
||||
variant: AudioPlayerVariant.compact,
|
||||
title: 'test track', artist: 'pyramid · demo',
|
||||
duration: _audioDur, position: _audioPos,
|
||||
playing: _playing, onTogglePlay: _toggle,
|
||||
onSeek: (p) => setState(() => _audioPos = p),
|
||||
)),
|
||||
_frame('04 · Mono', 'terminal',
|
||||
PyramidAudioPlayer(
|
||||
variant: AudioPlayerVariant.mono,
|
||||
title: 'test.wav', artist: 'pyramid · demo',
|
||||
duration: _audioDur, position: _audioPos,
|
||||
playing: _playing, onTogglePlay: _toggle,
|
||||
onSeek: (p) => setState(() => _audioPos = p),
|
||||
)),
|
||||
]),
|
||||
const SizedBox(height: 40),
|
||||
|
||||
_section('VIDEO'),
|
||||
_grid([
|
||||
_frame('01 · Default', 'hover-chrome',
|
||||
PyramidVideoPlayer(
|
||||
variant: VideoPlayerVariant.defaultHover,
|
||||
title: 'test clip', subtitle: 'pyramid · 1080p',
|
||||
duration: _videoDur, position: _videoPos, buffered: 0.65,
|
||||
playing: _playing, onTogglePlay: _toggle,
|
||||
onSeek: (p) => setState(() => _videoPos = p),
|
||||
)),
|
||||
_frame('02 · Minimal', 'always-visible',
|
||||
PyramidVideoPlayer(
|
||||
variant: VideoPlayerVariant.minimal,
|
||||
title: 'test clip', subtitle: 'pyramid · 1080p',
|
||||
duration: _videoDur, position: _videoPos, buffered: 0.65,
|
||||
playing: _playing, onTogglePlay: _toggle,
|
||||
onSeek: (p) => setState(() => _videoPos = p),
|
||||
)),
|
||||
_frame('03 · Framed', 'editorial',
|
||||
PyramidVideoPlayer(
|
||||
variant: VideoPlayerVariant.framed,
|
||||
title: 'test clip', subtitle: 'pyramid · 1080p',
|
||||
duration: _videoDur, position: _videoPos, buffered: 0.65,
|
||||
playing: _playing, onTogglePlay: _toggle,
|
||||
onSeek: (p) => setState(() => _videoPos = p),
|
||||
)),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _section(String label) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 20),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: PyramidTheme.border))),
|
||||
child: Text(label,
|
||||
style: TextStyle(fontFamily: PyramidTheme.fontMono, fontSize: 10, letterSpacing: 1.8, color: PyramidTheme.fgDim)),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _frame(String label, String sub, Widget child) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
|
||||
Text(label, style: TextStyle(fontFamily: PyramidTheme.fontMono, fontSize: 10, letterSpacing: 1.4, color: PyramidTheme.fgMuted)),
|
||||
Text(sub, style: TextStyle(fontFamily: PyramidTheme.fontMono, fontSize: 10, letterSpacing: 1.4, color: PyramidTheme.fgDim)),
|
||||
]),
|
||||
),
|
||||
child,
|
||||
],
|
||||
);
|
||||
|
||||
Widget _grid(List<Widget> items) => Wrap(
|
||||
spacing: 28, runSpacing: 28,
|
||||
children: items.map((i) => SizedBox(width: 420, child: i)).toList(),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
// pyramid_audio_player.dart
|
||||
// Pyramid · Audio Player
|
||||
//
|
||||
// Vier Varianten:
|
||||
// AudioPlayerVariant.defaultBars → Karte mit chunky-bar Waveform (wie HTML "01 · Default")
|
||||
// AudioPlayerVariant.gracile → Karte mit hairline-Waveform (graziler, schicker)
|
||||
// AudioPlayerVariant.compact → Eine Zeile, für In-Chat (Voice-Note Stil)
|
||||
// AudioPlayerVariant.mono → Terminal-Look, Mono-Font, harte Kanten
|
||||
//
|
||||
// Verwendung:
|
||||
// PyramidAudioPlayer(
|
||||
// variant: AudioPlayerVariant.gracile,
|
||||
// title: 'test track',
|
||||
// artist: 'pyramid · demo',
|
||||
// duration: Duration(minutes: 3, seconds: 24),
|
||||
// position: Duration(minutes: 1, seconds: 11),
|
||||
// playing: true,
|
||||
// onTogglePlay: () { ... },
|
||||
// onSeek: (Duration to) { ... },
|
||||
// );
|
||||
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'pyramid_theme.dart';
|
||||
|
||||
enum AudioPlayerVariant { defaultBars, gracile, compact, mono }
|
||||
|
||||
class PyramidAudioPlayer extends StatefulWidget {
|
||||
final AudioPlayerVariant variant;
|
||||
final String title;
|
||||
final String artist;
|
||||
final Duration duration;
|
||||
final Duration position;
|
||||
final bool playing;
|
||||
final double volume; // 0..1
|
||||
final VoidCallback? onTogglePlay;
|
||||
final ValueChanged<Duration>? onSeek;
|
||||
final ValueChanged<double>? onVolumeChange;
|
||||
final ValueChanged<double>? onSpeedChange; // 1.0, 1.5, 2.0
|
||||
final VoidCallback? onSkipBack;
|
||||
final VoidCallback? onSkipForward;
|
||||
final VoidCallback? onLike;
|
||||
final VoidCallback? onMore;
|
||||
|
||||
const PyramidAudioPlayer({
|
||||
super.key,
|
||||
this.variant = AudioPlayerVariant.gracile,
|
||||
required this.title,
|
||||
required this.artist,
|
||||
required this.duration,
|
||||
required this.position,
|
||||
this.playing = false,
|
||||
this.volume = 0.7,
|
||||
this.onTogglePlay,
|
||||
this.onSeek,
|
||||
this.onVolumeChange,
|
||||
this.onSpeedChange,
|
||||
this.onSkipBack,
|
||||
this.onSkipForward,
|
||||
this.onLike,
|
||||
this.onMore,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PyramidAudioPlayer> createState() => _PyramidAudioPlayerState();
|
||||
}
|
||||
|
||||
class _PyramidAudioPlayerState extends State<PyramidAudioPlayer> {
|
||||
double speed = 1.0;
|
||||
static const speeds = [1.0, 1.5, 2.0];
|
||||
|
||||
double get progress => widget.duration.inMilliseconds == 0
|
||||
? 0
|
||||
: widget.position.inMilliseconds / widget.duration.inMilliseconds;
|
||||
|
||||
String _fmt(Duration d) {
|
||||
final m = d.inMinutes;
|
||||
final s = (d.inSeconds % 60).toString().padLeft(2, '0');
|
||||
return '$m:$s';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.variant == AudioPlayerVariant.compact) return _buildCompact();
|
||||
return _buildCard();
|
||||
}
|
||||
|
||||
// ─── Compact (in-chat) ─────────────────────────────────────────────
|
||||
Widget _buildCompact() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 14, 12),
|
||||
decoration: BoxDecoration(
|
||||
color: PyramidTheme.bg1,
|
||||
border: Border.all(color: PyramidTheme.border),
|
||||
borderRadius: BorderRadius.circular(PyramidTheme.rLg),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_Artwork(size: 36),
|
||||
const SizedBox(width: 10),
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(widget.title, style: PyramidTheme.title.copyWith(fontSize: 13), overflow: TextOverflow.ellipsis),
|
||||
Text(widget.artist, style: PyramidTheme.subtitle.copyWith(fontSize: 11), overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_PlayButton(playing: widget.playing, onTap: widget.onTogglePlay, size: 32),
|
||||
const SizedBox(width: 12),
|
||||
Text(_fmt(widget.position), style: PyramidTheme.mono),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: _Scrubber(progress: progress, onSeek: _seekFromFraction)),
|
||||
const SizedBox(width: 8),
|
||||
Text(_fmt(widget.duration), style: PyramidTheme.mono),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Default / Gracile / Mono ──────────────────────────────────────
|
||||
Widget _buildCard() {
|
||||
final isMono = widget.variant == AudioPlayerVariant.mono;
|
||||
final radius = isMono ? 0.0 : PyramidTheme.rLg;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 18),
|
||||
decoration: BoxDecoration(
|
||||
color: isMono ? PyramidTheme.bg0 : PyramidTheme.bg1,
|
||||
border: Border.all(color: isMono ? PyramidTheme.borderStrong : PyramidTheme.border),
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Head
|
||||
Row(
|
||||
children: [
|
||||
_Artwork(size: 48, mono: isMono),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
isMono ? widget.title.toUpperCase() : widget.title,
|
||||
style: isMono
|
||||
? PyramidTheme.monoStrong.copyWith(fontSize: 12, color: PyramidTheme.fg, letterSpacing: 0.3)
|
||||
: PyramidTheme.title,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
isMono ? widget.artist.toUpperCase() : widget.artist,
|
||||
style: isMono
|
||||
? PyramidTheme.mono.copyWith(fontSize: 10, letterSpacing: 0.8)
|
||||
: PyramidTheme.subtitle,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_IconBtn(icon: Icons.favorite_border, onTap: widget.onLike),
|
||||
_IconBtn(icon: Icons.more_horiz, onTap: widget.onMore),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// Waveform
|
||||
GestureDetector(
|
||||
onTapDown: (d) => _seekFromTap(d.localPosition.dx, context.size?.width ?? 1),
|
||||
child: SizedBox(
|
||||
height: widget.variant == AudioPlayerVariant.gracile ? 44 : 40,
|
||||
child: widget.variant == AudioPlayerVariant.gracile
|
||||
? CustomPaint(painter: _GracileWavePainter(progress: progress), size: Size.infinite)
|
||||
: CustomPaint(painter: _ChunkyBarsPainter(progress: progress), size: Size.infinite),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Times
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(_fmt(widget.position), style: PyramidTheme.mono),
|
||||
Text('−${_fmt(widget.duration - widget.position)}', style: PyramidTheme.mono),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// Controls row
|
||||
Row(
|
||||
children: [
|
||||
_IconBtn(icon: Icons.volume_up_outlined, onTap: null),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 72,
|
||||
child: _MiniBar(value: widget.volume),
|
||||
),
|
||||
const Spacer(),
|
||||
_IconBtn(icon: Icons.replay_10, onTap: widget.onSkipBack), // ≈ −15 (Material has 10/30; close enough)
|
||||
const SizedBox(width: 4),
|
||||
_PlayButton(playing: widget.playing, onTap: widget.onTogglePlay, size: 40, mono: isMono),
|
||||
const SizedBox(width: 4),
|
||||
_IconBtn(icon: Icons.forward_10, onTap: widget.onSkipForward),
|
||||
const Spacer(),
|
||||
...speeds.map((s) => _SpeedPill(
|
||||
label: '${s == s.toInt() ? s.toInt() : s}x',
|
||||
active: speed == s,
|
||||
mono: isMono,
|
||||
onTap: () {
|
||||
setState(() => speed = s);
|
||||
widget.onSpeedChange?.call(s);
|
||||
},
|
||||
)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _seekFromFraction(double f) =>
|
||||
widget.onSeek?.call(Duration(milliseconds: (widget.duration.inMilliseconds * f).round()));
|
||||
|
||||
void _seekFromTap(double x, double w) {
|
||||
if (w <= 0) return;
|
||||
_seekFromFraction((x / w).clamp(0.0, 1.0));
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────── Building blocks ─────────────────────────
|
||||
|
||||
class _Artwork extends StatelessWidget {
|
||||
final double size;
|
||||
final bool mono;
|
||||
const _Artwork({required this.size, this.mono = false});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(mono ? 0 : PyramidTheme.rSm),
|
||||
gradient: mono
|
||||
? null
|
||||
: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
HSLColor.fromAHSL(1, PyramidTheme.accentH, PyramidTheme.accentS / 100, (PyramidTheme.accentL - 10) / 100).toColor(),
|
||||
HSLColor.fromAHSL(1, PyramidTheme.accentH + 30, PyramidTheme.accentS / 100, (PyramidTheme.accentL - 20) / 100).toColor(),
|
||||
],
|
||||
),
|
||||
color: mono ? PyramidTheme.accent : null,
|
||||
),
|
||||
child: Center(
|
||||
child: CustomPaint(
|
||||
size: Size(size * 0.45, size * 0.42),
|
||||
painter: _TrianglePainter(color: mono ? PyramidTheme.accentFg : Colors.white.withOpacity(0.9)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TrianglePainter extends CustomPainter {
|
||||
final Color color;
|
||||
_TrianglePainter({required this.color});
|
||||
@override
|
||||
void paint(Canvas canvas, Size s) {
|
||||
final p = Paint()..color = color;
|
||||
final path = Path()
|
||||
..moveTo(s.width / 2, 0)
|
||||
..lineTo(s.width, s.height)
|
||||
..lineTo(0, s.height)
|
||||
..close();
|
||||
canvas.drawPath(path, p);
|
||||
}
|
||||
@override
|
||||
bool shouldRepaint(_) => false;
|
||||
}
|
||||
|
||||
class _PlayButton extends StatelessWidget {
|
||||
final bool playing;
|
||||
final VoidCallback? onTap;
|
||||
final double size;
|
||||
final bool mono;
|
||||
const _PlayButton({required this.playing, this.onTap, this.size = 40, this.mono = false});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: PyramidTheme.accent,
|
||||
shape: mono ? BoxShape.rectangle : BoxShape.circle,
|
||||
boxShadow: [BoxShadow(color: PyramidTheme.accentGlow, blurRadius: 18, offset: const Offset(0, 4))],
|
||||
),
|
||||
child: Icon(playing ? Icons.pause : Icons.play_arrow, color: PyramidTheme.accentFg, size: size * 0.45),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _IconBtn extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final VoidCallback? onTap;
|
||||
const _IconBtn({required this.icon, this.onTap});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(PyramidTheme.rSm),
|
||||
child: Container(
|
||||
width: 32, height: 32,
|
||||
alignment: Alignment.center,
|
||||
child: Icon(icon, size: 18, color: PyramidTheme.fgMuted),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SpeedPill extends StatelessWidget {
|
||||
final String label;
|
||||
final bool active;
|
||||
final bool mono;
|
||||
final VoidCallback? onTap;
|
||||
const _SpeedPill({required this.label, this.active = false, this.mono = false, this.onTap});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 1),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? PyramidTheme.accentSoft : PyramidTheme.bg2,
|
||||
borderRadius: BorderRadius.circular(mono ? 0 : PyramidTheme.rSm),
|
||||
border: Border.all(color: active ? PyramidTheme.accentSoft : Colors.transparent),
|
||||
),
|
||||
child: Text(label, style: TextStyle(
|
||||
fontFamily: PyramidTheme.fontMono, fontSize: 11, fontWeight: FontWeight.w500,
|
||||
color: active ? PyramidTheme.accent : PyramidTheme.fgMuted,
|
||||
letterSpacing: 0.2,
|
||||
)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Scrubber extends StatelessWidget {
|
||||
final double progress; // 0..1
|
||||
final ValueChanged<double>? onSeek;
|
||||
const _Scrubber({required this.progress, this.onSeek});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(builder: (_, c) {
|
||||
return GestureDetector(
|
||||
onTapDown: (d) => onSeek?.call((d.localPosition.dx / c.maxWidth).clamp(0.0, 1.0)),
|
||||
onPanUpdate: (d) => onSeek?.call(((d.localPosition.dx) / c.maxWidth).clamp(0.0, 1.0)),
|
||||
child: SizedBox(
|
||||
height: 14,
|
||||
child: Stack(alignment: Alignment.centerLeft, children: [
|
||||
Container(height: 3, decoration: BoxDecoration(color: PyramidTheme.bg3, borderRadius: BorderRadius.circular(2))),
|
||||
FractionallySizedBox(
|
||||
widthFactor: progress,
|
||||
child: Container(height: 3, decoration: BoxDecoration(color: PyramidTheme.accent, borderRadius: BorderRadius.circular(2))),
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _MiniBar extends StatelessWidget {
|
||||
final double value;
|
||||
const _MiniBar({required this.value});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(alignment: Alignment.centerLeft, children: [
|
||||
Container(height: 3, decoration: BoxDecoration(color: PyramidTheme.bg3, borderRadius: BorderRadius.circular(2))),
|
||||
FractionallySizedBox(
|
||||
widthFactor: value,
|
||||
child: Container(height: 3, decoration: BoxDecoration(color: PyramidTheme.fgMuted, borderRadius: BorderRadius.circular(2))),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────── Wave painters ─────────────────────────
|
||||
|
||||
class _ChunkyBarsPainter extends CustomPainter {
|
||||
final double progress;
|
||||
static const int count = 56;
|
||||
static final List<double> _heights = _seed();
|
||||
static List<double> _seed() {
|
||||
final r = math.Random(7);
|
||||
return List.generate(count, (i) {
|
||||
final env = math.sin((i / count) * math.pi) * 0.6 + 0.4;
|
||||
return 0.2 + r.nextDouble() * 0.8 * env;
|
||||
});
|
||||
}
|
||||
|
||||
_ChunkyBarsPainter({required this.progress});
|
||||
@override
|
||||
void paint(Canvas canvas, Size s) {
|
||||
final gap = 2.0;
|
||||
final barW = (s.width - gap * (count - 1)) / count;
|
||||
final cursor = (progress * count).floor();
|
||||
for (int i = 0; i < count; i++) {
|
||||
final h = _heights[i] * s.height;
|
||||
final x = i * (barW + gap);
|
||||
final y = (s.height - h) / 2;
|
||||
final played = i < cursor;
|
||||
final isCursor = i == cursor;
|
||||
final paint = Paint()
|
||||
..color = isCursor
|
||||
? PyramidTheme.fg
|
||||
: played
|
||||
? PyramidTheme.accent
|
||||
: PyramidTheme.bg3;
|
||||
final r = RRect.fromRectAndRadius(Rect.fromLTWH(x, y, barW, h), const Radius.circular(1));
|
||||
canvas.drawRRect(r, paint);
|
||||
}
|
||||
}
|
||||
@override
|
||||
bool shouldRepaint(_ChunkyBarsPainter old) => old.progress != progress;
|
||||
}
|
||||
|
||||
class _GracileWavePainter extends CustomPainter {
|
||||
final double progress;
|
||||
static const int count = 72;
|
||||
static final List<double> _heights = _seed();
|
||||
static List<double> _seed() {
|
||||
final r = math.Random(11);
|
||||
return List.generate(count, (i) {
|
||||
final env = math.sin((i / count) * math.pi) * 0.55 + 0.45;
|
||||
return 0.18 + r.nextDouble() * 0.82 * env;
|
||||
});
|
||||
}
|
||||
|
||||
_GracileWavePainter({required this.progress});
|
||||
@override
|
||||
void paint(Canvas canvas, Size s) {
|
||||
final cy = s.height / 2;
|
||||
// Baseline
|
||||
canvas.drawLine(
|
||||
Offset(0, cy), Offset(s.width, cy),
|
||||
Paint()..color = PyramidTheme.border..strokeWidth = 0.6,
|
||||
);
|
||||
final step = s.width / (count - 1);
|
||||
for (int i = 0; i < count; i++) {
|
||||
final x = i * step;
|
||||
final h = _heights[i] * s.height * 0.88;
|
||||
final played = (x / s.width) <= progress;
|
||||
final p = Paint()
|
||||
..color = (played ? PyramidTheme.accent : PyramidTheme.fgDim).withOpacity(played ? 0.95 : 0.42)
|
||||
..strokeWidth = 1.0
|
||||
..strokeCap = StrokeCap.round;
|
||||
canvas.drawLine(Offset(x, cy - h / 2), Offset(x, cy + h / 2), p);
|
||||
}
|
||||
// Playhead
|
||||
final cx = progress * s.width;
|
||||
canvas.drawLine(Offset(cx, 4), Offset(cx, s.height - 4),
|
||||
Paint()..color = PyramidTheme.fg..strokeWidth = 1.2);
|
||||
canvas.drawCircle(Offset(cx, cy), 2.4, Paint()..color = PyramidTheme.accent);
|
||||
}
|
||||
@override
|
||||
bool shouldRepaint(_GracileWavePainter old) => old.progress != progress;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// pyramid_theme.dart
|
||||
// Pyramid · Design Tokens für Flutter
|
||||
//
|
||||
// Zentrale Farb-, Typo- und Radius-Konstanten. Alle Player ziehen sich hier raus.
|
||||
// Dark-first; Light-Modus umschaltbar via PyramidTheme.dark = false.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class PyramidTheme {
|
||||
PyramidTheme._();
|
||||
|
||||
/// Dark mode toggle. Beim Umschalten alle Player rebuilden.
|
||||
static bool dark = true;
|
||||
|
||||
// ─── Accent (HSL → Color) ─────────────────────────────────────────────
|
||||
// Default: Amber. Per setAccent(h,s,l) änderbar.
|
||||
static double accentH = 42;
|
||||
static double accentS = 95;
|
||||
static double accentL = 58;
|
||||
|
||||
static Color get accent => HSLColor.fromAHSL(1, accentH, accentS / 100, accentL / 100).toColor();
|
||||
static Color get accentSoft => HSLColor.fromAHSL(0.14, accentH, accentS / 100, accentL / 100).toColor();
|
||||
static Color get accentGlow => HSLColor.fromAHSL(0.35, accentH, accentS / 100, accentL / 100).toColor();
|
||||
static const Color accentFg = Color(0xFF0B0B0D);
|
||||
|
||||
static void setAccent(double h, double s, double l) {
|
||||
accentH = h; accentS = s; accentL = l;
|
||||
}
|
||||
|
||||
// ─── Backgrounds & Foregrounds ────────────────────────────────────────
|
||||
static Color get bg0 => dark ? const Color(0xFF0A0A0C) : const Color(0xFFF5F4F0);
|
||||
static Color get bg1 => dark ? const Color(0xFF111114) : const Color(0xFFFFFFFF);
|
||||
static Color get bg2 => dark ? const Color(0xFF17171C) : const Color(0xFFFAF9F5);
|
||||
static Color get bg3 => dark ? const Color(0xFF1E1E25) : const Color(0xFFF0EEEA);
|
||||
static Color get bgHover => dark ? const Color(0xFF232330) : const Color(0xFFE9E7E1);
|
||||
static Color get bgActive => dark ? const Color(0xFF2A2A38) : const Color(0xFFDFDCD3);
|
||||
|
||||
static Color get border => dark ? const Color(0xFF25252E) : const Color(0xFFE5E2DC);
|
||||
static Color get borderStrong => dark ? const Color(0xFF32323D) : const Color(0xFFD3CFC6);
|
||||
|
||||
static Color get fg => dark ? const Color(0xFFECECF0) : const Color(0xFF1A1A1F);
|
||||
static Color get fgMuted => dark ? const Color(0xFFA4A4B0) : const Color(0xFF54545E);
|
||||
static Color get fgDim => dark ? const Color(0xFF6F6F7D) : const Color(0xFF8A8A92);
|
||||
|
||||
// ─── Radii ────────────────────────────────────────────────────────────
|
||||
static const double rBase = 12;
|
||||
static const double rSm = 7.2; // base * 0.6
|
||||
static const double rLg = 18; // base * 1.5
|
||||
static const double rXl = 24; // base * 2
|
||||
static const double rPill = 999;
|
||||
|
||||
// ─── Typography ───────────────────────────────────────────────────────
|
||||
// Erfordert: Geist + Geist Mono in pubspec.yaml als fonts.
|
||||
// (Alternativ: durch ein anderes Sans/Mono-Paar ersetzen.)
|
||||
static const String fontSans = 'Geist';
|
||||
static const String fontMono = 'GeistMono';
|
||||
|
||||
static TextStyle get title => TextStyle(
|
||||
fontFamily: fontSans, fontSize: 14, fontWeight: FontWeight.w500, color: fg, letterSpacing: -0.1,
|
||||
);
|
||||
static TextStyle get subtitle => TextStyle(
|
||||
fontFamily: fontSans, fontSize: 12, color: fgMuted,
|
||||
);
|
||||
static TextStyle get mono => TextStyle(
|
||||
fontFamily: fontMono, fontSize: 11, color: fgDim, letterSpacing: 0.4,
|
||||
);
|
||||
static TextStyle get monoStrong => TextStyle(
|
||||
fontFamily: fontMono, fontSize: 11, color: fg, fontWeight: FontWeight.w500, letterSpacing: 0.4,
|
||||
);
|
||||
|
||||
// ─── Shadows ──────────────────────────────────────────────────────────
|
||||
static List<BoxShadow> get shadowLg => [
|
||||
BoxShadow(color: Colors.black.withOpacity(dark ? 0.6 : 0.12), blurRadius: 40, offset: const Offset(0, 12)),
|
||||
BoxShadow(color: Colors.black.withOpacity(dark ? 0.4 : 0.06), blurRadius: 8, offset: const Offset(0, 2)),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
// pyramid_video_player.dart
|
||||
// Pyramid · Video Player
|
||||
//
|
||||
// Drei Varianten:
|
||||
// VideoPlayerVariant.defaultHover → Chrome erscheint nur bei Hover/Tap (für Vollbild-Embeds)
|
||||
// VideoPlayerVariant.minimal → Chrome immer sichtbar (für kleine Inline-Embeds)
|
||||
// VideoPlayerVariant.framed → Editorial: Stage in Card, Controls außerhalb (für Galerie-Karten)
|
||||
//
|
||||
// Hinweis: Dies ist ein UI-Mockup. Für echte Wiedergabe diesen Widget mit
|
||||
// `video_player`-Package verbinden (controller.value.position etc. an `position` mappen).
|
||||
//
|
||||
// Verwendung:
|
||||
// PyramidVideoPlayer(
|
||||
// variant: VideoPlayerVariant.defaultHover,
|
||||
// title: 'test clip',
|
||||
// subtitle: 'pyramid · 1080p',
|
||||
// duration: Duration(minutes: 4, seconds: 12),
|
||||
// position: Duration(minutes: 1, seconds: 28),
|
||||
// buffered: 0.65,
|
||||
// playing: true,
|
||||
// onTogglePlay: () { ... },
|
||||
// onSeek: (Duration to) { ... },
|
||||
// onSpeedChange: (double speed) { ... },
|
||||
// onQualityChange: (String quality) { ... },
|
||||
// );
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'pyramid_theme.dart';
|
||||
|
||||
enum VideoPlayerVariant { defaultHover, minimal, framed }
|
||||
|
||||
class PyramidVideoPlayer extends StatefulWidget {
|
||||
final VideoPlayerVariant variant;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final Duration duration;
|
||||
final Duration position;
|
||||
final double buffered; // 0..1
|
||||
final bool playing;
|
||||
final double volume; // 0..1
|
||||
final VoidCallback? onTogglePlay;
|
||||
final ValueChanged<Duration>? onSeek;
|
||||
final ValueChanged<double>? onVolumeChange;
|
||||
final ValueChanged<double>? onSpeedChange;
|
||||
final ValueChanged<String>? onQualityChange;
|
||||
final VoidCallback? onSkipBack;
|
||||
final VoidCallback? onSkipForward;
|
||||
final VoidCallback? onFullscreen;
|
||||
final VoidCallback? onCaptions;
|
||||
final VoidCallback? onMore;
|
||||
|
||||
const PyramidVideoPlayer({
|
||||
super.key,
|
||||
this.variant = VideoPlayerVariant.defaultHover,
|
||||
required this.title,
|
||||
this.subtitle = '',
|
||||
required this.duration,
|
||||
required this.position,
|
||||
this.buffered = 0.0,
|
||||
this.playing = false,
|
||||
this.volume = 0.7,
|
||||
this.onTogglePlay,
|
||||
this.onSeek,
|
||||
this.onVolumeChange,
|
||||
this.onSpeedChange,
|
||||
this.onQualityChange,
|
||||
this.onSkipBack,
|
||||
this.onSkipForward,
|
||||
this.onFullscreen,
|
||||
this.onCaptions,
|
||||
this.onMore,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PyramidVideoPlayer> createState() => _PyramidVideoPlayerState();
|
||||
}
|
||||
|
||||
class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
|
||||
bool _hovered = false;
|
||||
String _quality = 'Auto';
|
||||
double _speed = 1.0;
|
||||
bool _settingsOpen = false;
|
||||
|
||||
static const _qualities = ['Auto', '1080p', '720p', '480p', '360p'];
|
||||
static const _speeds = [0.5, 1.0, 1.25, 1.5, 2.0];
|
||||
|
||||
double get progress => widget.duration.inMilliseconds == 0
|
||||
? 0
|
||||
: widget.position.inMilliseconds / widget.duration.inMilliseconds;
|
||||
|
||||
bool get _showChrome {
|
||||
if (widget.variant == VideoPlayerVariant.minimal) return true;
|
||||
if (widget.variant == VideoPlayerVariant.framed) return true;
|
||||
return _hovered || !widget.playing;
|
||||
}
|
||||
|
||||
String _fmt(Duration d) {
|
||||
final m = d.inMinutes;
|
||||
final s = (d.inSeconds % 60).toString().padLeft(2, '0');
|
||||
return '$m:$s';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.variant == VideoPlayerVariant.framed) return _buildFramed();
|
||||
return _buildOverlay();
|
||||
}
|
||||
|
||||
// ─── Default / Minimal ─────────────────────────────────────────────
|
||||
Widget _buildOverlay() {
|
||||
final aspect = widget.variant == VideoPlayerVariant.minimal ? 16 / 9 : 16 / 10;
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() { _hovered = false; _settingsOpen = false; }),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTogglePlay,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(PyramidTheme.rLg),
|
||||
child: AspectRatio(
|
||||
aspectRatio: aspect,
|
||||
child: Stack(fit: StackFit.expand, children: [
|
||||
_stage(),
|
||||
_grain(),
|
||||
if (!widget.playing) Center(child: _CenterPlay(onTap: widget.onTogglePlay)),
|
||||
AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
opacity: _showChrome ? 1 : 0,
|
||||
child: _topBar(onDark: true),
|
||||
),
|
||||
AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
opacity: _showChrome ? 1 : 0,
|
||||
child: Align(alignment: Alignment.bottomCenter, child: _bottomBar(onDark: true)),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Framed ────────────────────────────────────────────────────────
|
||||
Widget _buildFramed() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: PyramidTheme.bg1,
|
||||
border: Border.all(color: PyramidTheme.border),
|
||||
borderRadius: BorderRadius.circular(PyramidTheme.rLg),
|
||||
),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(PyramidTheme.rLg - 4),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTogglePlay,
|
||||
child: Stack(fit: StackFit.expand, children: [
|
||||
_stage(),
|
||||
_grain(),
|
||||
if (!widget.playing) Center(child: _CenterPlay(onTap: widget.onTogglePlay)),
|
||||
_topBar(onDark: true),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_bottomBar(onDark: false),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Stage (placeholder bg) ────────────────────────────────────────
|
||||
Widget _stage() {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft, end: Alignment.bottomRight,
|
||||
colors: [const Color(0xFF1A1A22), const Color(0xFF0C0C10)],
|
||||
),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Opacity(
|
||||
opacity: 0.35,
|
||||
child: CustomPaint(size: const Size(72, 60), painter: _BigTriPainter()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _grain() => IgnorePointer(child: Opacity(
|
||||
opacity: 0.05,
|
||||
child: Container(decoration: const BoxDecoration(color: Colors.white12)),
|
||||
));
|
||||
|
||||
// ─── Top bar (title + captions + more) ─────────────────────────────
|
||||
Widget _topBar({required bool onDark}) {
|
||||
final fg = onDark ? Colors.white : PyramidTheme.fg;
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
|
||||
decoration: BoxDecoration(
|
||||
gradient: onDark
|
||||
? LinearGradient(begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Colors.black.withOpacity(0.5), Colors.transparent])
|
||||
: null,
|
||||
),
|
||||
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
|
||||
Text(widget.title, style: TextStyle(color: fg, fontSize: 13, fontFamily: PyramidTheme.fontSans, fontWeight: FontWeight.w500)),
|
||||
if (widget.subtitle.isNotEmpty)
|
||||
Text(widget.subtitle.toUpperCase(), style: TextStyle(color: fg.withOpacity(0.65), fontSize: 10, fontFamily: PyramidTheme.fontMono, letterSpacing: 1.2)),
|
||||
])),
|
||||
_OverlayBtn(icon: Icons.closed_caption_outlined, onTap: widget.onCaptions, onDark: onDark),
|
||||
_OverlayBtn(icon: Icons.more_horiz, onTap: widget.onMore, onDark: onDark),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Bottom bar (scrubber + controls) ──────────────────────────────
|
||||
Widget _bottomBar({required bool onDark}) {
|
||||
final iconColor = onDark ? Colors.white.withOpacity(0.85) : PyramidTheme.fgMuted;
|
||||
final timeColor = onDark ? Colors.white.withOpacity(0.78) : PyramidTheme.fgDim;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
|
||||
decoration: BoxDecoration(
|
||||
gradient: onDark
|
||||
? LinearGradient(begin: Alignment.bottomCenter, end: Alignment.topCenter, colors: [Colors.black.withOpacity(0.75), Colors.transparent])
|
||||
: null,
|
||||
),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
_scrubber(onDark: onDark),
|
||||
const SizedBox(height: 10),
|
||||
Row(children: [
|
||||
// Left side
|
||||
_PlayMini(playing: widget.playing, onTap: widget.onTogglePlay, onDark: onDark),
|
||||
const SizedBox(width: 4),
|
||||
_OverlayBtn(icon: Icons.replay_10, onTap: widget.onSkipBack, onDark: onDark),
|
||||
_OverlayBtn(icon: Icons.forward_10, onTap: widget.onSkipForward, onDark: onDark),
|
||||
_OverlayBtn(icon: Icons.volume_up_outlined, onTap: null, onDark: onDark),
|
||||
const SizedBox(width: 8),
|
||||
Text('${_fmt(widget.position)} / ${_fmt(widget.duration)}',
|
||||
style: TextStyle(color: timeColor, fontSize: 11, fontFamily: PyramidTheme.fontMono, letterSpacing: 0.4)),
|
||||
const Spacer(),
|
||||
// Right side
|
||||
_QualityBadge(label: _quality == 'Auto' ? '1080p' : _quality, onDark: onDark),
|
||||
const SizedBox(width: 4),
|
||||
_SettingsButton(
|
||||
open: _settingsOpen,
|
||||
onTap: () => setState(() => _settingsOpen = !_settingsOpen),
|
||||
speed: '${_speed == _speed.toInt() ? _speed.toInt() : _speed}x',
|
||||
quality: _quality,
|
||||
onDark: onDark,
|
||||
onSpeed: (s) {
|
||||
setState(() { _speed = s; _settingsOpen = false; });
|
||||
widget.onSpeedChange?.call(s);
|
||||
},
|
||||
onQuality: (q) {
|
||||
setState(() { _quality = q; _settingsOpen = false; });
|
||||
widget.onQualityChange?.call(q);
|
||||
},
|
||||
speeds: _speeds, qualities: _qualities,
|
||||
),
|
||||
_OverlayBtn(icon: Icons.fullscreen, onTap: widget.onFullscreen, onDark: onDark),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _scrubber({required bool onDark}) {
|
||||
final trackBg = onDark ? Colors.white.withOpacity(0.18) : PyramidTheme.bg3;
|
||||
final bufferedBg = onDark ? Colors.white.withOpacity(0.28) : PyramidTheme.borderStrong;
|
||||
|
||||
return LayoutBuilder(builder: (_, c) => GestureDetector(
|
||||
onTapDown: (d) => widget.onSeek?.call(Duration(
|
||||
milliseconds: (widget.duration.inMilliseconds * (d.localPosition.dx / c.maxWidth).clamp(0.0, 1.0)).round(),
|
||||
)),
|
||||
onPanUpdate: (d) => widget.onSeek?.call(Duration(
|
||||
milliseconds: (widget.duration.inMilliseconds * (d.localPosition.dx / c.maxWidth).clamp(0.0, 1.0)).round(),
|
||||
)),
|
||||
child: SizedBox(
|
||||
height: 14,
|
||||
child: Stack(alignment: Alignment.centerLeft, children: [
|
||||
Container(height: 3, decoration: BoxDecoration(color: trackBg, borderRadius: BorderRadius.circular(2))),
|
||||
FractionallySizedBox(widthFactor: widget.buffered, child: Container(height: 3, decoration: BoxDecoration(color: bufferedBg, borderRadius: BorderRadius.circular(2)))),
|
||||
FractionallySizedBox(widthFactor: progress, child: Container(height: 3, decoration: BoxDecoration(color: PyramidTheme.accent, borderRadius: BorderRadius.circular(2)))),
|
||||
Positioned(
|
||||
left: c.maxWidth * progress - 5,
|
||||
child: Container(width: 10, height: 10, decoration: BoxDecoration(color: PyramidTheme.accent, shape: BoxShape.circle, boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.35), blurRadius: 0, spreadRadius: 3)])),
|
||||
),
|
||||
]),
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────── Overlay sub-widgets ─────────────────────────
|
||||
|
||||
class _CenterPlay extends StatelessWidget {
|
||||
final VoidCallback? onTap;
|
||||
const _CenterPlay({this.onTap});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 64, height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.08),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white.withOpacity(0.15)),
|
||||
),
|
||||
child: const Icon(Icons.play_arrow, color: Colors.white, size: 28),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OverlayBtn extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final VoidCallback? onTap;
|
||||
final bool onDark;
|
||||
const _OverlayBtn({required this.icon, this.onTap, required this.onDark});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(PyramidTheme.rSm),
|
||||
child: Container(
|
||||
width: 32, height: 32,
|
||||
alignment: Alignment.center,
|
||||
child: Icon(icon, size: 17, color: onDark ? Colors.white.withOpacity(0.85) : PyramidTheme.fgMuted),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlayMini extends StatelessWidget {
|
||||
final bool playing;
|
||||
final VoidCallback? onTap;
|
||||
final bool onDark;
|
||||
const _PlayMini({required this.playing, this.onTap, required this.onDark});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 36, height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: onDark ? Colors.white : PyramidTheme.accent,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(playing ? Icons.pause : Icons.play_arrow,
|
||||
color: onDark ? const Color(0xFF0A0A0C) : PyramidTheme.accentFg, size: 16),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QualityBadge extends StatelessWidget {
|
||||
final String label;
|
||||
final bool onDark;
|
||||
const _QualityBadge({required this.label, required this.onDark});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: onDark ? Colors.white.withOpacity(0.08) : PyramidTheme.bg2,
|
||||
borderRadius: BorderRadius.circular(PyramidTheme.rSm),
|
||||
),
|
||||
child: Text(label.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontFamily: PyramidTheme.fontMono, fontSize: 10, fontWeight: FontWeight.w500, letterSpacing: 0.6,
|
||||
color: onDark ? Colors.white.withOpacity(0.78) : PyramidTheme.fgMuted,
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────── Settings menu ─────────────────────────
|
||||
|
||||
class _SettingsButton extends StatelessWidget {
|
||||
final bool open;
|
||||
final VoidCallback onTap;
|
||||
final String speed;
|
||||
final String quality;
|
||||
final ValueChanged<double> onSpeed;
|
||||
final ValueChanged<String> onQuality;
|
||||
final List<double> speeds;
|
||||
final List<String> qualities;
|
||||
final bool onDark;
|
||||
|
||||
const _SettingsButton({
|
||||
required this.open, required this.onTap,
|
||||
required this.speed, required this.quality,
|
||||
required this.onSpeed, required this.onQuality,
|
||||
required this.speeds, required this.qualities,
|
||||
required this.onDark,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopupMenuButton<void>(
|
||||
tooltip: 'Einstellungen',
|
||||
offset: const Offset(0, -12),
|
||||
position: PopupMenuPosition.over,
|
||||
color: onDark ? const Color(0xFF14141A).withOpacity(0.96) : PyramidTheme.bg2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(PyramidTheme.rBase),
|
||||
side: BorderSide(color: onDark ? Colors.white.withOpacity(0.1) : PyramidTheme.border),
|
||||
),
|
||||
itemBuilder: (ctx) => [
|
||||
PopupMenuItem<void>(
|
||||
enabled: false,
|
||||
padding: EdgeInsets.zero,
|
||||
child: _SettingsMenu(
|
||||
speed: speed, quality: quality,
|
||||
onSpeed: onSpeed, onQuality: onQuality,
|
||||
speeds: speeds, qualities: qualities,
|
||||
onDark: onDark,
|
||||
),
|
||||
),
|
||||
],
|
||||
child: Container(
|
||||
width: 32, height: 32,
|
||||
alignment: Alignment.center,
|
||||
child: Icon(Icons.settings_outlined, size: 17, color: onDark ? Colors.white.withOpacity(0.85) : PyramidTheme.fgMuted),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SettingsMenu extends StatefulWidget {
|
||||
final String speed;
|
||||
final String quality;
|
||||
final ValueChanged<double> onSpeed;
|
||||
final ValueChanged<String> onQuality;
|
||||
final List<double> speeds;
|
||||
final List<String> qualities;
|
||||
final bool onDark;
|
||||
|
||||
const _SettingsMenu({
|
||||
required this.speed, required this.quality,
|
||||
required this.onSpeed, required this.onQuality,
|
||||
required this.speeds, required this.qualities,
|
||||
required this.onDark,
|
||||
});
|
||||
@override
|
||||
State<_SettingsMenu> createState() => _SettingsMenuState();
|
||||
}
|
||||
|
||||
class _SettingsMenuState extends State<_SettingsMenu> {
|
||||
String? section; // null | 'speed' | 'quality'
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fg = widget.onDark ? Colors.white.withOpacity(0.9) : PyramidTheme.fg;
|
||||
final dim = widget.onDark ? Colors.white.withOpacity(0.6) : PyramidTheme.fgMuted;
|
||||
|
||||
Widget row(String label, String value, VoidCallback onTap) => InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Text(label, style: TextStyle(color: fg, fontSize: 12, fontFamily: PyramidTheme.fontSans)),
|
||||
const SizedBox(width: 24),
|
||||
Text(value, style: TextStyle(color: dim, fontSize: 11, fontFamily: PyramidTheme.fontMono)),
|
||||
const SizedBox(width: 4),
|
||||
Icon(Icons.chevron_right, size: 14, color: dim),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
Widget item(String label, bool active, VoidCallback onTap) => InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
SizedBox(width: 18, child: active ? Icon(Icons.check, size: 14, color: PyramidTheme.accent) : null),
|
||||
Text(label, style: TextStyle(
|
||||
color: active ? PyramidTheme.accent : fg,
|
||||
fontSize: 12,
|
||||
fontFamily: section == 'speed' ? PyramidTheme.fontMono : PyramidTheme.fontSans,
|
||||
)),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
if (section == null) {
|
||||
return SizedBox(
|
||||
width: 200,
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
row('Geschwindigkeit', widget.speed, () => setState(() => section = 'speed')),
|
||||
row('Qualität', widget.quality, () => setState(() => section = 'quality')),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
final isSpeed = section == 'speed';
|
||||
return SizedBox(
|
||||
width: 180,
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
InkWell(
|
||||
onTap: () => setState(() => section = null),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Icon(Icons.arrow_back, size: 12, color: dim),
|
||||
const SizedBox(width: 8),
|
||||
Text(isSpeed ? 'GESCHWINDIGKEIT' : 'QUALITÄT',
|
||||
style: TextStyle(color: dim, fontSize: 10, fontFamily: PyramidTheme.fontMono, letterSpacing: 0.8)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
Container(height: 1, color: widget.onDark ? Colors.white.withOpacity(0.08) : PyramidTheme.border),
|
||||
if (isSpeed)
|
||||
...widget.speeds.map((s) {
|
||||
final lbl = '${s == s.toInt() ? s.toInt() : s}x';
|
||||
return item(lbl, lbl == widget.speed, () => widget.onSpeed(s));
|
||||
})
|
||||
else
|
||||
...widget.qualities.map((q) => item(q, q == widget.quality, () => widget.onQuality(q))),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BigTriPainter extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size s) {
|
||||
final p = Paint()..color = PyramidTheme.accent;
|
||||
final path = Path()
|
||||
..moveTo(s.width / 2, 0)
|
||||
..lineTo(s.width, s.height)
|
||||
..lineTo(0, s.height)
|
||||
..close();
|
||||
canvas.drawPath(path, p);
|
||||
}
|
||||
@override
|
||||
bool shouldRepaint(_) => false;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
name: pyramid_players
|
||||
description: Pyramid Audio + Video Player widgets (Mockup-Skelett, ohne echte Wiedergabe).
|
||||
publish_to: 'none'
|
||||
version: 0.1.0
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
flutter: '>=3.16.0'
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
dev_dependencies:
|
||||
flutter_lints: ^3.0.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
# Optional: Geist + Geist Mono einbinden, sonst Fallback auf System-Sans/Mono
|
||||
# fonts:
|
||||
# - family: Geist
|
||||
# fonts:
|
||||
# - asset: fonts/Geist-Regular.ttf
|
||||
# - asset: fonts/Geist-Medium.ttf
|
||||
# weight: 500
|
||||
# - asset: fonts/Geist-SemiBold.ttf
|
||||
# weight: 600
|
||||
# - family: GeistMono
|
||||
# fonts:
|
||||
# - asset: fonts/GeistMono-Regular.ttf
|
||||
# - asset: fonts/GeistMono-Medium.ttf
|
||||
# weight: 500
|
||||
@@ -0,0 +1,110 @@
|
||||
/* Inline SVG icons for Pyramid — React components */
|
||||
|
||||
const Icon = ({ name, size = 16, stroke = 1.75, ...rest }) => {
|
||||
const s = size;
|
||||
const sw = stroke;
|
||||
const paths = {
|
||||
hash: <><line x1="4" y1="9" x2="20" y2="9" /><line x1="4" y1="15" x2="20" y2="15" /><line x1="10" y1="3" x2="8" y2="21" /><line x1="16" y1="3" x2="14" y2="21" /></>,
|
||||
voice: <><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z" /><path d="M19 10v2a7 7 0 0 1-14 0v-2" /><line x1="12" y1="19" x2="12" y2="23" /></>,
|
||||
announce: <><path d="M3 11l18-8v18l-18-8v-2z" /><path d="M11 19v3" /></>,
|
||||
lock: <><rect x="5" y="11" width="14" height="10" rx="2" /><path d="M8 11V7a4 4 0 0 1 8 0v4" /></>,
|
||||
search: <><circle cx="11" cy="11" r="7" /><line x1="21" y1="21" x2="16.65" y2="16.65" /></>,
|
||||
plus: <><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></>,
|
||||
close: <><line x1="6" y1="6" x2="18" y2="18" /><line x1="18" y1="6" x2="6" y2="18" /></>,
|
||||
chevronDown: <polyline points="6 9 12 15 18 9" />,
|
||||
chevronRight: <polyline points="9 6 15 12 9 18" />,
|
||||
dots: <><circle cx="5" cy="12" r="1.6" /><circle cx="12" cy="12" r="1.6" /><circle cx="19" cy="12" r="1.6" /></>,
|
||||
dotsV: <><circle cx="12" cy="5" r="1.6" /><circle cx="12" cy="12" r="1.6" /><circle cx="12" cy="19" r="1.6" /></>,
|
||||
smile: <><circle cx="12" cy="12" r="9" /><line x1="9" y1="10" x2="9.01" y2="10" /><line x1="15" y1="10" x2="15.01" y2="10" /><path d="M8 15c1 1.5 2.5 2.5 4 2.5s3-1 4-2.5" /></>,
|
||||
reply: <><polyline points="9 14 4 9 9 4" /><path d="M20 20v-7a4 4 0 0 0-4-4H4" /></>,
|
||||
send: <><line x1="22" y1="2" x2="11" y2="13" /><polygon points="22 2 15 22 11 13 2 9 22 2" /></>,
|
||||
pin: <><line x1="12" y1="17" x2="12" y2="22" /><path d="M9 3h6l-1 6 3 3v2H7v-2l3-3-1-6z" /></>,
|
||||
users: <><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M23 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" /></>,
|
||||
bell: <><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" /><path d="M13.73 21a2 2 0 0 1-3.46 0" /></>,
|
||||
inbox: <><polyline points="22 12 16 12 14 15 10 15 8 12 2 12" /><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" /></>,
|
||||
mic: <><rect x="9" y="2" width="6" height="12" rx="3" /><path d="M5 11a7 7 0 0 0 14 0" /><line x1="12" y1="18" x2="12" y2="22" /></>,
|
||||
micOff: <><line x1="1" y1="1" x2="23" y2="23" /><path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V5a3 3 0 0 0-5.94-.6" /><path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23" /></>,
|
||||
headphones: <><path d="M3 18v-6a9 9 0 0 1 18 0v6" /><path d="M21 19a2 2 0 0 1-2 2h-1v-6h3v4zM3 19a2 2 0 0 0 2 2h1v-6H3v4z" /></>,
|
||||
videoOff: <><path d="M16 16v1a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2m5.66 0H14a2 2 0 0 1 2 2v3.34l1 1L23 7v10" /><line x1="1" y1="1" x2="23" y2="23" /></>,
|
||||
video: <><polygon points="23 7 16 12 23 17 23 7" /><rect x="1" y="5" width="15" height="14" rx="2" /></>,
|
||||
screen: <><rect x="2" y="3" width="20" height="14" rx="2" /><line x1="8" y1="21" x2="16" y2="21" /><line x1="12" y1="17" x2="12" y2="21" /></>,
|
||||
phoneOff: <><path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92V20a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.63A2 2 0 0 1 4.11 2h3.08a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 10" /><line x1="23" y1="1" x2="1" y2="23" /></>,
|
||||
settings: <><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" /></>,
|
||||
edit: <><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" /></>,
|
||||
trash: <><polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" /><path d="M10 11v6M14 11v6" /><path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2" /></>,
|
||||
copy: <><rect x="9" y="9" width="13" height="13" rx="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></>,
|
||||
bookmark: <path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />,
|
||||
link: <><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /></>,
|
||||
gif: <><rect x="2" y="5" width="20" height="14" rx="2" /><path d="M8 10h-2v4h2" /><line x1="11" y1="10" x2="11" y2="14" /><path d="M18 10h-3v4" /><line x1="15" y1="12" x2="17" y2="12" /></>,
|
||||
paperclip: <path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />,
|
||||
sun: <><circle cx="12" cy="12" r="4" /><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" /></>,
|
||||
moon: <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />,
|
||||
sidebarLeft: <><rect x="3" y="4" width="18" height="16" rx="2" /><line x1="9" y1="4" x2="9" y2="20" /></>,
|
||||
check: <polyline points="20 6 9 17 4 12" />,
|
||||
arrowUp: <><line x1="12" y1="19" x2="12" y2="5" /><polyline points="5 12 12 5 19 12" /></>,
|
||||
sparkle: <path d="M12 2l2.2 6.4L21 10l-6.8 2.2L12 18l-2.2-5.8L3 10l6.8-1.6L12 2z" />,
|
||||
palette: <><circle cx="12" cy="12" r="9" /><circle cx="7.5" cy="10.5" r="1" /><circle cx="12" cy="7.5" r="1" /><circle cx="16.5" cy="10.5" r="1" /><path d="M12 21c-1.7 0-3-1.3-3-3 0-1 .5-1.5 1-2 .5-.5 1-1 1-2 0-1.1-.9-2-2-2" /></>,
|
||||
globe: <><circle cx="12" cy="12" r="10" /><line x1="2" y1="12" x2="22" y2="12" /><path d="M12 2a15 15 0 0 1 0 20a15 15 0 0 1 0-20z" /></>,
|
||||
at: <><circle cx="12" cy="12" r="4" /><path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8" /></>,
|
||||
filter: <polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />,
|
||||
};
|
||||
return (
|
||||
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round" {...rest}>
|
||||
{paths[name]}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
/* Pyramid logo/mark */
|
||||
const PyramidMark = ({ size = 36, accent }) => {
|
||||
const color = accent || 'var(--accent)';
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 48 48" fill="none">
|
||||
<defs>
|
||||
<linearGradient id={`pyr-face-${size}`} x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0" stopColor={color} stopOpacity="1" />
|
||||
<stop offset="1" stopColor={color} stopOpacity="0.55" />
|
||||
</linearGradient>
|
||||
<linearGradient id={`pyr-side-${size}`} x1="0" x2="1" y1="0" y2="1">
|
||||
<stop offset="0" stopColor={color} stopOpacity="0.85" />
|
||||
<stop offset="1" stopColor={color} stopOpacity="0.25" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{/* right face */}
|
||||
<path d="M24 6 L42 40 L24 34 Z" fill={`url(#pyr-side-${size})`} />
|
||||
{/* left face (front) */}
|
||||
<path d="M24 6 L6 40 L24 34 Z" fill={`url(#pyr-face-${size})`} />
|
||||
{/* base outline */}
|
||||
<path d="M6 40 L24 34 L42 40" stroke={color} strokeOpacity="0.4" strokeWidth="1" fill="none" strokeLinejoin="round" />
|
||||
{/* edge highlight */}
|
||||
<path d="M24 6 L24 34" stroke={color} strokeOpacity="0.9" strokeWidth="0.8" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
/* Small pyramid for space icons */
|
||||
const PyramidGlyph = ({ size = 26, color = 'currentColor', short }) => (
|
||||
<div style={{position: 'relative', width: size, height: size, display: 'flex', alignItems: 'center', justifyContent: 'center'}}>
|
||||
<svg width={size} height={size} viewBox="0 0 32 32" fill="none" style={{position: 'absolute', inset: 0}}>
|
||||
<path d="M16 3 L29 27 L3 27 Z" fill={color} opacity="0.22" />
|
||||
<path d="M16 3 L29 27 L3 27 Z" stroke={color} strokeWidth="1.5" strokeLinejoin="round" />
|
||||
<path d="M16 3 L16 27" stroke={color} strokeWidth="1" opacity="0.6" />
|
||||
</svg>
|
||||
{short && (
|
||||
<span style={{
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
fontSize: size * 0.34,
|
||||
fontWeight: 700,
|
||||
color: color,
|
||||
marginTop: size * 0.12,
|
||||
letterSpacing: '-0.02em',
|
||||
}}>{short}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
window.Icon = Icon;
|
||||
window.PyramidMark = PyramidMark;
|
||||
window.PyramidGlyph = PyramidGlyph;
|
||||
@@ -0,0 +1,976 @@
|
||||
/* ============================================
|
||||
PYRAMID · Modals, popovers, overlays
|
||||
============================================ */
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
animation: fade-in 0.2s var(--spring-soft);
|
||||
}
|
||||
[data-theme="light"] .overlay { background: rgba(40,35,25,0.35); }
|
||||
|
||||
.modal {
|
||||
background: var(--bg-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-xl);
|
||||
box-shadow: var(--shadow-pop);
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: bounce-in 0.35s var(--spring-bounce);
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 16px; right: 16px;
|
||||
width: 28px; height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-2);
|
||||
color: var(--fg-muted);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: all 0.2s var(--spring-bounce);
|
||||
z-index: 2;
|
||||
}
|
||||
.modal-close:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--fg);
|
||||
transform: rotate(calc(90deg * var(--motion)));
|
||||
}
|
||||
|
||||
/* ==== Settings modal ==== */
|
||||
.settings-modal {
|
||||
width: 920px; height: 640px;
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr;
|
||||
}
|
||||
.settings-nav {
|
||||
background: var(--bg-2);
|
||||
padding: 18px 12px;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
.settings-nav-section {
|
||||
padding: 10px 10px 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
.settings-nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--r-sm);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--fg-muted);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.settings-nav-item:hover { background: var(--bg-hover); color: var(--fg); }
|
||||
.settings-nav-item.active { background: var(--accent-soft); color: var(--accent); }
|
||||
|
||||
.settings-content {
|
||||
padding: 32px 40px;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
.settings-content h2 {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
.settings-content .settings-subtitle {
|
||||
color: var(--fg-muted);
|
||||
font-size: 13px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.settings-group {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.settings-group-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-dim);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.settings-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.settings-row:last-child { border-bottom: none; }
|
||||
.settings-row-label { flex: 1; }
|
||||
.settings-row-title { font-size: 14px; font-weight: 500; color: var(--fg); }
|
||||
.settings-row-desc { font-size: 12px; color: var(--fg-muted); margin-top: 2px; }
|
||||
|
||||
/* Toggle */
|
||||
.toggle {
|
||||
width: 38px; height: 22px;
|
||||
background: var(--bg-3);
|
||||
border-radius: 999px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.toggle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px; left: 2px;
|
||||
width: 18px; height: 18px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.25s var(--spring-bounce);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||||
}
|
||||
.toggle.on { background: var(--accent); }
|
||||
.toggle.on::after { transform: translateX(16px); }
|
||||
|
||||
/* Select */
|
||||
.settings-select {
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 6px 10px;
|
||||
color: var(--fg);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.settings-select:hover { border-color: var(--border-strong); }
|
||||
.settings-select:focus { border-color: var(--accent); }
|
||||
|
||||
/* Account header in settings */
|
||||
.account-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, var(--accent-soft), transparent);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-lg);
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.account-card .avatar {
|
||||
width: 60px; height: 60px;
|
||||
border-radius: var(--r-lg);
|
||||
background: linear-gradient(135deg, #ff6b9d, #c471f5);
|
||||
font-size: 22px;
|
||||
position: relative;
|
||||
}
|
||||
.account-handle {
|
||||
color: var(--fg-muted);
|
||||
font-size: 13px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.account-name { font-size: 18px; font-weight: 600; }
|
||||
|
||||
/* ==== Emoji picker ==== */
|
||||
.emoji-picker {
|
||||
position: absolute;
|
||||
width: 360px; height: 420px;
|
||||
background: var(--bg-0);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-lg);
|
||||
box-shadow: var(--shadow-pop);
|
||||
z-index: 900;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: pop-in 0.2s var(--spring-bounce);
|
||||
transform-origin: bottom right;
|
||||
}
|
||||
.emoji-picker-search {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.emoji-picker-search input {
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--fg);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
.emoji-picker-search input:focus { border-color: var(--accent); background: var(--bg-1); }
|
||||
|
||||
.emoji-picker-tabs {
|
||||
display: flex;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
gap: 2px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.emoji-picker-tabs::-webkit-scrollbar { display: none; }
|
||||
.emoji-tab {
|
||||
flex-shrink: 0;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
opacity: 0.55;
|
||||
transition: all 0.2s var(--spring-bounce);
|
||||
}
|
||||
.emoji-tab:hover { opacity: 1; background: var(--bg-hover); }
|
||||
.emoji-tab.active { opacity: 1; background: var(--accent-soft); }
|
||||
|
||||
.emoji-grid-wrap {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
.emoji-category-title {
|
||||
padding: 8px 4px 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
.emoji-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
gap: 2px;
|
||||
}
|
||||
.emoji-cell {
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s var(--spring-bounce);
|
||||
}
|
||||
.emoji-cell:hover {
|
||||
background: var(--bg-hover);
|
||||
transform: scale(calc(1 + 0.25 * var(--motion)));
|
||||
}
|
||||
|
||||
.emoji-picker-preview {
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 40px;
|
||||
}
|
||||
.emoji-picker-preview .big { font-size: 22px; }
|
||||
.emoji-picker-preview .name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
}
|
||||
.emoji-picker-preview .shortcode {
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
|
||||
/* ==== Context menu ==== */
|
||||
.context-menu {
|
||||
position: absolute;
|
||||
background: var(--bg-0);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-base);
|
||||
box-shadow: var(--shadow-pop);
|
||||
padding: 4px;
|
||||
min-width: 200px;
|
||||
z-index: 950;
|
||||
animation: pop-in 0.16s var(--spring-bounce);
|
||||
transform-origin: top left;
|
||||
}
|
||||
.context-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 10px;
|
||||
border-radius: var(--r-sm);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--fg);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.context-item:hover { background: var(--bg-hover); }
|
||||
.context-item.danger { color: var(--danger); }
|
||||
.context-item.danger:hover { background: rgba(229, 72, 77, 0.12); }
|
||||
.context-item .shortcut {
|
||||
margin-left: auto;
|
||||
color: var(--fg-dim);
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.context-divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 4px 2px;
|
||||
}
|
||||
|
||||
/* ==== Profile popover ==== */
|
||||
.profile-popover {
|
||||
position: absolute;
|
||||
width: 320px;
|
||||
background: var(--bg-0);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-lg);
|
||||
box-shadow: var(--shadow-pop);
|
||||
z-index: 900;
|
||||
overflow: hidden;
|
||||
animation: pop-in 0.22s var(--spring-bounce);
|
||||
transform-origin: top left;
|
||||
}
|
||||
.profile-banner {
|
||||
height: 72px;
|
||||
background: linear-gradient(135deg, var(--accent), hsl(calc(var(--accent-h) + 40) 80% 50%));
|
||||
position: relative;
|
||||
}
|
||||
.profile-banner::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(circle at 30% 40%, rgba(255,255,255,0.2), transparent 60%);
|
||||
}
|
||||
.profile-content {
|
||||
padding: 0 16px 16px;
|
||||
margin-top: -32px;
|
||||
position: relative;
|
||||
}
|
||||
.profile-avatar {
|
||||
width: 70px; height: 70px;
|
||||
border-radius: var(--r-lg);
|
||||
border: 4px solid var(--bg-0);
|
||||
background: linear-gradient(135deg, #ff6b9d, #c471f5);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
position: relative;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.profile-name { font-size: 18px; font-weight: 600; }
|
||||
.profile-handle {
|
||||
font-size: 12px;
|
||||
color: var(--fg-muted);
|
||||
font-family: var(--font-mono);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.profile-section {
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.profile-section-title {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-dim);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.profile-about {
|
||||
font-size: 13px;
|
||||
color: var(--fg-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.profile-actions { display: flex; gap: 6px; margin-top: 14px; }
|
||||
.profile-btn {
|
||||
flex: 1;
|
||||
padding: 7px 10px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--fg);
|
||||
display: flex; align-items: center; justify-content: center; gap: 6px;
|
||||
transition: all 0.2s var(--spring-bounce);
|
||||
}
|
||||
.profile-btn.primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.profile-btn:hover {
|
||||
transform: translateY(calc(-1px * var(--motion)));
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.profile-roles { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.profile-role {
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--bg-3);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.profile-role .dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
}
|
||||
|
||||
/* ==== Voice channel ==== */
|
||||
.voice-hero {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 24px;
|
||||
background:
|
||||
radial-gradient(ellipse at top left, var(--accent-soft), transparent 50%),
|
||||
radial-gradient(ellipse at bottom right, hsl(calc(var(--accent-h) + 60) 60% 40% / 0.15), transparent 50%),
|
||||
var(--bg-1);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.voice-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.voice-title { font-size: 20px; font-weight: 600; }
|
||||
.voice-live-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--r-pill);
|
||||
background: rgba(229,72,77,0.12);
|
||||
color: var(--danger);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.voice-live-pill .live-dot {
|
||||
width: 6px; height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--danger);
|
||||
animation: pulse-ring 1.4s infinite;
|
||||
}
|
||||
|
||||
.voice-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.voice-tile {
|
||||
aspect-ratio: 16/10;
|
||||
background: var(--bg-2);
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: var(--r-lg);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.25s var(--spring-bounce);
|
||||
}
|
||||
.voice-tile.speaking {
|
||||
border-color: var(--online);
|
||||
box-shadow: 0 0 0 3px hsl(142 76% 50% / 0.2);
|
||||
}
|
||||
.voice-tile-avatar {
|
||||
width: 72px; height: 72px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
}
|
||||
.voice-tile.speaking .voice-tile-avatar {
|
||||
animation: pulse-voice 1.2s infinite;
|
||||
}
|
||||
@keyframes pulse-voice {
|
||||
0%, 100% { box-shadow: 0 0 0 0 hsl(142 76% 50% / 0.6); }
|
||||
50% { box-shadow: 0 0 0 12px hsl(142 76% 50% / 0); }
|
||||
}
|
||||
|
||||
.voice-tile-name {
|
||||
position: absolute;
|
||||
bottom: 10px; left: 10px;
|
||||
background: rgba(0,0,0,0.55);
|
||||
color: white;
|
||||
padding: 3px 8px;
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.voice-wave {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
height: 10px;
|
||||
}
|
||||
.voice-wave span {
|
||||
width: 2px;
|
||||
background: var(--online);
|
||||
border-radius: 1px;
|
||||
animation: voice-wave 0.8s infinite ease-in-out;
|
||||
}
|
||||
.voice-wave span:nth-child(1) { animation-delay: 0s; height: 6px; }
|
||||
.voice-wave span:nth-child(2) { animation-delay: 0.2s; height: 10px; }
|
||||
.voice-wave span:nth-child(3) { animation-delay: 0.4s; height: 8px; }
|
||||
.voice-wave span:nth-child(4) { animation-delay: 0.1s; height: 10px; }
|
||||
|
||||
.voice-status-icons {
|
||||
position: absolute;
|
||||
top: 10px; right: 10px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.voice-status-icon {
|
||||
width: 24px; height: 24px;
|
||||
background: rgba(0,0,0,0.55);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.voice-status-icon.muted { background: var(--danger); }
|
||||
|
||||
.voice-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 16px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-xl);
|
||||
margin-top: auto;
|
||||
}
|
||||
.voice-ctrl-btn {
|
||||
width: 48px; height: 48px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-3);
|
||||
color: var(--fg);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: all 0.2s var(--spring-bounce);
|
||||
}
|
||||
.voice-ctrl-btn:hover { transform: scale(calc(1 + 0.08 * var(--motion))); background: var(--bg-hover); }
|
||||
.voice-ctrl-btn.on { background: var(--accent); color: var(--accent-fg); }
|
||||
.voice-ctrl-btn.danger { background: var(--danger); color: white; }
|
||||
.voice-ctrl-btn.danger:hover { background: #d13f43; transform: scale(calc(1 + 0.08 * var(--motion))) rotate(calc(135deg * var(--motion))); }
|
||||
|
||||
/* ==== Search modal ==== */
|
||||
.search-modal {
|
||||
width: 640px;
|
||||
max-height: 560px;
|
||||
}
|
||||
.search-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.search-input-row svg { color: var(--fg-dim); flex-shrink: 0; }
|
||||
.search-input-row input {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--fg);
|
||||
font-size: 16px;
|
||||
}
|
||||
.search-input-row .esc {
|
||||
padding: 2px 6px;
|
||||
background: var(--bg-3);
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
|
||||
.search-filters {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 10px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.search-filters::-webkit-scrollbar { display: none; }
|
||||
|
||||
.search-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
color: var(--fg-muted);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s var(--spring-bounce);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.search-chip:hover { border-color: var(--accent); color: var(--fg); }
|
||||
.search-chip.active {
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.search-results {
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
max-height: 400px;
|
||||
}
|
||||
.search-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--r-base);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.search-result:hover,
|
||||
.search-result.selected { background: var(--accent-soft); }
|
||||
.search-result .avatar {
|
||||
width: 32px; height: 32px;
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 12px;
|
||||
}
|
||||
.search-result-content { flex: 1; min-width: 0; }
|
||||
.search-result-top {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.search-result-name { font-weight: 600; }
|
||||
.search-result-meta { font-size: 11px; color: var(--fg-dim); }
|
||||
.search-result-snippet {
|
||||
font-size: 12px;
|
||||
color: var(--fg-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.search-result-snippet mark {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
padding: 0 3px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.search-footer {
|
||||
padding: 10px 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 11px;
|
||||
color: var(--fg-dim);
|
||||
background: var(--bg-2);
|
||||
}
|
||||
.search-footer kbd {
|
||||
display: inline-block;
|
||||
padding: 1px 5px;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--fg-muted);
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
/* ==== Tweaks panel ==== */
|
||||
.tweaks-panel {
|
||||
position: fixed;
|
||||
bottom: 20px; right: 20px;
|
||||
width: 300px;
|
||||
background: var(--bg-0);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-lg);
|
||||
box-shadow: var(--shadow-pop);
|
||||
z-index: 800;
|
||||
overflow: hidden;
|
||||
animation: slide-up 0.3s var(--spring-bounce);
|
||||
}
|
||||
.tweaks-header {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-1);
|
||||
}
|
||||
.tweaks-title { font-weight: 600; font-size: 13px; display: flex; align-items: center; gap: 6px; }
|
||||
.tweaks-body { padding: 14px 16px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.tweak-row { display: flex; flex-direction: column; gap: 6px; }
|
||||
.tweak-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-dim);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.tweak-label .val { color: var(--fg); font-family: var(--font-mono); }
|
||||
.tweak-row input[type=range] {
|
||||
width: 100%;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
.tweak-swatches { display: flex; gap: 6px; }
|
||||
.tweak-swatch {
|
||||
width: 28px; height: 28px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
transition: transform 0.2s var(--spring-bounce);
|
||||
}
|
||||
.tweak-swatch:hover { transform: scale(calc(1 + 0.12 * var(--motion))); }
|
||||
.tweak-swatch.active { border-color: var(--fg); transform: scale(calc(1 + 0.08 * var(--motion))); }
|
||||
|
||||
.seg {
|
||||
display: flex;
|
||||
background: var(--bg-2);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 2px;
|
||||
gap: 2px;
|
||||
}
|
||||
.seg button {
|
||||
flex: 1;
|
||||
padding: 5px 8px;
|
||||
border-radius: calc(var(--r-sm) - 2px);
|
||||
font-size: 12px;
|
||||
color: var(--fg-muted);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.seg button.active {
|
||||
background: var(--bg-0);
|
||||
color: var(--fg);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
/* ==== Login ==== */
|
||||
.login-shell {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background:
|
||||
radial-gradient(ellipse 700px 500px at 20% 20%, var(--accent-soft), transparent),
|
||||
radial-gradient(ellipse 700px 600px at 80% 80%, hsl(calc(var(--accent-h) + 180) 60% 40% / 0.08), transparent),
|
||||
var(--bg-0);
|
||||
overflow: hidden;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 420px;
|
||||
background: var(--bg-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-xl);
|
||||
padding: 36px 34px;
|
||||
box-shadow: var(--shadow-pop);
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
.login-brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
.login-brand-mark {
|
||||
filter: drop-shadow(0 6px 20px var(--accent-glow));
|
||||
}
|
||||
.login-brand-name {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.login-brand-tag {
|
||||
font-size: 13px;
|
||||
color: var(--fg-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-field { margin-bottom: 14px; }
|
||||
.login-field-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-dim);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.login-field input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--fg);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.login-field input:focus {
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-1);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
|
||||
.login-submit {
|
||||
width: 100%;
|
||||
padding: 11px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-top: 6px;
|
||||
transition: all 0.25s var(--spring-bounce);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.login-submit:hover {
|
||||
transform: translateY(calc(-1px * var(--motion)));
|
||||
box-shadow: 0 8px 20px -4px var(--accent-glow);
|
||||
}
|
||||
.login-submit:active { transform: scale(0.98); }
|
||||
|
||||
.login-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 22px 0 16px;
|
||||
font-size: 11px;
|
||||
color: var(--fg-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.login-divider::before, .login-divider::after {
|
||||
content: ''; flex: 1; height: 1px; background: var(--border);
|
||||
}
|
||||
|
||||
.login-alt-row { display: flex; gap: 8px; margin-bottom: 14px; }
|
||||
.login-alt-btn {
|
||||
flex: 1;
|
||||
padding: 9px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--fg);
|
||||
transition: all 0.2s var(--spring-bounce);
|
||||
display: flex; align-items: center; justify-content: center; gap: 6px;
|
||||
}
|
||||
.login-alt-btn:hover { border-color: var(--accent); transform: translateY(calc(-1px * var(--motion))); }
|
||||
|
||||
.login-foot {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--fg-muted);
|
||||
margin-top: 14px;
|
||||
}
|
||||
.login-foot a { color: var(--accent); cursor: pointer; }
|
||||
|
||||
/* Floating pyramids in login bg */
|
||||
.login-float {
|
||||
position: absolute;
|
||||
opacity: 0.18;
|
||||
animation: float-pyramid 8s infinite ease-in-out;
|
||||
}
|
||||
@keyframes float-pyramid {
|
||||
0%, 100% { transform: translateY(0) rotate(var(--rot, 0deg)); }
|
||||
50% { transform: translateY(-20px) rotate(calc(var(--rot, 0deg) + 10deg)); }
|
||||
}
|
||||
|
||||
/* ==== Toast ==== */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--bg-0);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-pill);
|
||||
padding: 10px 18px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
box-shadow: var(--shadow-pop);
|
||||
z-index: 1500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
animation: slide-up 0.3s var(--spring-bounce);
|
||||
}
|
||||
|
||||
/* ==== View switcher tabs (for demo) ==== */
|
||||
.demo-switcher {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--bg-0);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-pill);
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
z-index: 700;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.demo-switcher button {
|
||||
padding: 5px 12px;
|
||||
border-radius: var(--r-pill);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--fg-muted);
|
||||
transition: all 0.2s var(--spring-bounce);
|
||||
}
|
||||
.demo-switcher button.active {
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
}
|
||||
.demo-switcher button:hover:not(.active) { color: var(--fg); background: var(--bg-hover); }
|
||||
@@ -0,0 +1,224 @@
|
||||
/* ============================================
|
||||
PYRAMID · Matrix Client
|
||||
Design tokens + global styles
|
||||
============================================ */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500&display=swap');
|
||||
|
||||
:root {
|
||||
/* Type */
|
||||
--font-sans: 'Geist', system-ui, -apple-system, sans-serif;
|
||||
--font-mono: 'Geist Mono', ui-monospace, monospace;
|
||||
|
||||
/* Accent (tweakable) */
|
||||
--accent-h: 42;
|
||||
--accent-s: 95%;
|
||||
--accent-l: 58%;
|
||||
--accent: hsl(var(--accent-h) var(--accent-s) var(--accent-l));
|
||||
--accent-soft: hsl(var(--accent-h) var(--accent-s) var(--accent-l) / 0.14);
|
||||
--accent-glow: hsl(var(--accent-h) var(--accent-s) var(--accent-l) / 0.35);
|
||||
--accent-fg: #0b0b0d;
|
||||
|
||||
/* Radius (tweakable) */
|
||||
--r-sm: calc(var(--r-base) * 0.6);
|
||||
--r-base: 12px;
|
||||
--r-lg: calc(var(--r-base) * 1.5);
|
||||
--r-xl: calc(var(--r-base) * 2);
|
||||
--r-pill: 999px;
|
||||
|
||||
/* Density (tweakable) */
|
||||
--density: 1;
|
||||
--row-pad-y: calc(10px * var(--density));
|
||||
--row-pad-x: calc(12px * var(--density));
|
||||
--msg-gap: calc(16px * var(--density));
|
||||
|
||||
/* Animations (tweakable 0..1) */
|
||||
--motion: 1;
|
||||
--spring-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
--spring-soft: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
|
||||
/* Dark theme (default) */
|
||||
--bg-0: #0a0a0c;
|
||||
--bg-1: #111114;
|
||||
--bg-2: #17171c;
|
||||
--bg-3: #1e1e25;
|
||||
--bg-hover: #232330;
|
||||
--bg-active: #2a2a38;
|
||||
--border: #25252e;
|
||||
--border-strong: #32323d;
|
||||
--fg: #ececf0;
|
||||
--fg-muted: #a4a4b0;
|
||||
--fg-dim: #6f6f7d;
|
||||
--danger: #e5484d;
|
||||
--success: #30a46c;
|
||||
--online: #4ade80;
|
||||
--away: #facc15;
|
||||
--busy: #f87171;
|
||||
--shadow-lg: 0 12px 40px -8px rgba(0,0,0,0.6), 0 2px 8px -2px rgba(0,0,0,0.4);
|
||||
--shadow-pop: 0 20px 60px -10px rgba(0,0,0,0.7), 0 4px 12px -4px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg-0: #f5f4f0;
|
||||
--bg-1: #ffffff;
|
||||
--bg-2: #faf9f5;
|
||||
--bg-3: #f0eeea;
|
||||
--bg-hover: #e9e7e1;
|
||||
--bg-active: #dfdcd3;
|
||||
--border: #e5e2dc;
|
||||
--border-strong: #d3cfc6;
|
||||
--fg: #1a1a1f;
|
||||
--fg-muted: #54545e;
|
||||
--fg-dim: #8a8a92;
|
||||
--shadow-lg: 0 12px 40px -8px rgba(30,25,15,0.12), 0 2px 8px -2px rgba(30,25,15,0.06);
|
||||
--shadow-pop: 0 20px 60px -10px rgba(30,25,15,0.18), 0 4px 12px -4px rgba(30,25,15,0.08);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #root { height: 100%; margin: 0; }
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background: var(--bg-0);
|
||||
color: var(--fg);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow: hidden;
|
||||
transition: background 0.3s var(--spring-soft), color 0.3s var(--spring-soft);
|
||||
}
|
||||
|
||||
button { font-family: inherit; cursor: pointer; border: none; background: none; color: inherit; }
|
||||
input, textarea { font-family: inherit; }
|
||||
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--border-strong); }
|
||||
|
||||
/* ==== App shell ==== */
|
||||
.app {
|
||||
display: grid;
|
||||
grid-template-columns: 72px 260px 1fr;
|
||||
height: 100vh;
|
||||
background: var(--bg-0);
|
||||
}
|
||||
|
||||
.app.right-open {
|
||||
grid-template-columns: 72px 260px 1fr 280px;
|
||||
}
|
||||
|
||||
.app.rail-collapsed {
|
||||
grid-template-columns: 0 260px 1fr;
|
||||
}
|
||||
.app.rail-collapsed.right-open {
|
||||
grid-template-columns: 0 260px 1fr 280px;
|
||||
}
|
||||
|
||||
.app.rooms-collapsed {
|
||||
grid-template-columns: 72px 0 1fr;
|
||||
}
|
||||
|
||||
/* ==== Pyramid logo ==== */
|
||||
.pyramid-logo {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ==== Keyframes ==== */
|
||||
@keyframes pop-in {
|
||||
0% { opacity: 0; transform: scale(0.85) translateY(4px); }
|
||||
70% { transform: scale(1.03) translateY(0); }
|
||||
100% { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes slide-right {
|
||||
from { opacity: 0; transform: translateX(-8px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
@keyframes pulse-ring {
|
||||
0% { box-shadow: 0 0 0 0 var(--accent-glow); }
|
||||
100% { box-shadow: 0 0 0 10px transparent; }
|
||||
}
|
||||
|
||||
@keyframes wiggle {
|
||||
0%, 100% { transform: rotate(0); }
|
||||
25% { transform: rotate(-3deg); }
|
||||
75% { transform: rotate(3deg); }
|
||||
}
|
||||
|
||||
@keyframes bounce-in {
|
||||
0% { transform: scale(0.3); opacity: 0; }
|
||||
50% { transform: scale(1.15); }
|
||||
80% { transform: scale(0.95); }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes voice-wave {
|
||||
0%, 100% { transform: scaleY(0.4); }
|
||||
50% { transform: scaleY(1); }
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
/* ==== Shared util ==== */
|
||||
.icon-btn {
|
||||
width: 32px; height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center; justify-content: center;
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--fg-muted);
|
||||
transition: all 0.18s var(--spring-soft);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.icon-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--fg);
|
||||
transform: scale(calc(1 + 0.05 * var(--motion)));
|
||||
}
|
||||
.icon-btn:active { transform: scale(calc(1 - 0.05 * var(--motion))); }
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--r-pill);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
background: var(--bg-3);
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
display: inline-flex;
|
||||
align-items: center; justify-content: center;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.presence-dot {
|
||||
width: 10px; height: 10px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--bg-1);
|
||||
position: absolute;
|
||||
bottom: -2px; right: -2px;
|
||||
}
|
||||
.presence-dot.online { background: var(--online); }
|
||||
.presence-dot.away { background: var(--away); }
|
||||
.presence-dot.busy { background: var(--busy); }
|
||||
.presence-dot.offline { background: var(--fg-dim); }
|
||||
Reference in New Issue
Block a user