jevstrudel.git / website / src / repl / components / panel / WelcomeTab.jsx
WelcomeTab.jsx368 lines · 15.0 KB · raw
1import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react';
2import cx from '@src/cx.mjs';
3import { useSettings } from '@src/settings.mjs';
4
5import { loadListenerSong as showListenerSong, nowPlaying, playSong, readSongs, songPageId } from '../../../jev/client.mjs';
6import { SongListening } from '../../../jev/SongListening.jsx';
7import { MoodPicker, RadioToggle } from '../../../jev/JevPicks.jsx';
8import { ArtBreakdown } from '../../../jev/ArtBreakdown.jsx';
9import { JevTakes } from '../../../jev/JevTakes.jsx';
10import { NameLink } from '../../../jev/NameLink.jsx';
11import { AgreeWithJev } from '../../../jev/AgreeWithJev.jsx';
12import { useBooth } from '../../../jev/JevBooth.jsx';
13import { loadTally, tallyFor, tallyStore } from '../../../jev/reactions.mjs';
14import { LISTENER_PREFIX, listenerParam, listenerSongs, loadListenerSong } from '../../../jev/listeners.mjs';
15import {
16  Comments,
17  ListenerSongView,
18  MyContent,
19  Pitches,
20  PublishSong,
21  useListenerSongs,
22} from '../../../jev/Listeners.jsx';
23
24// jevstrudel: the welcome tab ("jev") is the song browser. The songs are
25// indexed at build time (src/jev/songs.mjs) and embedded in the REPL page as
26// JSON. A card plays its song and opens it in place of the list, with a
27// breadcrumb back; the song shown is whatever is playing, so the radio moves
28// the page along with it. Below the site's songs are listeners' songs
29// (jev/Listeners.jsx), opened and played the same way, and every song's
30// view has its comments.
31
32// Every listener's 🔥/😴 for the song, per section (jev/reactions.mjs): in
33// the order the song is written once it has been played here (its form's
34// sections; other names are no section of it), else most reacted first.
35const MAX_TALLY_SECTIONS = 24;
36function ListenerTally({ song }) {
37  useSyncExternalStore(tallyStore.subscribe, tallyStore.version, tallyStore.version);
38  const booth = useBooth();
39  useEffect(() => {
40    if (!tallyFor(song.id)) loadTally(song.id);
41  }, [song.id]);
42  const tally = tallyFor(song.id);
43  if (!tally) return null;
44  const written = nowPlaying.get() === song.id ? booth?.form?.sections : null;
45  const total = (n) => tally[n].fire + tally[n].sleep;
46  const names = (written ? written.filter((n) => Object.hasOwn(tally, n)) : Object.keys(tally).sort((a, b) => total(b) - total(a)))
47    .filter((n) => total(n) > 0)
48    .slice(0, MAX_TALLY_SECTIONS);
49  if (!names.length) return null;
50  return (
51    <div className="not-prose text-sm mb-3" data-listener-tally>
52      <p className="mb-1">What every listener said, per section (Jev hears it too):</p>
53      <div className="flex flex-wrap gap-x-3 gap-y-1 text-xs font-mono">
54        {names.map((n) => (
55          <span key={n}>
56            {n} 🔥{tally[n].fire} 😴{tally[n].sleep}
57          </span>
58        ))}
59      </div>
60    </div>
61  );
62}
63
64function SongSpec({ song }) {
65  return (
66    <div>
67      <h2 className="mb-1">{song.title}</h2>
68      {song.art && (
69        <div className="not-prose text-sm mb-3">
70          <p className="mb-1">
71            Is it art? Jev (<span className="font-mono">{song.art.model}</span>) says{' '}
72            <strong>{artPercent(song.art)}%</strong>
73            {song.art.criteria && (
74              <span className="text-xs opacity-70">
75                {' '}
76                — the mean of six criteria scored like a harsh critic, out of 5: 3 is good, 5 the best ever made in its
77                genre. Three notes scores 2%.
78              </span>
79            )}
80          </p>
81          <ArtBreakdown criteria={song.art.criteria} />
82        </div>
83      )}
84      {/* the Worker keeps tallies only for site songs (worker/src/listening.ts) */}
85      {!song.id.startsWith(LISTENER_PREFIX) && <ListenerTally song={song} />}
86      {/* Jev's takes and how it moves through the song: site songs only, like the tally */}
87      {!song.id.startsWith(LISTENER_PREFIX) && <JevTakes song={song} />}
88      {song.revisions.length > 0 && (
89        <details className="not-prose text-xs opacity-75 mb-2">
90          <summary className="cursor-pointer">
91            {song.revisions.length} revision{song.revisions.length > 1 ? 's' : ''} ·{' '}
92            {[...new Set(song.revisions.map((r) => r.model))].join(', ')}
93            <ArtTrend revisions={song.revisions} />
94          </summary>
95          <ol className="list-decimal pl-5 space-y-1 mt-1">
96            {song.revisions.map((r) => (
97              <li key={r.rev}>
98                <span className="font-mono">{r.date}</span> · <span className="font-mono">{r.model}</span> ·{' '}
99                {`"${r.prompt}"${r.output ? ` → ${r.output}` : ''}`} ·{' '}
100                {r.art === null ? (
101                  <span title="its code could not be recovered">not scored</span>
102                ) : (
103                  `${artPercent({ p: r.art })}% art`
104                )}
105              </li>
106            ))}
107          </ol>
108        </details>
109      )}
110      {/* our own SPEC.md, compiled by Astro at build time */}
111      <div dangerouslySetInnerHTML={{ __html: song.specHtml }} />
112    </div>
113  );
114}
115
116// "rev 1 91% → rev 4 94% (+3)": the first and last scored revisions, when
117// there are two to compare.
118function ArtTrend({ revisions }) {
119  const scored = revisions.filter((r) => r.art !== null);
120  if (scored.length < 2) return null;
121  const [first, last] = [scored[0], scored[scored.length - 1]];
122  const [a, b] = [artPercent({ p: first.art }), artPercent({ p: last.art })];
123  const change = b - a;
124  return (
125    <>
126      {' '}
127      · art: rev {first.rev} {a}% → rev {last.rev} {b}% ({change > 0 ? `+${change}` : change === 0 ? '±0' : change})
128    </>
129  );
130}
131
132function formatDuration(seconds) {
133  return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`;
134}
135
136function artPercent(art) {
137  return Math.round(art.p * 100);
138}
139
140// A song's card. The whole card plays it (a button stretched over it, so
141// Enter and Space do too, and it reads as the song's title), and the
142// author's name opens their profile: two controls side by side, never one
143// inside the other, since a button cannot hold another.
144export function SongCard({ song, selected, onSelect }) {
145  // the breakdown shows only while the pointer is on the "N% art" pill
146  const [showArt, setShowArt] = useState(false);
147  const playable = Boolean(song.code || song.listenerId);
148  return (
149    <div
150      data-testid="jev-song-card"
151      className={cx(
152        'relative flex flex-col text-left rounded-md overflow-hidden border bg-lineHighlight',
153        playable && 'hover:opacity-80',
154        selected ? 'border-foreground' : 'border-muted',
155        !playable && 'opacity-50',
156      )}
157    >
158      <button
159        type="button"
160        onClick={onSelect}
161        disabled={!playable}
162        aria-label={song.title}
163        className="absolute inset-0 z-0 cursor-pointer disabled:cursor-default focus-visible:outline focus-visible:outline-2 focus-visible:outline-foreground"
164      />
165      {/* drawn over the button, letting clicks through to it but for the art pill and the name */}
166      <div className="relative pointer-events-none">
167        {song.cover ? (
168          <img src={song.cover} alt={song.coverAlt ?? ''} loading="lazy" className="w-full aspect-square object-cover" />
169        ) : (
170          <div className="w-full aspect-square flex items-center justify-center text-2xl opacity-25">♪</div>
171        )}
172        {song.art && (
173          <div
174            onMouseEnter={() => setShowArt(true)}
175            onMouseLeave={() => setShowArt(false)}
176            onClick={playable ? onSelect : undefined}
177            className="pointer-events-auto absolute z-10 top-1 right-1 rounded px-1.5 py-0.5 text-xs font-bold font-mono bg-black/75 text-white cursor-help"
178          >
179            {artPercent(song.art)}% art
180          </div>
181        )}
182        {song.art?.criteria && showArt && (
183          <div className="absolute inset-0 flex flex-col justify-center gap-1 p-2 bg-black/85 text-white text-[10px] font-mono">
184            <div className="font-bold">
185              {artPercent(song.art)}% art, says {song.art.model}
186            </div>
187            <ArtBreakdown criteria={song.art.criteria} compact />
188          </div>
189        )}
190      </div>
191      <div className="relative p-2 space-y-1 pointer-events-none">
192        <div className="font-bold leading-tight">{song.title}</div>
193        <div className="text-xs opacity-60 font-mono">
194          {song.author ? (
195            <>
196              by <NameLink id={song.authorId} name={song.author} className="pointer-events-auto relative z-10" />
197            </>
198          ) : song.timing ? (
199            `${song.timing.jev ? "Jev's call" : formatDuration(song.timing.seconds)} · ${song.timing.bpm} bpm`
200          ) : (
201            'planned'
202          )}
203        </div>
204        {song.description && <div className="text-xs opacity-80 line-clamp-4">{song.description}</div>}
205      </div>
206    </div>
207  );
208}
209
210export function WelcomeTab({ context }) {
211  const { fontFamily } = useSettings();
212  const songs = readSongs();
213  const listened = useListenerSongs();
214  // the song loaded last, by a card, the mood picker or the radio
215  const selectedId = useSyncExternalStore(nowPlaying.subscribe, nowPlaying.get, nowPlaying.get);
216  const selected = songs.find((s) => s.id === selectedId) ?? listenerSongs.get(selectedId);
217  const themes = [...new Set(songs.map((s) => s.theme))];
218
219  // a song's own page, or a listener song's link, opens on that song; the home page on the list
220  const [open, setOpen] = useState(() => songPageId() !== null || listenerParam() !== null);
221  const [loading, setLoading] = useState(null); // { title } while a listener song is fetched, or { error }
222  // the panel scrolls, and keeps the list's position: start each view at its top
223  const top = useRef(null);
224  const showing = open && selected ? selected.id : null;
225  useLayoutEffect(() => {
226    top.current?.scrollIntoView({ block: 'start' });
227  }, [showing]);
228
229  // a listener song's card has no code: the song is fetched whole, then
230  // plays in the sandbox (jev/sandbox.mjs), never in this page
231  const openListenerSong = async (card) => {
232    setLoading({ title: card.title });
233    try {
234      const song = await loadListenerSong(card.listenerId);
235      setLoading(null);
236      setOpen(true);
237      await showListenerSong(context.editorRef.current, song, { play: true });
238    } catch (e) {
239      setLoading({ error: `${card.title} could not be loaded: ${e.message}` });
240    }
241  };
242
243  if (open && selected)
244    return (
245      <div ref={top} className="prose dark:prose-invert min-w-full py-4 font-sans px-4 text-sm" style={{ fontFamily }}>
246        <nav aria-label="breadcrumb" className="not-prose text-sm mb-3 flex items-center gap-2">
247          <button className="underline hover:opacity-50" onClick={() => setOpen(false)}>
248            jev
249          </button>
250          <span className="opacity-50">›</span>
251          {selected.listenerId && (
252            <>
253              <button className="underline hover:opacity-50" onClick={() => setOpen(false)}>
254                listeners
255              </button>
256              <span className="opacity-50">›</span>
257            </>
258          )}
259          <span className="font-bold">{selected.title}</span>
260        </nav>
261        {/* the song's own card, as in the list: clicking it plays it from the top */}
262        <div className="not-prose mb-4" style={{ maxWidth: '14rem' }}>
263          <SongCard
264            song={selected}
265            selected
266            onSelect={() =>
267              selected.listenerId ? openListenerSong(selected) : playSong(context.editorRef.current, selected)
268            }
269          />
270        </div>
271        {/* what people say first, then who is listening, then the song itself */}
272        <Comments song={selected.id} />
273        <SongListening song={selected} context={context} />
274        {selected.listenerId ? <ListenerSongView song={selected} context={context} /> : <SongSpec song={selected} />}
275      </div>
276    );
277
278  return (
279    <div ref={top} className="prose dark:prose-invert min-w-full py-4 font-sans px-4 text-sm" style={{ fontFamily }}>
280      <h3>jevstrudel</h3>
281      <p>
282        Songs vibe-coded in strudel. Pick one: it opens here, its code loads into the editor, and it plays from the top.
283      </p>
284      <MoodPicker context={context} />
285      <RadioToggle />
286      <AgreeWithJev />
287      {themes.map((theme) => (
288        <div key={theme}>
289          <h4>{theme}</h4>
290          <div
291            className="not-prose grid gap-3"
292            style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(10rem, 1fr))' }}
293          >
294            {songs
295              .filter((s) => s.theme === theme)
296              .map((song) => (
297                <SongCard
298                  key={song.id}
299                  song={song}
300                  selected={song.id === selectedId}
301                  onSelect={() => {
302                    setOpen(true);
303                    playSong(context.editorRef.current, song);
304                  }}
305                />
306              ))}
307          </div>
308        </div>
309      ))}
310      <div data-listeners>
311        <h4>listeners</h4>
312        <p>
313          Songs listeners published, most art first by the same critic. Jev reads everything before it is shown here. A
314          listener's song is their code: it plays in a sandbox, apart from this page and your account.
315        </p>
316        <PublishSong context={context} />
317        <MyContent />
318        {listened === null ? (
319          <p className="text-xs opacity-70">Listeners' songs are not available here.</p>
320        ) : listened.length === 0 ? (
321          <p className="text-xs opacity-70">None yet.</p>
322        ) : (
323          <div
324            className="not-prose grid gap-3"
325            style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(10rem, 1fr))' }}
326          >
327            {listened.map((song) => (
328              <SongCard
329                key={song.id}
330                song={song}
331                selected={song.id === selectedId}
332                onSelect={() => openListenerSong(song)}
333              />
334            ))}
335          </div>
336        )}
337        {loading && <p className="text-xs opacity-70">{loading.error ?? `loading ${loading.title}…`}</p>}
338        <Pitches />
339      </div>
340      <hr />
341      <h3>about strudel</h3>
342      <p>
343        jevstrudel is built on{' '}
344        <a href="https://strudel.cc" target="_blank">
345          strudel
346        </a>
347        , a JavaScript version of{' '}
348        <a href="https://tidalcycles.org/" target="_blank">
349          tidalcycles
350        </a>
351        . Strudel is free/open source software, with copyright owned by its{' '}
352        <a href="https://codeberg.org/uzu/strudel/activity/contributors" target="_blank">
353          contributors
354        </a>
355        . You can redistribute and/or modify it under the terms of the{' '}
356        <a href="https://codeberg.org/uzu/strudel/src/branch/main/LICENSE" target="_blank">
357          GNU Affero General Public License
358        </a>
359        . Its source is on{' '}
360        <a href="https://codeberg.org/uzu/strudel" target="_blank">
361          codeberg
362        </a>
363        , and <a href="https://github.com/felixroos/dough-samples/blob/main/README.md">licensing info</a> for the
364        default sound banks is there too.
365      </p>
366    </div>
367  );
368}