Empathy: Add support for submitting answers to a prompy
[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   window.game.set_active_prompt(state.active_prompt);
63 });
64
65 events.addEventListener("prompt", event => {
66   const prompt = JSON.parse(event.data);
67
68   window.game.add_or_update_prompt(prompt);
69 });
70
71 events.addEventListener("start", event => {
72   const prompt = JSON.parse(event.data);
73
74   window.game.set_active_prompt(prompt);
75 });
76
77 /*********************************************************
78  * Game and supporting classes                           *
79  *********************************************************/
80
81 function copy_to_clipboard(id)
82 {
83   const tmp = document.createElement("input");
84   tmp.setAttribute("value", document.getElementById(id).innerHTML);
85   document.body.appendChild(tmp);
86   tmp.select();
87   document.execCommand("copy");
88   document.body.removeChild(tmp);
89 }
90
91 const GameInfo = React.memo(props => {
92   if (! props.id)
93     return null;
94
95   return (
96     <div className="game-info">
97       <span className="game-id">{props.id}</span>
98       {" "}
99       Share this link to invite friends:{" "}
100       <span id="game-share-url">{props.url}</span>
101       {" "}
102       <button
103         className="inline"
104         onClick={() => copy_to_clipboard('game-share-url')}
105       >Copy Link</button>
106     </div>
107   );
108 });
109
110 const PlayerInfo = React.memo(props => {
111   if (! props.player.id)
112     return null;
113
114   return (
115     <div className="player-info">
116       <span className="players-header">Players: </span>
117       {props.player.name}
118       {props.other_players.map(other => (
119         <span key={other.id}>
120           {", "}
121           {other.name}
122         </span>
123       ))}
124     </div>
125   );
126 });
127
128 function fetch_method_json(method, api = '', data = {}) {
129   const response = fetch(api, {
130     method: method,
131     headers: {
132       'Content-Type': 'application/json'
133     },
134     body: JSON.stringify(data)
135   });
136   return response;
137 }
138
139 function fetch_post_json(api = '', data = {}) {
140   return fetch_method_json('POST', api, data);
141 }
142
143 async function fetch_put_json(api = '', data = {}) {
144   return fetch_method_json('PUT', api, data);
145 }
146
147 class CategoryRequest extends React.PureComponent {
148   constructor(props) {
149     super(props);
150     this.category = React.createRef();
151
152     this.handle_change = this.handle_change.bind(this);
153     this.handle_submit = this.handle_submit.bind(this);
154   }
155
156   handle_change(event) {
157     const category_input = this.category.current;
158     const category = category_input.value;
159
160     if (/[0-9]/.test(category))
161       category_input.setCustomValidity("");
162   }
163
164   handle_submit(event) {
165     const form = event.currentTarget;
166     const category_input = this.category.current;
167     const category = category_input.value;
168
169     /* Prevent the default page-changing form-submission behavior. */
170     event.preventDefault();
171
172     const match = category.match(/[0-9]+/);
173     if (match === null) {
174       category_input.setCustomValidity("Category must include a number");
175       form.reportValidity();
176       return;
177     }
178
179     fetch_post_json("prompts", {
180       items: parseInt(match[0], 10),
181       prompt: category
182     });
183
184     form.reset();
185   }
186
187   render() {
188     return (
189       <div className="category-request">
190         <h2>Submit a Category</h2>
191         <p>
192           Suggest a category to play. Don't forget to include the
193           number of items for each person to submit.
194         </p>
195
196         <form onSubmit={this.handle_submit} >
197           <div className="form-field large">
198             <input
199               type="text"
200               id="category"
201               placeholder="6 things at the beach"
202               required
203               autoComplete="off"
204               onChange={this.handle_change}
205               ref={this.category}
206             />
207           </div>
208
209           <div className="form-field large">
210             <button type="submit">
211               Send
212             </button>
213           </div>
214
215         </form>
216       </div>
217     );
218   }
219 }
220
221 const PromptOptions = React.memo(props => {
222
223   if (props.prompts.length === 0)
224     return null;
225
226   return (
227     <div className="prompt-options">
228       <h2>Vote on Categories</h2>
229       <p>
230         Select any categories below that you'd like to play.
231         You can choose as many as you'd like.
232       </p>
233       {props.prompts.map(p => {
234         return (
235           <button
236             className="vote-button"
237             key={p.id}
238             onClick={() => fetch_post_json(`vote/${p.id}`) }
239           >
240             {p.prompt}
241             <div className="vote-choices">
242               {p.votes.map(v => {
243                 return (
244                   <div
245                     key={v}
246                     className="vote-choice"
247                   >
248                     {v}
249                   </div>
250                 );
251               })}
252             </div>
253           </button>
254         );
255       })}
256     </div>
257   );
258 });
259
260 const LetsPlay = React.memo(props => {
261
262   function handle_click(prompt_id) {
263     fetch_post_json
264   }
265
266   const quorum = Math.round((props.num_players + 1) / 2);
267   const max_votes = props.prompts.reduce(
268     (max_so_far, v) => Math.max(max_so_far, v.votes.length), 0);
269
270   if (max_votes < quorum)
271     return null;
272
273   const candidates = props.prompts.filter(p => p.votes.length >= quorum);
274   const index = Math.floor(Math.random() * candidates.length);
275   const winner = candidates[index];
276
277   return (
278     <div className="lets-play">
279       <h2>Let's Play</h2>
280       <p>
281         That should be enough voting. If you're not waiting for any
282         other players to join, then let's start.
283       </p>
284       <button
285         className="lets-play"
286         onClick={() => fetch_post_json(`start/${winner.id}`) }
287       >
288         Start Game
289       </button>
290     </div>
291   );
292 });
293
294 class ActivePrompt extends React.PureComponent {
295
296   constructor(props) {
297     super(props);
298     const items = props.prompt.items;
299
300     this.state = {
301       submitted: false
302     };
303
304     this.answers = [...Array(items)].map(() => React.createRef());
305     this.handle_submit = this.handle_submit.bind(this);
306   }
307
308   async handle_submit(event) {
309     const form = event.currentTarget;
310
311     /* Prevent the default page-changing form-submission behavior. */
312     event.preventDefault();
313
314     const response = await fetch_post_json(`answer/${this.props.prompt.id}`, {
315       answers: this.answers.map(r => r.current.value)
316     });
317     if (response.status == 200) {
318       const result = await response.json();
319       if (! result.valid) {
320         add_message("danger", result.message);
321         return;
322       }
323     } else {
324       add_message("danger", "An error occurred submitting your answers");
325       return;
326     }
327
328     /* Everything worked. Server is happy with our answers. */
329     form.reset();
330     this.setState({
331         submitted: true
332     });
333   }
334
335   render() {
336     if (this.state.submitted)
337       return (
338         <div className="please-wait">
339           <h2>Answers submitted</h2>
340           <p>
341             Please wait for the rest of the players to submit their answers.
342           </p>
343         </div>
344       );
345
346     return (
347       <div className="active-prompt">
348         <h2>The Game of Empathy</h2>
349         <p>
350           Remember, you're trying to match your answers with
351           what the other players submit.
352           Give {this.props.prompt.items} answers for the following prompt:
353         </p>
354         <h2>{this.props.prompt.prompt}</h2>
355         <form onSubmit={this.handle_submit}>
356           {Array(this.props.prompt.items).fill(null).map((whocares,i) => {
357             return (
358               <div
359                 key={i}
360                 className="form-field large">
361                 <input
362                   type="text"
363                   name={`answer_${i}`}
364                   required
365                   autoComplete="off"
366                   ref={this.answers[i]}
367                 />
368               </div>
369             );
370           })}
371
372           <div
373             key="submit-button"
374             className="form-field large">
375             <button type="submit">
376               Send
377             </button>
378           </div>
379
380         </form>
381       </div>
382     );
383   }
384 }
385
386 class Game extends React.PureComponent {
387   constructor(props) {
388     super(props);
389     this.state = {
390       game_info: {},
391       player_info: {},
392       other_players: [],
393       prompts: []
394     };
395   }
396
397   set_game_info(info) {
398     this.setState({
399       game_info: info
400     });
401   }
402
403   set_player_info(info) {
404     this.setState({
405       player_info: info
406     });
407   }
408
409   set_other_player_info(info) {
410     const other_players_copy = [...this.state.other_players];
411     const idx = other_players_copy.findIndex(o => o.id === info.id);
412     if (idx >= 0) {
413       other_players_copy[idx] = info;
414     } else {
415       other_players_copy.push(info);
416     }
417     this.setState({
418       other_players: other_players_copy
419     });
420   }
421
422   add_or_update_prompt(prompt) {
423     const prompts_copy = [...this.state.prompts];
424     const idx = prompts_copy.findIndex(p => p.id === prompt.id);
425     if (idx >= 0) {
426       prompts_copy[idx] = prompt;
427     } else {
428       prompts_copy.push(prompt);
429     }
430     this.setState({
431       prompts: prompts_copy
432     });
433   }
434
435   set_active_prompt(prompt) {
436     this.setState({
437       active_prompt: prompt
438     });
439   }
440
441   render() {
442     const state = this.state;
443
444     if (state.active_prompt) {
445       return <ActivePrompt
446                prompt={state.active_prompt}
447              />;
448     }
449
450     return [
451       <GameInfo
452         key="game-info"
453         id={state.game_info.id}
454         url={state.game_info.url}
455       />,
456       <PlayerInfo
457         key="player-info"
458         game={this}
459         player={state.player_info}
460         other_players={state.other_players}
461       />,
462       <p key="spacer"></p>,
463       <CategoryRequest
464         key="category-request"
465       />,
466       <PromptOptions
467         key="prompts"
468         prompts={state.prompts}
469       />,
470       <LetsPlay
471         key="lets-play"
472         num_players={1+state.other_players.length}
473         prompts={state.prompts}
474       />
475     ];
476   }
477 }
478
479 ReactDOM.render(<Game
480                   ref={(me) => window.game = me}
481                 />, document.getElementById("empathy"));