1// A listener's profile (profile.mjs), in the jev panel over whichever tab 2// opened it, with a way back: their songs as the song browser's cards 3// (played the song browser's way: a listener's song in the sandbox, never 4// the page), what they are playing now (and listening along to it), what 5// they wrote that is public, and what they played lately (History.jsx's 6// PlayedList: their takes, each replayed, and radio plays). Everything here 7// is someone else's text, shown as text. 8import { useEffect, useState, useSyncExternalStore } from 'react'; 9import { SongCard } from '@src/repl/components/panel/WelcomeTab.jsx'; 10import { loadListenerSong as showListenerSong, readSongs } from './client.mjs'; 11import { LISTENER_PREFIX, loadListenerSong } from './listeners.mjs'; 12import { listenAlong, lobbyStore } from './lobby.mjs'; 13import { PlayedList } from './History.jsx'; 14import { closeProfile, loadProfile, profileStore, profileView } from './profile.mjs'; 15import { themeColor as ACCENT } from './site.mjs'; 16 17const day = (ms) => new Date(ms).toISOString().slice(0, 10); 18 19// A song id's title: a site song's from this build, else what it is. 20function titleOf(id, songs) { 21 if (id.startsWith(LISTENER_PREFIX)) return 'a listener’s song'; 22 return songs.find((s) => s.id === id)?.title ?? id; 23} 24 25function Section({ title, children }) { 26 return ( 27 <section className="mt-4"> 28 <h4 className="font-bold mb-1" style={{ color: ACCENT }}> 29 {title} 30 </h4> 31 {children} 32 </section> 33 ); 34} 35 36// "and 2 waiting on Jev, 1 held": what is not public, counted. 37function notShown({ pending, held }) { 38 const parts = [pending && `${pending} waiting on Jev`, held && `${held} held by Jev`].filter(Boolean); 39 return parts.length ? <p className="text-xs opacity-60">Also {parts.join(', ')} (on the data tab).</p> : null; 40} 41 42export function Profile({ context }) { 43 const { id } = useSyncExternalStore(profileStore.subscribe, profileStore.get, profileStore.get); 44 const lobby = useSyncExternalStore(lobbyStore.subscribe, lobbyStore.get, lobbyStore.get); 45 const [account, setAccount] = useState(null); 46 const [error, setError] = useState(null); 47 const [opening, setOpening] = useState(null); 48 useEffect(() => { 49 if (!id) return; 50 let live = true; 51 setAccount(null); 52 setError(null); 53 loadProfile(id).then( 54 (a) => live && setAccount(a), 55 (e) => live && setError(e.message), 56 ); 57 return () => void (live = false); 58 }, [id]); 59 if (!id) return null; 60 const songs = readSongs(); 61 const view = account && profileView(account, lobby.tabs); 62 const editor = context.editorRef.current; 63 64 const playListener = async (card) => { 65 setOpening(card.listenerId); 66 try { 67 await showListenerSong(editor, await loadListenerSong(card.listenerId), { play: true }); 68 } catch (e) { 69 setError(`${card.title} could not be loaded: ${e.message}`); 70 } finally { 71 setOpening(null); 72 } 73 }; 74 75 return ( 76 <div className="px-4 py-3 text-sm font-mono" data-testid="jev-profile"> 77 <button className="underline hover:opacity-50 mb-2" onClick={closeProfile}> 78 ← back 79 </button> 80 {error && <p className="text-red-400">{error}</p>} 81 {!view && !error && <p className="opacity-70">Reading…</p>} 82 {view && ( 83 <> 84 <h3 className="text-lg font-bold">{view.name}</h3> 85 <p className="opacity-70 text-xs"> 86 joined {day(view.joined)} · {view.songs.length} songs · {view.comments.shown.length} comments ·{' '} 87 {view.pitches.shown.length} pitches 88 </p> 89 90 {view.playing.length > 0 && ( 91 <Section title="playing now"> 92 <ul className="space-y-1"> 93 {view.playing.map((t) => ( 94 <li key={t.id} className="flex items-center gap-2"> 95 <span className="grow"> 96 ▶ <b>{t.song ? titleOf(t.song, songs) : (t.title ?? 'their own code')}</b> 97 {t.section && <span className="opacity-60"> · {t.section}</span>} 98 </span> 99 {t.song && ( 100 <button 101 className="rounded border px-2 py-0.5 hover:opacity-80" 102 style={{ borderColor: ACCENT }} 103 onClick={() => listenAlong(t)} 104 disabled={Boolean(lobby.pending)} 105 > 106 {lobby.pending === t.id ? 'asking…' : '🎧 listen along'} 107 </button> 108 )} 109 </li> 110 ))} 111 </ul> 112 </Section> 113 )} 114 115 <Section title="songs"> 116 {view.songs.length ? ( 117 <div className="grid gap-3" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(9rem, 1fr))' }}> 118 {view.songs.map((s) => ( 119 <SongCard key={s.id} song={s} selected={opening === s.listenerId} onSelect={() => playListener(s)} /> 120 ))} 121 </div> 122 ) : ( 123 <p className="opacity-60">No public songs yet.</p> 124 )} 125 {notShown(view.revisions)} 126 </Section> 127 128 <Section title="comments"> 129 {view.comments.shown.length ? ( 130 <ul className="space-y-2"> 131 {view.comments.shown.map((c) => ( 132 <li key={c.id}> 133 <span className="opacity-60 text-xs"> 134 on {titleOf(c.song, songs)} · {day(c.at)} 135 </span> 136 <p className="whitespace-pre-wrap break-words">{c.body}</p> 137 </li> 138 ))} 139 </ul> 140 ) : ( 141 <p className="opacity-60">No public comments.</p> 142 )} 143 {notShown(view.comments)} 144 </Section> 145 146 <Section title="pitches"> 147 {view.pitches.shown.length ? ( 148 <ul className="space-y-2"> 149 {view.pitches.shown.map((p) => ( 150 <li key={p.id}> 151 <span className="opacity-60 text-xs">{day(p.at)}</span> 152 <p className="whitespace-pre-wrap break-words">{p.body}</p> 153 </li> 154 ))} 155 </ul> 156 ) : ( 157 <p className="opacity-60">No public pitches.</p> 158 )} 159 {notShown(view.pitches)} 160 </Section> 161 162 <Section title="played lately"> 163 <PlayedList entries={view.played} context={context} empty="Nothing played yet." /> 164 </Section> 165 </> 166 )} 167 </div> 168 ); 169}