jevstrudel.git / website / src / jev / Listeners.jsx
Listeners.jsx527 lines · 20.7 KB · raw
1// Listeners' content on the jev panel (listeners.mjs; worker/src/content.ts):
2//
3// - ListenerSongs: the public listener songs, most art first, as cards like
4//   the site's own; one opens its view and plays, as theirs do.
5// - ListenerSongView: a listener song's view (the tab's breadcrumb is
6//   WelcomeTab's): cover, author, the critic's score, revisions, spec as the
7//   author wrote it (plain text), Jev's takes on it (JevTakes.jsx, each
8//   replayed in the sandbox), and, for its author, a new revision from
9//   the editor and a cover upload.
10// - PublishSong: signed in, publish what is in the editor as a new song.
11// - Comments: any song's comments, the site's or a listener's; signed in,
12//   add one.
13// - Pitches: listeners' song ideas for Jev songs; signed in, pitch one.
14// - MyContent: signed in, everything you wrote and its status: public, held
15//   (Jev's verdict, seen only by you) or pending (why, and when Jev is
16//   asked again). It asks again every POLL_MS while anything is pending,
17//   which is also what runs your pending items' retries (the Worker does
18//   them behind that answer).
19//
20// Everything here is listeners' text, shown as text: React escapes it, and
21// a spec is never rendered as HTML.
22import { useEffect, useState, useSyncExternalStore } from 'react';
23import { NameLink } from './NameLink.jsx';
24import { account } from './account.mjs';
25import { ArtBreakdown } from './ArtBreakdown.jsx';
26import { JevTakes } from './JevTakes.jsx';
27import {
28  codeTitle,
29  listComments,
30  listPitches,
31  listSongs,
32  asSong,
33  mine,
34  postComment,
35  postPitch,
36  publishSong,
37  reviseSong,
38  scoreText,
39  setSongPitch,
40  statusText,
41  uploadCover,
42  votePitch,
43} from './listeners.mjs';
44import { answersOf, PITCH_SORTS } from './pitches.mjs';
45import { readSongs } from './client.mjs';
46import { themeColor as ACCENT } from './site.mjs';
47
48const POLL_MS = 20_000;
49const useAccount = () => useSyncExternalStore(account.subscribe, account.get, account.get);
50const button = 'border rounded px-2 py-1 hover:opacity-70 disabled:opacity-50';
51const input = 'bg-background border border-muted rounded px-2 py-1 min-w-0';
52const day = (ms) => new Date(ms).toISOString().slice(0, 10);
53
54// Something written changed: lists refetch.
55const written = new Set();
56let writes = 0;
57const wrote = () => {
58  writes++;
59  for (const l of written) l();
60};
61const useWrites = () =>
62  useSyncExternalStore(
63    (l) => {
64      written.add(l);
65      return () => written.delete(l);
66    },
67    () => writes,
68    () => writes,
69  );
70
71// What a write answered, in words for its author.
72const afterWrite = (what, res) =>
73  res.status === 'public' ? `${what} is public.` : `${what} is ${statusText(res)}.`;
74
75// ── the list ───────────────────────────────────────
76export function useListenerSongs() {
77  const version = useWrites();
78  const [songs, setSongs] = useState(null); // null: loading or unavailable
79  useEffect(() => {
80    let live = true;
81    listSongs()
82      .then((list) => live && setSongs(list.map(asSong)))
83      .catch(() => live && setSongs(null));
84    return () => {
85      live = false;
86    };
87  }, [version]);
88  return songs;
89}
90
91// ── publishing ─────────────────────────────────────
92function SongFieldsForm({ context, submit, label, initial }) {
93  const [title, setTitle] = useState(initial?.title ?? '');
94  const [description, setDescription] = useState(initial?.description ?? '');
95  const [spec, setSpec] = useState(initial?.spec ?? '');
96  const [state, setState] = useState(null); // { busy } | { note } | { error }
97  const send = async (e) => {
98    e.preventDefault();
99    const code = context.editorRef.current?.code ?? '';
100    if (!code.trim()) return setState({ error: 'the editor is empty' });
101    setState({ busy: true });
102    try {
103      const note = await submit({ title: title.trim() || codeTitle(code), description, spec, code });
104      setState({ note });
105      wrote();
106    } catch (err) {
107      setState({ error: err.message });
108    }
109  };
110  return (
111    <form className="not-prose flex flex-col gap-2 my-2 text-sm" onSubmit={send}>
112      <input className={input} maxLength={80} placeholder="title (or the code's // @title)" value={title} onChange={(e) => setTitle(e.target.value)} />
113      <input
114        className={input}
115        maxLength={280}
116        placeholder="one line about it"
117        value={description}
118        onChange={(e) => setDescription(e.target.value)}
119      />
120      <textarea
121        className={`${input} font-mono text-xs`}
122        rows={4}
123        placeholder="spec (optional): what the song sets out to be"
124        value={spec}
125        onChange={(e) => setSpec(e.target.value)}
126      />
127      <span className="flex items-center gap-2">
128        <button type="submit" className={button} style={{ borderColor: ACCENT }} disabled={state?.busy}>
129          {state?.busy ? 'Jev is screening it…' : label}
130        </button>
131        <span className="text-xs opacity-70">the code is whatever is in the editor now</span>
132      </span>
133      {state?.note && <span className="text-xs">{state.note}</span>}
134      {state?.error && <span className="text-xs text-red-400">{state.error}</span>}
135    </form>
136  );
137}
138
139export function PublishSong({ context }) {
140  const { user } = useAccount();
141  const [open, setOpen] = useState(false);
142  if (!user) return <p className="text-xs opacity-70">Sign in (🔑, on the you tab) to publish the code in the editor as your own song.</p>;
143  if (!open) {
144    return (
145      <button className={`not-prose ${button} my-2`} style={{ borderColor: ACCENT }} onClick={() => setOpen(true)}>
146        publish the editor's code as a song
147      </button>
148    );
149  }
150  return (
151    <SongFieldsForm
152      context={context}
153      label="publish"
154      submit={async (fields) => afterWrite('Your song', await publishSong(fields))}
155    />
156  );
157}
158
159// ── one listener song ──────────────────────────────
160export function ListenerSongView({ song, context }) {
161  const { user } = useAccount();
162  const [own, setOwn] = useState(false);
163  useEffect(() => {
164    if (!user) return setOwn(false);
165    mine()
166      .then((m) => setOwn(m.revisions.some((r) => r.song === song.listenerId)))
167      .catch(() => setOwn(false));
168  }, [user?.id, song.listenerId]);
169
170  return (
171    <div>
172      <h2 className="mb-1">{song.title}</h2>
173      <p className="not-prose text-sm mb-2">
174        by <NameLink id={song.authorId} name={song.author} />, a listener · rev {song.rev}
175      </p>
176      {song.cover && <img src={song.cover} alt={song.coverAlt} className="not-prose w-48 aspect-square object-cover rounded mb-3" />}
177      <div className="not-prose text-sm mb-3">
178        {song.art ? (
179          <>
180            <p className="mb-1">
181              Is it art? Jev (<span className="font-mono">{song.art.model}</span>) says{' '}
182              <strong>{Math.round(song.art.p * 100)}%</strong>
183              <span className="text-xs opacity-70"> — the same critic, and the same three runs, as the site's songs.</span>
184            </p>
185            <ArtBreakdown criteria={song.art.criteria} />
186          </>
187        ) : (
188          <p className="text-xs opacity-70">Jev has not scored this revision yet.</p>
189        )}
190      </div>
191      {song.revisions.length > 1 && (
192        <details className="not-prose text-xs opacity-75 mb-2">
193          <summary className="cursor-pointer">{song.revisions.length} revisions</summary>
194          <ol className="list-decimal pl-5 space-y-1 mt-1">
195            {song.revisions.map((r) => (
196              <li key={r.rev}>
197                <span className="font-mono">{day(r.at)}</span> · {r.art === null ? 'not scored yet' : `${Math.round(r.art * 100)}% art`}
198              </li>
199            ))}
200          </ol>
201        </details>
202      )}
203      {song.description && <p>{song.description}</p>}
204      {song.spec && <pre className="whitespace-pre-wrap text-xs not-prose bg-lineHighlight rounded p-2">{song.spec}</pre>}
205      <JevTakes song={song} />
206      {own && <OwnSongControls song={song} context={context} />}
207    </div>
208  );
209}
210
211function OwnSongControls({ song, context }) {
212  const [file, setFile] = useState(null);
213  const [alt, setAlt] = useState('');
214  const [state, setState] = useState(null);
215  const upload = async (e) => {
216    e.preventDefault();
217    if (!file || !alt.trim()) return;
218    setState({ busy: true });
219    try {
220      setState({ note: afterWrite('The cover', await uploadCover(song.listenerId, file, alt.trim())) });
221      wrote();
222    } catch (err) {
223      setState({ error: err.message });
224    }
225  };
226  return (
227    <details className="not-prose my-3 text-sm border border-muted rounded p-2">
228      <summary className="cursor-pointer">your song: a new revision, or its cover</summary>
229      <SongFieldsForm
230        context={context}
231        label="publish the editor's code as the next revision"
232        initial={{ title: song.title, description: song.description, spec: song.spec }}
233        submit={async (fields) => {
234          const res = await reviseSong(song.listenerId, fields);
235          return afterWrite(`Revision ${res.rev}`, res);
236        }}
237      />
238      <form className="flex flex-col gap-2 mt-3" onSubmit={upload}>
239        <span className="text-xs opacity-80">Cover: a PNG, JPEG, WebP or GIF, 2 MiB at most, and a line saying what it shows.</span>
240        <input type="file" accept="image/png,image/jpeg,image/webp,image/gif" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
241        <input className={input} maxLength={200} placeholder="what the cover shows" value={alt} onChange={(e) => setAlt(e.target.value)} />
242        <button type="submit" className={button} style={{ borderColor: ACCENT }} disabled={state?.busy || !file || !alt.trim()}>
243          {state?.busy ? 'uploading…' : 'upload cover'}
244        </button>
245        {state?.note && <span className="text-xs">{state.note}</span>}
246        {state?.error && <span className="text-xs text-red-400">{state.error}</span>}
247      </form>
248      <AnswersPitch song={song} />
249    </details>
250  );
251}
252
253// The song's author says which pitch it answers: the pitches list then
254// shows the song under it (pitches.mjs). Only a public pitch can be picked.
255function AnswersPitch({ song }) {
256  const [pitches, setPitches] = useState(null);
257  const [chosen, setChosen] = useState(song.pitch ?? '');
258  const [saved, setSaved] = useState(song.pitch ?? '');
259  const [state, setState] = useState(null);
260  useEffect(() => {
261    let live = true;
262    listPitches({ sort: 'votes' })
263      .then((p) => live && setPitches(p))
264      .catch(() => live && setPitches([]));
265    return () => {
266      live = false;
267    };
268  }, []);
269  if (!pitches?.length && !song.pitch) return null;
270  const save = async (e) => {
271    e.preventDefault();
272    setState({ busy: true });
273    try {
274      await setSongPitch(song.listenerId, chosen || null);
275      setSaved(chosen);
276      setState({ note: chosen ? 'It now shows under that pitch.' : 'It answers no pitch now.' });
277      wrote();
278    } catch (err) {
279      setState({ error: err.message });
280    }
281  };
282  const short = (text) => (text.length > 70 ? `${text.slice(0, 70)}…` : text);
283  return (
284    <form className="flex flex-col gap-2 mt-3" onSubmit={save}>
285      <span className="text-xs opacity-80">Did a listener's pitch give you the idea? Say which, and it shows under it.</span>
286      <select className={input} value={chosen} onChange={(e) => setChosen(e.target.value)}>
287        <option value="">no pitch</option>
288        {(pitches ?? []).map((p) => (
289          <option key={p.id} value={p.id}>
290            {p.author}: {short(p.body)}
291          </option>
292        ))}
293      </select>
294      <button type="submit" className={button} style={{ borderColor: ACCENT }} disabled={state?.busy || chosen === saved}>
295        {state?.busy ? 'saving…' : 'answers this pitch'}
296      </button>
297      {state?.note && <span className="text-xs">{state.note}</span>}
298      {state?.error && <span className="text-xs text-red-400">{state.error}</span>}
299    </form>
300  );
301}
302
303// ── comments ───────────────────────────────────────
304export function Comments({ song }) {
305  const { user } = useAccount();
306  const version = useWrites();
307  const [comments, setComments] = useState(null);
308  const [body, setBody] = useState('');
309  const [state, setState] = useState(null);
310  useEffect(() => {
311    let live = true;
312    listComments(song)
313      .then((c) => live && setComments(c))
314      .catch(() => live && setComments(null));
315    return () => {
316      live = false;
317    };
318  }, [song, version]);
319  if (comments === null) return null; // no Worker here, or it is down
320
321  const send = async (e) => {
322    e.preventDefault();
323    if (!body.trim()) return;
324    setState({ busy: true });
325    try {
326      setState({ note: afterWrite('Your comment', await postComment(song, body.trim())) });
327      setBody('');
328      wrote();
329    } catch (err) {
330      setState({ error: err.message });
331    }
332  };
333  return (
334    <div className="not-prose mt-4 text-sm" data-comments>
335      <h4 className="font-bold mb-1">Comments</h4>
336      {comments.length === 0 && <p className="text-xs opacity-70">None yet.</p>}
337      <ul className="space-y-2 mb-2">
338        {comments.map((c) => (
339          <li key={c.id}>
340            <span className="text-xs opacity-70">
341              <NameLink id={c.authorId} name={c.author} /> · {day(c.at)}
342            </span>
343            <p className="whitespace-pre-wrap">{c.body}</p>
344          </li>
345        ))}
346      </ul>
347      {user ? (
348        <form className="flex flex-col gap-2" onSubmit={send}>
349          <textarea className={input} rows={2} maxLength={2000} placeholder="say something about it" value={body} onChange={(e) => setBody(e.target.value)} />
350          <button type="submit" className={`${button} self-start`} style={{ borderColor: ACCENT }} disabled={state?.busy || !body.trim()}>
351            {state?.busy ? 'Jev is reading it…' : 'comment'}
352          </button>
353        </form>
354      ) : (
355        <p className="text-xs opacity-70">Sign in to comment. Jev reads every comment before it is shown.</p>
356      )}
357      {state?.note && <p className="text-xs mt-1">{state.note}</p>}
358      {state?.error && <p className="text-xs text-red-400 mt-1">{state.error}</p>}
359    </div>
360  );
361}
362
363// ── pitches ────────────────────────────────────────
364export function Pitches() {
365  const { user } = useAccount();
366  const version = useWrites();
367  const [pitches, setPitches] = useState(null);
368  const [body, setBody] = useState('');
369  const [state, setState] = useState(null);
370  const [sort, setSort] = useState('votes');
371  const [voting, setVoting] = useState(null); // the pitch whose vote is in flight
372  useEffect(() => {
373    let live = true;
374    listPitches({ sort })
375      .then((p) => live && setPitches(p))
376      .catch(() => live && setPitches(null));
377    return () => {
378      live = false;
379    };
380  }, [version, sort, user?.id]);
381  const vote = async (p) => {
382    setVoting(p.id);
383    try {
384      const { votes, voted } = await votePitch(p.id, !p.voted);
385      setPitches((all) => all.map((x) => (x.id === p.id ? { ...x, votes, voted } : x)));
386    } catch (err) {
387      setState({ error: err.message });
388    } finally {
389      setVoting(null);
390    }
391  };
392  if (pitches === null) return null;
393  const send = async (e) => {
394    e.preventDefault();
395    if (!body.trim()) return;
396    setState({ busy: true });
397    try {
398      setState({ note: afterWrite('Your pitch', await postPitch(body.trim())) });
399      setBody('');
400      wrote();
401    } catch (err) {
402      setState({ error: err.message });
403    }
404  };
405  return (
406    <div className="not-prose text-sm my-3" data-pitches>
407      <h4 className="font-bold mb-1">Pitch Jev a song</h4>
408      {user ? (
409        <form className="flex flex-col gap-2 mb-2" onSubmit={send}>
410          <textarea className={input} rows={2} maxLength={2000} placeholder="an idea for a Jev song" value={body} onChange={(e) => setBody(e.target.value)} />
411          <button type="submit" className={`${button} self-start`} style={{ borderColor: ACCENT }} disabled={state?.busy || !body.trim()}>
412            {state?.busy ? 'Jev is reading it…' : 'pitch'}
413          </button>
414          {state?.note && <span className="text-xs">{state.note}</span>}
415          {state?.error && <span className="text-xs text-red-400">{state.error}</span>}
416        </form>
417      ) : (
418        <p className="text-xs opacity-70 mb-2">Sign in to pitch an idea.</p>
419      )}
420      {pitches.length > 0 && (
421        <div className="flex gap-2 text-xs mb-2" role="group" aria-label="sort pitches">
422          {Object.entries(PITCH_SORTS).map(([key, label]) => (
423            <button
424              key={key}
425              className={`border rounded px-2 py-0.5 ${sort === key ? '' : 'opacity-60 hover:opacity-100'}`}
426              style={{ borderColor: sort === key ? ACCENT : undefined }}
427              onClick={() => setSort(key)}
428            >
429              {label}
430            </button>
431          ))}
432        </div>
433      )}
434      {pitches.length > 0 && (
435        <ul className="space-y-2">
436          {pitches.slice(0, 20).map((p) => {
437            const answers = answersOf(p, readSongs());
438            return (
439              <li key={p.id} className="flex gap-2" data-pitch={p.id}>
440                <button
441                  className="shrink-0 w-10 border rounded text-xs py-0.5 disabled:opacity-50"
442                  style={{ borderColor: p.voted ? ACCENT : undefined, color: p.voted ? ACCENT : undefined }}
443                  disabled={!user || voting === p.id}
444                  onClick={() => vote(p)}
445                  title={!user ? 'sign in to vote' : p.voted ? 'take your vote back' : 'vote for this pitch'}
446                  aria-pressed={p.voted}
447                >
448                  ▲ {p.votes}
449                </button>
450                <div className="min-w-0">
451                  <span className="text-xs opacity-70">
452                    <NameLink id={p.authorId} name={p.author} /> · {day(p.at)}
453                  </span>
454                  <p className="whitespace-pre-wrap">{p.body}</p>
455                  {answers.length > 0 && (
456                    <p className="text-xs mt-0.5">
457                      became{' '}
458                      {answers.map((a, i) => (
459                        <span key={a.site ?? a.listener}>
460                          {i > 0 && ', '}
461                          <a
462                            className="underline"
463                            style={{ color: ACCENT }}
464                            href={a.site ? `/songs/${a.site}/` : `/?listener=${encodeURIComponent(a.listener)}`}
465                          >
466                            {a.title}
467                          </a>
468                        </span>
469                      ))}
470                    </p>
471                  )}
472                </div>
473              </li>
474            );
475          })}
476        </ul>
477      )}
478    </div>
479  );
480}
481
482// ── yours ──────────────────────────────────────────
483export function MyContent() {
484  const { user } = useAccount();
485  const version = useWrites();
486  const [data, setData] = useState(null);
487  const [tick, setTick] = useState(0);
488  useEffect(() => {
489    if (!user) return setData(null);
490    let live = true;
491    mine()
492      .then((m) => live && setData(m))
493      .catch(() => live && setData(null));
494    return () => {
495      live = false;
496    };
497  }, [user?.id, version, tick]);
498  const items = data
499    ? [
500        ...data.revisions.map((r) => ({ key: `r${r.song}${r.rev}`, what: `song "${r.title}", rev ${r.rev}`, at: r.at, item: r, score: scoreText(r) })),
501        ...data.covers.map((c) => ({ key: `c${c.id}`, what: `cover: ${c.alt}`, at: c.at, item: c })),
502        ...data.comments.map((c) => ({ key: `m${c.id}`, what: `comment: ${c.body.slice(0, 60)}`, at: c.at, item: c })),
503        ...data.pitches.map((p) => ({ key: `p${p.id}`, what: `pitch: ${p.body.slice(0, 60)}`, at: p.at, item: p })),
504      ].sort((a, b) => b.at - a.at)
505    : [];
506  const waiting = items.some((i) => i.item.status === 'pending' || i.item.score);
507  useEffect(() => {
508    if (!waiting) return;
509    const t = setTimeout(() => setTick((n) => n + 1), POLL_MS);
510    return () => clearTimeout(t);
511  }, [waiting, tick]);
512  if (!user || !items.length) return null;
513  return (
514    <details className="not-prose text-sm my-3" data-my-content open={waiting}>
515      <summary className="cursor-pointer">What you wrote ({items.length})</summary>
516      <ul className="space-y-1 mt-1 text-xs">
517        {items.map((i) => (
518          <li key={i.key}>
519            <span className="opacity-70">{day(i.at)}</span> · {i.what} ·{' '}
520            <span className={i.item.status === 'held' ? 'text-red-400' : ''}>{statusText(i.item)}</span>
521            {i.score && <> · {i.score}</>}
522          </li>
523        ))}
524      </ul>
525    </details>
526  );
527}