jevstrudel.git / website / src / jev / Profile.jsx
Profile.jsx169 lines · 6.5 KB · raw
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}