1import { Bars3Icon, PlayIcon, StopIcon, XMarkIcon } from '@heroicons/react/16/solid'; 2import cx from '@src/cx.mjs'; 3import { StrudelIcon } from '@src/repl/components/icons/StrudelIcon'; 4import { 5 useSettings, 6 setIsZen, 7 setIsPanelOpened, 8 setIsJevPanelOpened, 9 setJevTab, 10 setActiveFooter as setTab, 11} from '../../../settings.mjs'; 12import '../../Repl.css'; 13import { useLogger } from '../useLogger'; 14import { ConsoleTab } from './ConsoleTab'; 15import ExportTab from './ExportTab'; 16import { FilesTab } from './FilesTab'; 17import { PatternsTab } from './PatternsTab'; 18import { Reference } from './Reference'; 19import { SettingsTab } from './SettingsTab'; 20import { SoundsTab } from './SoundsTab'; 21import { WelcomeTab } from './WelcomeTab'; 22import { AutoplayToggle, NextTrack } from '@src/jev/JevPicks.jsx'; 23import { ConnectAi } from '@src/jev/ConnectAi.jsx'; 24import { DataTab } from '@src/jev/DataTab.jsx'; 25import { AccountControl } from '@src/jev/AccountControl.jsx'; 26import { ReleaseButton } from '@src/jev/UpdateModal.jsx'; 27import { useLobby, WhoIsListening } from '@src/jev/Lobby.jsx'; 28import { ActivityTab } from '@src/jev/ActivityTab.jsx'; 29import { RecentlyPlayed } from '@src/jev/History.jsx'; 30import { LiveParties } from '@src/jev/LiveParties.jsx'; 31import { Profile } from '@src/jev/Profile.jsx'; 32import { closeProfile, profileStore, setOnOpenProfile } from '@src/jev/profile.mjs'; 33import { useSyncExternalStore } from 'react'; 34 35const TAURI = typeof window !== 'undefined' && window.__TAURI__; 36 37const { BASE_URL } = import.meta.env; 38const baseNoTrailing = BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL; 39 40export function LogoButton({ context, isEmbedded }) { 41 const { started } = context; 42 const { isZen, isCSSAnimationDisabled, fontFamily } = useSettings(); 43 return ( 44 <div 45 className={cx( 46 'mt-[1px]', 47 started && !isCSSAnimationDisabled && 'animate-spin', 48 'cursor-pointer text-blue-500', 49 isZen && 'fixed top-2 right-4', 50 )} 51 onClick={() => { 52 if (!isEmbedded) { 53 setIsZen(!isZen); 54 } 55 }} 56 > 57 <span className="block text-foreground rotate-90"> 58 <StrudelIcon className="w-5 h-5 fill-foreground" /> 59 </span> 60 </div> 61 ); 62} 63 64export function MainPanel({ context, isEmbedded = false, className }) { 65 const { isZen, isButtonRowHidden, fontFamily } = useSettings(); 66 return ( 67 <nav 68 id="header" 69 className={cx( 70 'flex-none text-black z-[100] text-sm select-none min-h-10 max-h-10', 71 !isZen && !isEmbedded && 'border-b border-muted bg-lineHighlight', 72 isZen ? 'h-12 w-8 fixed top-0 left-0' : '', 73 'flex items-center', 74 className, 75 )} 76 style={{ fontFamily }} 77 > 78 <div className={cx('flex w-full justify-between')}> 79 <JevPanelToggle isEmbedded={isEmbedded} isZen={isZen} /> 80 <div className="px-1 sm:px-3 py-1 flex space-x-2 select-none min-w-0 overflow-hidden"> 81 <h1 82 onClick={() => { 83 if (isEmbedded) window.open(window.location.href.replace('embed', '')); 84 }} 85 className={cx( 86 isEmbedded ? 'text-l cursor-pointer' : 'text-xl', 87 'text-foreground font-bold flex space-x-2 items-center', 88 )} 89 > 90 <LogoButton context={context} isEmbedded={isEmbedded} /> 91 {!isZen && ( 92 <div className="space-x-2 flex items-baseline"> 93 <span className="hidden sm:block">jevstrudel</span> 94 <span className="text-sm font-medium hidden sm:block">REPL</span> 95 {context.mcp?.isMCPEnabled && ( 96 <span className="text-lg font-mono text-blue-400 select-text whitespace-nowrap hidden xl:inline"> 97 session_id: {context.mcp.sessionId} 98 <span 99 className={cx( 100 'ml-1', 101 context.mcp.connectionStatus === 'connected' 102 ? 'text-green-400' 103 : context.mcp.connectionStatus === 'error' 104 ? 'text-red-400' 105 : 'text-yellow-400', 106 )} 107 > 108 {context.mcp.connectionStatus === 'connected' 109 ? '●' 110 : context.mcp.connectionStatus === 'error' 111 ? '✕' 112 : '○'} 113 </span> 114 </span> 115 )} 116 </div> 117 )} 118 </h1> 119 </div> 120 {!isZen && ( 121 <div className="flex grow shrink-0 justify-end"> 122 {!isButtonRowHidden && <MainMenu isEmbedded={isEmbedded} context={context} />} 123 <PanelToggle isEmbedded={isEmbedded} isZen={isZen} /> 124 </div> 125 )} 126 </div> 127 </nav> 128 ); 129} 130 131export function Footer({ context, isEmbedded = false }) { 132 return ( 133 <div className="border-t border-muted bg-lineHighlight block lg:hidden"> 134 <MainMenu context={context} isEmbedded={isEmbedded} /> 135 </div> 136 ); 137} 138 139function MainMenu({ context, isEmbedded = false, className }) { 140 const { started, pending, isDirty, activeCode, handleTogglePlay, handleEvaluate, handleShare } = context; 141 const { isCSSAnimationDisabled } = useSettings(); 142 return ( 143 <div className={cx('flex text-sm max-w-full shrink-0 overflow-hidden text-foreground px-2 h-10', className)}> 144 <button 145 onClick={handleTogglePlay} 146 title={started ? 'stop' : 'play'} 147 className={cx('px-2 hover:opacity-50', !started && !isCSSAnimationDisabled && 'animate-pulse')} 148 > 149 <span className={cx('flex items-center space-x-2')}> 150 {started ? <StopIcon className="w-5 h-5" /> : <PlayIcon className="w-5 h-5" />} 151 {!isEmbedded && <span>{pending ? '...' : started ? 'stop' : 'play'}</span>} 152 </span> 153 </button> 154 {!isEmbedded && <NextTrack context={context} />} 155 {!isEmbedded && <AutoplayToggle />} 156 <button 157 onClick={handleEvaluate} 158 title="run the code again (ctrl+enter): bright when you have edited it" 159 className={cx('flex items-center space-x-1 px-2', !isDirty || !activeCode ? 'opacity-50' : 'hover:opacity-50')} 160 > 161 {!isEmbedded && <span>run</span>} 162 </button> 163 {!isEmbedded && ( 164 <button 165 title="share" 166 className={cx('cursor-pointer hover:opacity-50 flex items-center space-x-1 px-2')} 167 onClick={handleShare} 168 > 169 <span>share</span> 170 </button> 171 )} 172 {!isEmbedded && ( 173 <a 174 title="learn" 175 href={`${baseNoTrailing}/workshop/getting-started/`} 176 className={cx('hover:opacity-50 hidden sm:flex items-center space-x-1', !isEmbedded ? 'p-2' : 'px-2')} 177 > 178 <span>learn</span> 179 </a> 180 )} 181 </div> 182 ); 183} 184 185function PanelCloseButton() { 186 const { isPanelOpen } = useSettings(); 187 return ( 188 isPanelOpen && ( 189 <button 190 onClick={() => setIsPanelOpened(false)} 191 className={cx('px-2 py-0 text-foreground hover:opacity-50')} 192 aria-label="Close Menu" 193 > 194 <XMarkIcon className="w-6 h-6" /> 195 </button> 196 ) 197 ); 198} 199 200export function BottomPanel({ context }) { 201 const { isPanelOpen, activeFooter } = useSettings(); 202 const tab = rightTab(activeFooter); 203 return ( 204 <PanelNav 205 className={cx( 206 isPanelOpen ? `min-h-[360px] max-h-[360px]` : 'min-h-10 max-h-10', 207 'overflow-hidden flex flex-col relative', 208 )} 209 > 210 <div className="flex justify-between min-h-10 max-h-10 grid-cols-2 items-center border-t border-muted"> 211 <PanelCloseButton /> 212 <Tabs setTab={setTab} tab={tab} className={cx(isPanelOpen && 'border-l border-muted')} /> 213 </div> 214 {isPanelOpen && ( 215 <div className="w-full h-full overflow-auto border-t border-muted"> 216 <PanelContent context={context} tab={tab} /> 217 </div> 218 )} 219 </PanelNav> 220 ); 221} 222 223export function RightPanel({ context }) { 224 const settings = useSettings(); 225 const { activeFooter, isPanelOpen } = settings; 226 const tab = rightTab(activeFooter); 227 if (!isPanelOpen) { 228 return; 229 } 230 return ( 231 <PanelNav 232 settings={settings} 233 className={cx( 234 'border-l border-muted shrink-0 h-full overflow-hidden', 235 isPanelOpen ? `min-w-[min(600px,100vw)] max-w-[min(600px,80vw)]` : 'min-w-12 max-w-12', 236 )} 237 > 238 <div className={cx('flex flex-col h-full')}> 239 <div className="flex justify-between w-full overflow-hidden border-b border-muted min-h-10 max-h-10"> 240 <PanelCloseButton /> 241 <Tabs setTab={setTab} tab={tab} className="border-l border-muted" /> 242 </div> 243 <div className="overflow-auto h-full"> 244 <PanelContent context={context} tab={tab} /> 245 </div> 246 </div> 247 </PanelNav> 248 ); 249} 250 251// jevstrudel: the jev tab is the left panel's (LeftPanel, below); a stored 252// 'intro', its old name here, shows the first of these. 253const tabNames = { 254 patterns: 'patterns', 255 sounds: 'sounds', 256 reference: 'reference', 257 export: 'export', 258 console: 'console', 259 settings: 'settings', 260}; 261if (TAURI) { 262 tabNames.files = 'files'; 263} 264 265const rightTab = (tab) => (Object.values(tabNames).includes(tab) ? tab : tabNames.patterns); 266 267// Too narrow for the editor between two panels, opening one closes the other. 268const NARROW = 1024; 269function openOnly(set, other, open) { 270 set(open); 271 if (open && typeof window !== 'undefined' && window.innerWidth < NARROW) other(false); 272} 273 274function PanelNav({ children, className, label = 'Menu Panel', ...props }) { 275 const settings = useSettings(); 276 return ( 277 <nav 278 onClick={() => { 279 if (!settings.isPanelOpen) { 280 setIsPanelOpened(true); 281 } 282 }} 283 aria-label={label} 284 className={cx('h-full bg-lineHighlight group overflow-x-auto', className)} 285 {...props} 286 > 287 {children} 288 </nav> 289 ); 290} 291 292function PanelContent({ context, tab }) { 293 useLogger(); 294 switch (tab) { 295 case tabNames.patterns: 296 return <PatternsTab context={context} />; 297 case tabNames.console: 298 return <ConsoleTab />; 299 case tabNames.sounds: 300 return <SoundsTab />; 301 case tabNames.reference: 302 return <Reference />; 303 case tabNames.export: 304 return <ExportTab handleExport={context.handleExport} />; 305 case tabNames.settings: 306 return <SettingsTab started={context.started} />; 307 case tabNames.files: 308 return <FilesTab />; 309 default: 310 return <PatternsTab context={context} />; 311 } 312} 313 314function PanelTab({ label, isSelected, onClick }) { 315 return ( 316 <> 317 <button 318 onClick={onClick} 319 className={cx( 320 'h-10 px-2 text-sm border-t-2 border-t-transparent text-foreground cursor-pointer hover:opacity-50 flex items-center space-x-1 border-b-2', 321 isSelected ? 'border-foreground' : 'border-transparent', 322 )} 323 > 324 {label} 325 </button> 326 </> 327 ); 328} 329function Tabs({ className }) { 330 const { isPanelOpen, activeFooter } = useSettings(); 331 const tab = rightTab(activeFooter); 332 return ( 333 <div 334 className={cx( 335 'px-2 w-full flex select-none max-w-full h-10 max-h-10 min-h-10 overflow-auto items-center', 336 className, 337 )} 338 > 339 {Object.keys(tabNames).map((key) => { 340 const val = tabNames[key]; 341 return <PanelTab key={key} isSelected={tab === val && isPanelOpen} label={key} onClick={() => setTab(val)} />; 342 })} 343 </div> 344 ); 345} 346 347export function PanelToggle({ isEmbedded, isZen }) { 348 const { panelPosition, isPanelOpen } = useSettings(); 349 return ( 350 !isEmbedded && 351 !isZen && 352 panelPosition === 'right' && ( 353 <button 354 title="menu" 355 className={cx('border-l border-muted px-2 py-0 text-foreground hover:opacity-50')} 356 onClick={() => openOnly(setIsPanelOpened, setIsJevPanelOpened, !isPanelOpen)} 357 > 358 <Bars3Icon className="w-6 h-6" /> 359 </button> 360 ) 361 ); 362} 363 364// ── jevstrudel: the left panel, Jev's ──────────────────────────────── 365// Everything jevstrudel adds to Strudel lives here, so what is Strudel's 366// and what is not can be told apart at a glance: the right panel and the 367// header are Strudel's (plus ⏭ next and autoplay beside play, asked for 368// there), and Jev at work on the playing song is the pane under the editor 369// (JevPane.jsx), beside the code it arranges. It is the right panel's twin, 370// with its own ☰ at the header's left edge, open by default so a first 371// visit lands on the songs; its open state and tab are settings like the 372// right panel's. The tabs: 373// - songs: the song browser (WelcomeTab: the songs, the mood picker, 374// autoplay, "do you agree with Jev?", listeners' songs) 375// - activity: what is happening on the site, newest first (ActivityTab.jsx) 376// - listening: the live listening parties to join (LiveParties.jsx), and 377// who is listening, and listen along (Lobby.jsx) 378// - you: the account (AccountControl.jsx), and what you played lately 379// (History.jsx) 380// - mcp: whether your AI can play in this tab, and how to connect it 381// (ConnectAi.jsx, the same guide as /ai/) 382// - data: everything the site stores, for anyone to read (DataTab.jsx) 383// and, at its foot, the release and its notes (UpdateModal.jsx). Any name 384// the site shows opens that listener's profile (Profile.jsx) over the tab it 385// was in, until "back" or another tab. 386const jevTabs = { 387 songs: 'songs', 388 activity: 'activity', 389 listening: 'listening', 390 you: 'you', 391 mcp: 'mcp', 392 data: 'data', 393}; 394// a name clicked anywhere opens the panel on that profile 395setOnOpenProfile(() => setIsJevPanelOpened(true)); 396 397function YouTab({ context }) { 398 return ( 399 <div className="px-4 py-3 flex flex-col gap-3 text-sm font-mono"> 400 <div> 401 <AccountControl /> 402 </div> 403 <RecentlyPlayed context={context} /> 404 <button className="text-left opacity-80 hover:opacity-100 underline" onClick={() => setJevTab(jevTabs.mcp)}> 405 🤖 make songs with your own AI: the mcp tab 406 </button> 407 </div> 408 ); 409} 410 411function McpTab({ context }) { 412 return ( 413 <div className="px-4 py-3 text-sm"> 414 <p className="font-mono mb-3" data-testid="jev-my-tab"> 415 {context.mcp?.mine === 'connected' 416 ? `🤖 Your AI is connected: it can play in this tab (${context.mcp.sessionId}), in its sandbox.` 417 : '🤖 No AI connected to this tab yet. Connect yours:'} 418 </p> 419 <ConnectAi server={`${window.location.origin}/jev/mcp`} /> 420 </div> 421 ); 422} 423 424function JevTabContent({ context, tab }) { 425 switch (tab) { 426 case jevTabs.activity: 427 return <ActivityTab context={context} />; 428 case jevTabs.listening: 429 return ( 430 <div className="px-4 py-2 font-sans text-sm"> 431 <LiveParties /> 432 <WhoIsListening context={context} /> 433 </div> 434 ); 435 case jevTabs.you: 436 return <YouTab context={context} />; 437 case jevTabs.mcp: 438 return <McpTab context={context} />; 439 case jevTabs.data: 440 return <DataTab />; 441 default: 442 return <WelcomeTab context={context} />; 443 } 444} 445 446export function LeftPanel({ context }) { 447 const { isJevPanelOpen, jevTab } = useSettings(); 448 const profile = useSyncExternalStore(profileStore.subscribe, profileStore.get, profileStore.get); 449 if (!isJevPanelOpen) return null; 450 const tab = Object.values(jevTabs).includes(jevTab) ? jevTab : jevTabs.songs; 451 return ( 452 <nav 453 aria-label="Jev Panel" 454 data-testid="jev-panel" 455 className="h-full bg-lineHighlight border-r border-muted shrink-0 overflow-hidden min-w-[min(440px,100vw)] max-w-[min(440px,80vw)]" 456 > 457 <div className="flex flex-col h-full"> 458 <div className="flex justify-between w-full overflow-hidden border-b border-muted min-h-10 max-h-10"> 459 <div className="px-2 w-full flex select-none h-10 items-center border-r border-muted overflow-x-auto"> 460 {Object.entries(jevTabs).map(([label, val]) => ( 461 <PanelTab 462 key={val} 463 label={label} 464 isSelected={tab === val && !profile.id} 465 onClick={() => { 466 closeProfile(); 467 setJevTab(val); 468 }} 469 /> 470 ))} 471 </div> 472 <button 473 onClick={() => setIsJevPanelOpened(false)} 474 className="px-2 py-0 text-foreground hover:opacity-50" 475 aria-label="Close Jev Panel" 476 > 477 <XMarkIcon className="w-6 h-6" /> 478 </button> 479 </div> 480 <div className="overflow-auto h-full"> 481 {profile.id ? <Profile context={context} /> : <JevTabContent context={context} tab={tab} />} 482 </div> 483 <div className="border-t border-muted px-4 py-1 text-xs font-mono flex items-center gap-2 opacity-80"> 484 <span>jevstrudel</span> 485 <ReleaseButton /> 486 </div> 487 </div> 488 </nav> 489 ); 490} 491 492// The jev panel's ☰, with how many tabs are open on the site beside it. 493export function JevPanelToggle({ isEmbedded, isZen }) { 494 const { isJevPanelOpen } = useSettings(); 495 const lobby = useLobby(); 496 if (isEmbedded || isZen) return null; 497 return ( 498 <button 499 title="jev: the songs, who is listening, you, your AI, and everything the site stores" 500 data-testid="jev-panel-toggle" 501 className="border-r border-muted px-2 py-0 text-foreground hover:opacity-50 shrink-0 flex items-center gap-1 font-mono text-sm" 502 onClick={() => openOnly(setIsJevPanelOpened, setIsPanelOpened, !isJevPanelOpen)} 503 > 504 <Bars3Icon className="w-6 h-6" /> 505 <span className="hidden sm:inline">jev</span> 506 {lobby.status === 'open' && lobby.n > 1 && ( 507 <span className="opacity-70 hidden sm:inline" data-testid="jev-lobby-badge" title={`${lobby.n} tabs open on the site`}> 508 👥 {lobby.n} 509 </span> 510 )} 511 </button> 512 ); 513}