1 var THUMB_MAX = 200; // image max dimensions in px
2 var drawto = document.getElementById('post-area');
3 var last_touched = 0;
4 var thumbnail_blob = null;
5 var get_post_controller; // set in get_new_posts()
6
7 // TODO: Currently using "submitting" for all server actions,
8 // and for all button classes to show "whee" spinner while waiting.
9 // Make this more granular:
10 // * Add "updating", "deleting", etc.
11 // * Style items and show the action text ("Delete" becomes "Deleting...")
12 // in the retrov draw functions, NOT as jquery-style dom changes!!!
13 // * In the main draw() function, set "submitting" to true if any
14 // of the granular ones are going. Otherwise, set it to false
15 // that way, we can still make forms inert with just one variable
16 var editing_post = null;
17 var submitting = false;
18
19 // TODO: Instead of hard-coding the word game displays into the main codebase,
20 // check for rendering callback(s) and set those in the header - include
21 // whatever i've currently got in an example header so it's not lost
22 // and others can use it.
23
24 // TODO: Instead of storing the global 'posts' as an ordered array, store them
25 // with the values hashed by rowid so replacing/deleting them is as simple as
26 // lookup by id rather than looping over all of them to find the rowid (this
27 // also naturally prevents duplicates). I went with a simple ordered array
28 // first because I always prefer the simplest structures, when possible, but a
29 // hash will be much *simpler* for this application!
30
31
32 // ============================================================================
33
34 var daynames = ["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"];
35 var curdate; // For displaying date separators
36
37 function draw(){
38 curdate = (new Date()).getDate(); // start with today
39
40 var attach_label = thumbnail_blob ? 'Attach a different image' : 'Attach an image';
41
42 RV.render(drawto, [
43 ['form', {rvid:'new_post_form', inert:submitting},
44 ['input', {name:'parent', type:'hidden', value:null}],
45 ['.postbox',
46 ['textarea', {name:'txt',rvid:'post_txt',
47 onpaste:onpaste_text, onkeyup:onkeyup_text,
48 }],
49 ['div', {id:'image-preview'}, false],
50 ['.control-row',
51 ['a', {href:'#', onclick:upload_image}, attach_label],
52 ['a', {href:'#', onclick:emoji('post_txt')},
53 ['<span>😀 Add Emoji!</span>']],
54 ['a', {href:'#', onclick:spoiler('post_txt')},
55 ['<span>🥷 Add spoiler!</span>']],
56 ['input', {type:'file', rvid:'imagef', name:'image',
57 accept:"image/*", onchange:make_thumb}],
58 ['div',
59 ['button', {rvid:'postbtn', class:(submitting?'whee':''),
60 onclick:submit_new_post,}, 'Post!'],
61 ],
62 ],
63 ],
64 ],
65 ['.posts', draw_posts()],
66 ]);
67 }
68
69 function emoji(insert_to){
70 return function(e){
71 e.preventDefault();
72 FC.attach_popup(e.target, function(emoji){
73 FC.insert(RV.id[insert_to], emoji);
74 });
75 };
76 }
77
78 function spoiler(insert_to){
79 return function(e){
80 e.preventDefault();
81
82 var st = "";
83 var el_popup = make_popup(e.target,{
84 class: 'spoiler-pop',
85 width: '400px',
86 offset_x: -100,
87 offset_y: -10,
88 });
89 var input_el = null;
90 var button_el = null;
91
92 RV.render(el_popup, [
93 ["button.cancel", {onclick:function(){
94 el_popup.remove();
95 }}, "Cancel"],
96 ["input", {placeholder:"Spoiler here...",
97 oninput:function(e){ st = e.target.value; },
98 oncreate:function(el){input_el = el;},
99 onkeypress:function(e){
100 if(e.key === 'Enter'){
101 e.preventDefault();
102 button_el.click();
103 }
104 },
105 }],
106 ["button.add", {
107 onclick:function(){
108 pasteinto(RV.id[insert_to], `<spoiler>${st}</spoiler>`);
109 el_popup.remove();
110 },
111 oncreate:function(el){button_el = el;},
112 }, "Add"],
113 ]);
114
115 // Put focus in input immediately
116 input_el.focus();
117 };
118 }
119
120 function make_popup(elem, props){
121 // Put it an arbitrary amount over the click target
122 var x = elem.getBoundingClientRect().left + window.scrollX + props.offset_x;
123 var y = elem.getBoundingClientRect().bottom + window.scrollY + props.offset_y;
124 var pop = document.createElement("div");
125 pop.style.left = x+'px';
126 pop.style.top = y+'px';
127 pop.style.width = props.width;
128 pop.classList.add('popup');
129 pop.classList.add(props.class);
130 document.body.appendChild(pop);
131 return pop;
132 }
133
134 function pasteinto(elem, txt){
135 var start = elem.selectionStart;
136 var end = elem.selectionEnd;
137 elem.setRangeText(txt, start, end, 'end');
138 elem.focus();
139 }
140
141 var match_wordplay = /WordPlay.com\s+Daily Puzzle #/;
142 var match_connections= /Connections.*Puzzle #/s;
143 var match_wordle = /Wordle [0-9]/;
144
145 // helper for draw_posts()
146 function make_day(daynum, dayofweek){
147 return {
148 daynum: daynum,
149 dayname: daynames[dayofweek],
150 special1: [],
151 special2: [],
152 posts: []
153 };
154 }
155
156 // undefined in the first param uses browser's default locale
157 var time_fmt = Intl.DateTimeFormat(undefined, {
158 hour: 'numeric',
159 minute: 'numeric',
160 second: 'numeric',
161 hour12: false,
162 });
163
164 function draw_posts(){
165 // Put posts into day and type buckets
166 var days = [];
167 var d; // current day object
168
169 // Reset each time (if we delete, it can change anyway)
170 last_touched = 0;
171
172 // Do data wrangling on posts to simplify the drawing
173 posts.forEach(function(post){
174 // Update the last post we've seen so we know to check for
175 // posts newer than this.
176 if(post.touched > last_touched){
177 last_touched = post.touched;
178 }
179
180 // Get the post's day of the month
181 post.dateobj = new Date(post.posted * 1000);
182 post.daynum = post.dateobj.getDate(); // day of the month
183 //post.time = post.dateobj.toLocaleTimeString('en-GB'); // 24-hour time
184 post.time = time_fmt.format( post.dateobj );
185 if(!d || d.daynum != post.daynum){
186 // Make new day object and add it to the list
187 d = make_day(post.daynum, post.dateobj.getDay());
188 days.push(d);
189 }
190
191 // Detect post type and categorize - special or regular post
192 if(match_wordplay.test(post.txt)){
193 d.special1.push(post);
194 }
195 else if(match_connections.test(post.txt)){
196 d.special2.push(post);
197 }
198 else if(match_wordle.test(post.txt)){
199 d.special1.push(post);
200 }
201 else{
202 d.posts.push(post);
203 }
204 });
205
206 // Now draw!
207 return days.map(function(day){
208 return [
209 ['h2.day', day.dayname],
210 day.posts.map(draw_post),
211 draw_special(2, day),
212 draw_special(1, day),
213 ]
214 });
215 }
216
217 function draw_special(num, day){
218 if(day['special'+num].length < 1){
219 return null;
220 }
221 // since we read left-to-right, put oldest first
222 day['special'+num].sort(function(a,b){
223 return a.posted - b.posted;
224 });
225 return [".special", {class:'special'+num},
226 day['special'+num].map(draw_post)];
227 }
228
229 // Regexes to match simple syntax elements. Note that lookahead and behind
230 // assertions are now supported in browsers.
231 var rx_spoil = /<spoiler>([^<]*)<\/spoiler>/g;
232 var rx_code = /`([^`]+)`/g;
233 var rx_bold = /(?<=^|\s)\*([^*]+)\*(?=\s|$)/g;
234 var rx_ital = /(?<=^|\s)_([^_]+)_(?=\s|$)/g;
235 var rx_url = /(https?:\/\/\S+)/g;
236
237 function draw_post(post){
238 // The post body is either the txt field or the edit box
239 var post_body;
240
241 if(editing_post === post.rowid){
242 post_body = draw_edit_post(post);
243 }
244 else if(post.deleted){
245 return ['.post', ['i','Post deleted']];
246 }
247 else
248 {
249
250 // Break post body into lines for processing
251 var lines = post.txt.split(/\r?\n/);
252 lines = lines.map(function(line){
253 // Using raw HTML here because it's much easier to do.
254 line = line.replace(rx_spoil, '<span class="spoiler">$1</span>');
255 line = line.replace(rx_code, '<code>$1</code>');
256 line = line.replace(rx_bold, '<b>$1</b>');
257 line = line.replace(rx_ital, '<i>$1</i>');
258 line = line.replace(rx_url, '<a href="$1" target="_blank">$1</a>');
259
260 // Ensure blank lines render by giving them a br
261 if(line.length === 0){
262 line = '<br>';
263 }
264
265 // Each line goes in a div. Use HTML tag to force line to
266 // be rendered as a raw HTML string.
267 return ['<div>'+line+'</div>'];
268 });
269
270 post_body = ['.txt', lines];
271 }
272
273 var u = users[post.user];
274 if(!u){ u = {name:'_'}; }
275 return [
276 ['.post', {'id':'post-'+post.rowid},
277 ['.info',
278 ['img.avatar', {src:'/avatars/'+post.user+'.png'}],
279 ['div',
280 ['b', u.name],
281 ['br'],
282 ['i', post.time],
283 draw_add_reaction_link(post),
284 draw_edit_link(post),
285 ],
286 ],
287 post_body,
288 draw_post_image(post),
289 draw_reactions(post),
290 ],
291 ];
292 }
293
294 function draw_reactions(post){
295 var rs = post.reactions;
296 if(rs.length < 1){ return null; }
297
298 return ['.reactions', rs.map(function(r){
299 return ['div.reaction',
300 ['span.user', users[r.user].name + ': '],
301 ['span.emoji', r.emoji],
302 ((r.txt && r.txt.length > 0) ? ['span.txt', '"' + r.txt + '"'] : null),
303 ];
304 })];
305 }
306
307 function draw_post_image(post){
308 if(!post.filename){ return null; }
309 return ['.image',
310 ['a', {href:'/uploads/'+post.filename, target: '_blank'},
311 ['img', {src:'/uploads/tn_'+post.filename}],
312 ],
313 ['br'],
314 ['i.small', "(Click image to view full size)"],
315 ];
316 }
317
318 function draw_edit_link(post){
319 if(post.user !== my_id) return null;
320 return ['a.edit-link',
321 {href:'#',onclick:edit_post_click(post.rowid)}, "Edit/Delete"];
322 }
323
324 function draw_add_reaction_link(post){
325 var picked_emoji = '😀';
326 var st = "";
327 var el_popup = null;
328 var input_el = null;
329 var pick_el = null;
330
331 function emoji_callback(emoji){
332 picked_emoji = emoji;
333 FC.close();
334 draw_self();
335 }
336
337 function draw_self(){
338 RV.render(el_popup, [
339 ["button.cancel", {onclick:function(){
340 el_popup.remove();
341 }}, "Cancel"],
342 ["button.pick", {
343 onclick:function(e){
344 FC.attach_popup(e.target, emoji_callback);
345 },
346 oncreate:function(el){pick_el = el;},
347 },
348 "Pick ",
349 [`<span class="picked-emoji">${picked_emoji}</span>`],
350 ],
351 ["input", {placeholder:"Note (optional)",
352 oninput:function(e){ st = e.target.value; },
353 oncreate:function(el){input_el = el;},
354 onkeypress:function(e){
355 if(e.key === 'Enter'){
356 e.preventDefault();
357 RV.id.reaction_add_btn.click();
358 }
359 },
360 }],
361 ["button.add", {
362 onclick:function(){
363 submit_reaction(post, picked_emoji, input_el.value);
364 //el_popup.remove();
365 },
366 rvid:'reaction_add_btn',
367 }, "Add"],
368 ]);
369 }
370
371 return ['a.add_reaction', {href:'#', onclick:function(e){
372 document.querySelectorAll('.reaction-pop').forEach(function(el){
373 el.remove(); // Kill 'em all. Easier to deal with just one.
374 });
375 e.preventDefault();
376
377 el_popup = make_popup(e.target,{
378 class: 'reaction-pop',
379 width: '520px',
380 offset_x: -60,
381 offset_y: -60,
382 });
383
384 draw_self();
385 FC.attach_popup(e.target, emoji_callback);
386 }},
387 ['span.label', 'Add reaction']
388 ];
389 }
390
391 function edit_post_click(post_id){
392 return function(e){
393 editing_post = post_id;
394 draw();
395 e.preventDefault();
396 };
397 }
398
399 function draw_edit_post(post){
400 return ['form', {rvid:'edit_post_form', inert:submitting},
401 ['input', {type:'hidden',name:'rowid',value:post.rowid}],
402 ['.postbox',
403 ['textarea', {
404 name:'txt', rvid:'edit_post_txt',
405 oncreate:function(elem){
406 // Resize textarea to fit content to edit
407 if(elem.scrollHeight > elem.clientHeight){
408 elem.style.height = elem.scrollHeight + 'px';
409 }
410 }}, post.txt
411 ],
412 ['.control-row',
413 ['a', {href:'#', onclick:emoji('edit_post_txt')},
414 ['<span>😀 Add Emoji!</span>']],
415 ['a', {href:'#', onclick:spoiler('edit_post_txt')},
416 ['<span>🥷 Add spoiler!</span>']],
417 ['a.delete', {rvid:'postdeletebtn', class:(submitting?'whee':''),
418 onclick:submit_delete_post}, 'Delete'],
419 ['a.cancel', {class:(submitting?'whee':''),
420 onclick:cancel_edit_post}, 'Cancel Editing'],
421 ['div.buttons',
422 ['button', {rvid:'posteditbtn', class:(submitting?'whee':''),
423 onclick:submit_edit_post,}, 'Submit Edit!'],
424 ],
425 ],
426 ],
427 ];
428 }
429
430 function onpaste_text(e){
431 e.preventDefault();
432
433 // Get pasted text and modify it
434 var pasted = event.clipboardData.getData("text");
435
436 // Strip end of Wordplay text. NOTE the 's' flag matches over newlines!
437 pasted = pasted.replace(/Play the game.*#wordplay/s, '');
438
439 // Add newlines to Connections paste
440 if(match_connections.test(pasted)){
441 pasted += "\n\n";
442 }
443
444 // Add newlines to Wordle paste
445 if(match_wordle.test(pasted)){
446 pasted += "\n\n";
447 }
448
449 // Manually perform the "paste" on the current text and update area.
450 var prev_txt = RV.id.post_txt.value;
451 var start = RV.id.post_txt.selectionStart;
452 var end = RV.id.post_txt.selectionEnd;
453 RV.id.post_txt.value = prev_txt.slice(0, start) + pasted + prev_txt.slice(end);
454 }
455
456 function onkeyup_text(){
457 // After typing or pasting (via keyboard), resize textarea as needed
458 var t = RV.id.post_txt;
459 if(t.scrollHeight > t.clientHeight){
460 t.style.height = t.scrollHeight + 'px';
461 }
462 }
463
464
465 // Upload image
466 // ===========================================================================
467 function upload_image(e){
468 e.preventDefault();
469 RV.id.imagef.click();
470 }
471
472 function make_thumb(){
473 var preview_thumb = document.createElement('img');
474 var preview_area = document.getElementById('image-preview');
475 preview_area.replaceChildren();
476 preview_area.appendChild(preview_thumb);
477
478 var file = this.files[0];
479 var reader = new FileReader();
480 var img = new Image();
481 reader.onload = function(e){
482 // STEP 2: load image
483 img.src = e.src = e.target.result;
484 };
485 img.onload = function(e){
486 // STEP 3: resize on canvas, preview
487 var biggest_dim = Math.max(img.width, img.height);
488 var scale = THUMB_MAX < biggest_dim ? THUMB_MAX / biggest_dim : 1;
489 var w = img.width * scale;
490 var h = img.height * scale;
491
492 // Make a canvas element for the resizing
493 var canvas = document.createElement('canvas');
494 canvas.width = w; canvas.height = h;
495 var context = canvas.getContext('2d');
496 context.drawImage(img, 0, 0, w, h);
497 preview_thumb.src = canvas.toDataURL(file.type);
498
499 // Save blob data to append to form for uploading later
500 canvas.toBlob(function(b){
501 thumbnail_blob = b;
502 draw();
503 }, file.type);
504 };
505 // STEP 1: read file
506 reader.readAsDataURL(file);
507 }
508
509 // New Post
510 // ===========================================================================
511 function submit_new_post(e){
512 e.preventDefault();
513 if(RV.id.post_txt.value == '' && !thumbnail_blob){
514 RV.id.postbtn.textContent = "Nothing to post.";
515 setTimeout(function(){
516 RV.id.postbtn.textContent = 'Post!';
517 },1000);
518 return;
519 }
520 submitting = true;
521 draw();
522 RV.id.postbtn.textContent = "Posting...";
523 var data = new FormData(RV.id.new_post_form);
524 if(thumbnail_blob){ data.append('thumb', thumbnail_blob); }
525 var xhr = new XMLHttpRequest();
526 xhr.onload = upload_done;
527 xhr.upload.addEventListener('progress', upload_progress);
528 xhr.open('POST', "fam.php?r=posts", true);
529 xhr.onerror = function(){alert('Network error?');}
530 xhr.send(data);
531 }
532
533 function upload_progress(e){
534 var p = (e.loaded / e.total * 100).toFixed(1);
535 RV.id.postbtn.textContent = "Posting... "+p+"%";
536 }
537
538 function upload_done(e){
539 submitting = false;
540 if(e.target.status !== 200){
541 alert("Error! Return status was: "+e.target.status);
542 return;
543 }
544 var posted = JSON.parse(e.target.response);
545 add_or_replace_posts(posted);
546 RV.id.post_txt.value = ""; // clear textarea
547 RV.id.imagef.value = ""; // clear file input
548 thumbnail_blob = null;
549 document.getElementById('image-preview').replaceChildren();
550 draw();
551 }
552
553 // Edit Post
554 // ===========================================================================
555 function edit_progress(e){
556 RV.id.posteditbtn.textContent = "Submitting "+e.loaded+"/"+e.total+"...";
557 }
558 function edit_done(e){
559 submitting = false;
560 if(e.target.status !== 200){
561 alert("Error! Return status was: "+e.target.status);
562 return;
563 }
564 var posted = JSON.parse(e.target.response);
565 replace_posts(posted);
566 editing_post = null;
567 draw();
568 }
569
570 function submit_edit_post(e){
571 e.preventDefault();
572 if(RV.id.edit_post_txt.value == '' && !thumbnail_blob){
573 RV.id.posteditbtn.textContent = "Nothing to post.";
574 setTimeout(function(){
575 RV.id.posteditbtn.textContent = 'Submit Edit!';
576 },1000);
577 return;
578 }
579 submitting = true;
580 draw();
581 RV.id.posteditbtn.textContent = "Submitting...";
582 var data = new FormData(RV.id.edit_post_form);
583 var xhr = new XMLHttpRequest();
584 xhr.onload = edit_done;
585 xhr.upload.addEventListener('progress', edit_progress);
586 xhr.open('post', "fam.php?r=post", true);
587 xhr.onerror = function(){alert('Network error?');}
588 xhr.send(data);
589 }
590
591 function cancel_edit_post(e){
592 e.preventDefault();
593 editing_post = null;
594 draw();
595 }
596
597 function delete_done(post){
598 return function(e){
599 submitting = false;
600 editing_post = null;
601 if(e.target.status !== 200){
602 alert("Error! Return status was: "+e.target.status);
603 return;
604 }
605 post.deleted = true;
606 draw();
607 };
608 }
609
610 function submit_delete_post(e){
611 e.preventDefault();
612 var data = new FormData(RV.id.edit_post_form);
613 var rowid = data.get('rowid');
614 var post = get_post(rowid);
615
616 var conf = "Are you sure you wish to delete this post?";
617 if(post.filename){
618 conf += " (This will also delete the attached file.)";
619 }
620 var yes = confirm(conf);
621 if(!yes) return;
622 submitting = true;
623 draw();
624 RV.id.postdeletebtn.textContent = "Deleting...";
625 // Get form data so we can get the ID of the post to delete.
626 var xhr = new XMLHttpRequest();
627 xhr.onload = delete_done(post);
628 xhr.open('delete', "fam.php?r=post&rowid="+rowid, true);
629 xhr.onerror = function(){alert('Network error?');}
630 xhr.send();
631 }
632
633 // Add reaction
634 // ===========================================================================
635 function reaction_done(e){
636 document.querySelector('.reaction-pop').remove();
637 if(e.target.status !== 200){
638 alert("Error! Return status was: "+e.target.status);
639 return;
640 }
641 // We get back the whole post with reactions for redrawing!
642 var posted = JSON.parse(e.target.response);
643 replace_posts(posted);
644 draw();
645 }
646
647 function submit_reaction(post, emoji, txt){
648 RV.id.reaction_add_btn.textContent = '...';
649 RV.id.reaction_add_btn.classList.add('whee');
650 RV.id.reaction_add_btn.inert = true;
651 var data = new FormData();
652 data.append('post', post.rowid);
653 data.append('emoji', emoji);
654 data.append('txt', txt);
655 var xhr = new XMLHttpRequest();
656 xhr.onload = reaction_done;
657 xhr.open('post', "fam.php?r=reaction", true);
658 xhr.onerror = function(){alert('Network error?');}
659 xhr.send(data);
660 }
661
662 // Post manipulation utilities
663 // ===========================================================================
664 function get_post(rowid){
665 var post = null;
666 posts.forEach(function(p){ if(Number(p.rowid)===Number(rowid)) post = p; });
667 if(!post){ alert("Unable to find post with rowid "+rowid); }
668 return post;
669 }
670
671 function add_or_replace_posts(list){
672 var prev_rowid = null;
673
674 // Parse incoming JSON reactions
675 list.forEach(function(p){
676 p.reactions = JSON.parse(p.reactions);
677
678 // Temporary: replace existing - see todo at top of file
679 // and yes, this is totally redundant with what happens
680 // below in a moment.
681 posts.forEach(function(o, idx){
682 if(o.rowid==p.rowid){
683 posts[idx] = p;
684 }
685 });
686 });
687
688 // Add one or more posts to the list
689 posts = posts.concat(list)
690 // Sort by rowid, or touched recentness
691 .sort(function(a,b){
692 if(b.rowid === a.rowid){
693 return b.touched - a.touched;
694 }
695 return b.rowid - a.rowid;
696 })
697 // Duplicate rows are now consecutive, filter them out
698 .filter(function(post, i, list){
699 var dup = prev_rowid === post.rowid
700 prev_rowid = post.rowid;
701 return !dup;
702 });
703 }
704
705 function replace_posts(replacement_posts){
706 var replacing_ids = replacement_posts.map(function(p){
707 return p.rowid;
708 });
709 // Remove a post for replacement
710 posts = posts.filter(function(post, i, list){
711 return !replacing_ids.includes(post.rowid);
712 });
713 // Now re-add them
714 add_or_replace_posts(replacement_posts);
715 }
716
717 async function get_new_posts() {
718 // to test with a delay, ex.: &sleep=4
719 var url = "fam.php?r=posts&after=" + last_touched;
720
721 get_post_controller = new AbortController();
722 try{
723 var response = await fetch(url, {
724 signal: get_post_controller.signal,
725 });
726 }
727 catch(error) {
728 if(error.name === 'AbortError'){
729 console.log("Info: Fetch aborted due to new post being posted.");
730 return;
731 }
732 throw error; // not the AbortError! Re-throw this.
733 }
734
735 if (!response.ok) {
736 alert('New post request failure: ' + response.status);
737 return;
738 }
739
740 // Append any new items to the list and re-draw
741 var json = await response.json();
742 if(json.length > 0){
743 add_or_replace_posts(json);
744
745 // Pause all drawing while we're editing an existing post.
746 if(editing_post){
747 return;
748 }
749
750 draw();
751 }
752 }
753
754 // Initial draw and start polling.
755 draw();
756 setInterval(get_new_posts, 5000); // 5 seconds