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}