Panel.jsx513 lines · 17.6 KB · raw
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}