Empathy: Let React now I'm a good boy and I won't mutate state
[lmno.games] / empathy / empathy.jsx
1 function undisplay(element) {
2   element.style.display="none";
3 }
4
5 function add_message(severity, message) {
6   message = `<div class="message ${severity}" onclick="undisplay(this)">
7 <span class="hide-button" onclick="undisplay(this.parentElement)">&times;</span>
8 ${message}
9 </div>`;
10   const message_area = document.getElementById('message-area');
11   message_area.insertAdjacentHTML('beforeend', message);
12 }
13
14 /*********************************************************
15  * Handling server-sent event stream                     *
16  *********************************************************/
17
18 const events = new EventSource("events");
19
20 events.onerror = function(event) {
21   if (event.target.readyState === EventSource.CLOSED) {
22     setTimeout(() => {
23       add_message("danger", "Connection to server lost.");
24     }, 1000);
25   }
26 };
27
28 events.addEventListener("game-info", event => {
29   const info = JSON.parse(event.data);
30
31   window.game.set_game_info(info);
32 });
33
34 events.addEventListener("player-info", event => {
35   const info = JSON.parse(event.data);
36
37   window.game.set_player_info(info);
38 });
39
40 events.addEventListener("player-enter", event => {
41   const info = JSON.parse(event.data);
42
43   window.game.set_other_player_info(info);
44 });
45
46 events.addEventListener("player-update", event => {
47   const info = JSON.parse(event.data);
48
49   if (info.id === window.game.state.player_info.id)
50     window.game.set_player_info(info);
51   else
52     window.game.set_other_player_info(info);
53 });
54
55 events.addEventListener("game-state", event => {
56   const state = JSON.parse(event.data);
57
58   for (let prompt of state.prompts) {
59     window.game.add_or_update_prompt(prompt);
60   }
61 });
62
63 events.addEventListener("prompt", event => {
64   const prompt = JSON.parse(event.data);
65
66   window.game.add_or_update_prompt(prompt);
67 });
68
69 /*********************************************************
70  * Game and supporting classes                           *
71  *********************************************************/
72
73 function copy_to_clipboard(id)
74 {
75   const tmp = document.createElement("input");
76   tmp.setAttribute("value", document.getElementById(id).innerHTML);
77   document.body.appendChild(tmp);
78   tmp.select();
79   document.execCommand("copy");
80   document.body.removeChild(tmp);
81 }
82
83 const GameInfo = React.memo(props => {
84   if (! props.id)
85     return null;
86
87   return (
88     <div className="game-info">
89       <span className="game-id">{props.id}</span>
90       {" "}
91       Share this link to invite friends:{" "}
92       <span id="game-share-url">{props.url}</span>
93       {" "}
94       <button
95         className="inline"
96         onClick={() => copy_to_clipboard('game-share-url')}
97       >Copy Link</button>
98     </div>
99   );
100 });
101
102 const PlayerInfo = React.memo(props => {
103   if (! props.player.id)
104     return null;
105
106   return (
107     <div className="player-info">
108       <span className="players-header">Players: </span>
109       {props.player.name}
110       {props.other_players.map(other => (
111         <span key={other.id}>
112           {", "}
113           {other.name}
114         </span>
115       ))}
116     </div>
117   );
118 });
119
120 function fetch_method_json(method, api = '', data = {}) {
121   const response = fetch(api, {
122     method: method,
123     headers: {
124       'Content-Type': 'application/json'
125     },
126     body: JSON.stringify(data)
127   });
128   return response;
129 }
130
131 function fetch_post_json(api = '', data = {}) {
132   return fetch_method_json('POST', api, data);
133 }
134
135 async function fetch_put_json(api = '', data = {}) {
136   return fetch_method_json('PUT', api, data);
137 }
138
139 class CategoryRequest extends React.PureComponent {
140   constructor(props) {
141     super(props);
142     this.category = React.createRef();
143
144     this.handle_change = this.handle_change.bind(this);
145     this.handle_submit = this.handle_submit.bind(this);
146   }
147
148   handle_change(event) {
149     const category_input = this.category.current;
150     const category = category_input.value;
151
152     if (/[0-9]/.test(category))
153       category_input.setCustomValidity("");
154   }
155
156   handle_submit(event) {
157     const form = event.currentTarget;
158     const category_input = this.category.current;
159     const category = category_input.value;
160
161     /* Prevent the default page-changing form-submission behavior. */
162     event.preventDefault();
163
164     const match = category.match(/[0-9]+/);
165     if (match === null) {
166       category_input.setCustomValidity("Category must include a number");
167       form.reportValidity();
168       return;
169     }
170
171     fetch_post_json("prompts", {
172       items: parseInt(match[0], 10),
173       prompt: category
174     });
175
176     form.reset();
177   }
178
179   render() {
180     return (
181       <div className="category-request">
182         <h2>Submit a Category</h2>
183         <p>
184           Suggest a category to play. Don't forget to include the
185           number of items for each person to submit.
186         </p>
187
188         <form onSubmit={this.handle_submit} >
189           <div className="form-field large">
190             <input
191               type="text"
192               id="category"
193               placeholder="6 things at the beach"
194               required
195               autoComplete="off"
196               onChange={this.handle_change}
197               ref={this.category}
198             />
199           </div>
200
201           <div className="form-field large">
202             <button type="submit">
203               Send
204             </button>
205           </div>
206
207         </form>
208       </div>
209     );
210   }
211 }
212
213 const PromptOptions = React.memo(props => {
214
215   function handle_click(id) {
216     fetch_post_json(`vote/${id}`);
217   }
218
219   if (props.prompts.length === 0)
220     return null;
221
222   return (
223     <div className="prompt-options">
224       <h2>Vote on Categories</h2>
225       <p>
226         Select any categories below that you'd like to play.
227         You can choose as many as you'd like.
228       </p>
229       {props.prompts.map(p => {
230         return (
231           <button
232             className="vote-button"
233             key={p.id}
234             onClick={() => handle_click(p.id)}
235           >
236             {p.prompt}
237             <div className="vote-choices">
238               {p.votes.map(v => {
239                 return (
240                   <div
241                     key={v}
242                     className="vote-choice"
243                   >
244                     {v}
245                   </div>
246                 );
247               })}
248             </div>
249           </button>
250         );
251       })}
252     </div>
253   );
254 });
255
256 class Game extends React.PureComponent {
257   constructor(props) {
258     super(props);
259     this.state = {
260       game_info: {},
261       player_info: {},
262       other_players: [],
263       prompts: []
264     };
265   }
266
267   set_game_info(info) {
268     this.setState({
269       game_info: info
270     });
271   }
272
273   set_player_info(info) {
274     this.setState({
275       player_info: info
276     });
277   }
278
279   set_other_player_info(info) {
280     const other_players_copy = [...this.state.other_players];
281     const idx = other_players_copy.findIndex(o => o.id === info.id);
282     if (idx >= 0) {
283       other_players_copy[idx] = info;
284     } else {
285       other_players_copy.push(info);
286     }
287     this.setState({
288       other_players: other_players_copy
289     });
290   }
291
292   add_or_update_prompt(prompt) {
293     const prompts_copy = [...this.state.prompts];
294     const idx = prompts_copy.findIndex(p => p.id === prompt.id);
295     if (idx >= 0) {
296       prompts_copy[idx] = prompt;
297     } else {
298       prompts_copy.push(prompt);
299     }
300     this.setState({
301       prompts: prompts_copy
302     });
303   }
304
305   render() {
306     const state = this.state;
307
308     return [
309       <GameInfo
310         key="game-info"
311         id={state.game_info.id}
312         url={state.game_info.url}
313       />,
314       <PlayerInfo
315         key="player-info"
316         game={this}
317         player={state.player_info}
318         other_players={state.other_players}
319       />,
320       <p key="spacer"></p>,
321       <CategoryRequest
322         key="category-request"
323       />,
324       <PromptOptions
325         key="prompts"
326         prompts={state.prompts}
327       />
328     ];
329   }
330 }
331
332 ReactDOM.render(<Game
333                   ref={(me) => window.game = me}
334                 />, document.getElementById("empathy"));