colorful rat Ratfactor.com > Dave's Repos

famsite

A super tiny social site for families
git clone http://ratfactor.com/repos/famsite/famsite.git

famsite/fam.js

Download raw file: fam.js

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