tuiter 2006
at main 316 lines 12 kB view raw
1// Centralized application JavaScript for Tuiter 2006 2// This file replaces inline scripts previously embedded in templates. 3 4(function(){ 5 'use strict'; 6 7 // Char count updater for post box 8 function updateCharCount(remaining){ 9 var charCountEl = document.getElementById('char-count'); 10 if (charCountEl) { 11 charCountEl.textContent = remaining; 12 charCountEl.style.color = remaining < 20 ? 'red' : (remaining < 50 ? 'orange' : 'green'); 13 } 14 } 15 16 // Expose to global for compatibility 17 window.updateCharCount = updateCharCount; 18 19 // Form wiring for post-box forms: update char count and reset on htmx:afterRequest 20 function initPostBoxForms(){ 21 var forms = document.querySelectorAll('.post-box-form'); 22 forms.forEach(function(form){ 23 var ta = form.querySelector('textarea[data-maxlength]'); 24 var max = ta && parseInt(ta.getAttribute('data-maxlength'), 10) || 140; 25 if (ta){ 26 ta.addEventListener('input', function(){ updateCharCount(max - this.value.length); }); 27 // set initial 28 updateCharCount(max - ta.value.length); 29 } 30 31 // Listen for HTMX afterRequest to reset the form 32 form.addEventListener('htmx:afterRequest', function(evt){ 33 try{ form.reset(); if (ta) updateCharCount(max); } catch(e){ console.log('form reset error', e); } 34 }); 35 }); 36 } 37 38 // Lightbox handling (moved from footer template) 39 function initLightbox(){ 40 var overlay = document.getElementById('lightbox-overlay'); 41 if (!overlay) return; 42 var img = document.getElementById('lightbox-img'); 43 var video = document.getElementById('lightbox-video'); 44 45 function showOverlay(){ overlay.classList.add('visible'); overlay.setAttribute('aria-hidden','false'); } 46 function hideOverlay(){ overlay.classList.remove('visible'); overlay.setAttribute('aria-hidden','true'); } 47 48 function openImage(src, alt){ 49 try{ video.pause(); } catch(e){} 50 video.removeAttribute('src'); 51 while(video.firstChild) video.removeChild(video.firstChild); 52 try{ video.load(); } catch(e){} 53 video.style.display = 'none'; 54 55 img.src = src; 56 img.alt = alt || ''; 57 img.style.display = ''; 58 showOverlay(); 59 } 60 61 function openVideo(src, mime){ 62 img.src = ''; 63 img.alt = ''; 64 img.style.display = 'none'; 65 66 var type = mime || 'video/mp4'; 67 while(video.firstChild) video.removeChild(video.firstChild); 68 var source = document.createElement('source'); 69 source.src = src; 70 source.type = type; 71 video.appendChild(source); 72 73 video.style.display = ''; 74 try{ video.load(); var p = video.play(); if (p && typeof p.then === 'function') p.catch(function(){}); } catch(e){ console.log('DEBUG: video play error', e); } 75 showOverlay(); 76 } 77 78 function closeLightbox(){ 79 try{ video.pause(); } catch(e){} 80 video.removeAttribute('src'); 81 while(video.firstChild) video.removeChild(video.firstChild); 82 try{ video.load(); } catch(e){} 83 84 img.src = ''; 85 img.alt = ''; 86 img.style.display = ''; 87 hideOverlay(); 88 } 89 90 document.addEventListener('click', function(e){ 91 var t = e.target; 92 if (!t || !t.classList) return; 93 94 if (t.classList.contains('post-image')){ 95 e.preventDefault(); 96 var parent = t.closest('a'); 97 var href = parent && parent.getAttribute('href'); 98 if (href) openImage(href, t.getAttribute('alt')); 99 return; 100 } 101 102 if (t.classList.contains('post-video-thumb')){ 103 e.preventDefault(); 104 var parent = t.closest('a'); 105 var href = parent && parent.getAttribute('href'); 106 var mime = parent && parent.dataset && parent.dataset.mime; 107 if (href) openVideo(href, mime || 'video/mp4'); 108 return; 109 } 110 111 if (t.id === 'lightbox-overlay') closeLightbox(); 112 }, false); 113 114 document.addEventListener('keydown', function(e){ if (e.key === 'Escape') closeLightbox(); }); 115 } 116 117 // Initialize banner backgrounds set via data attributes 118 function initProfileBanners(){ 119 var nodes = document.querySelectorAll('[data-banner-url]'); 120 nodes.forEach(function(n){ 121 var url = n.getAttribute('data-banner-url'); 122 if (url && url !== '') n.style.backgroundImage = "url('" + url + "')"; 123 }); 124 } 125 126 // Setup video element styling for embedded videos 127 function initVideoStyling(){ 128 var vids = document.querySelectorAll('.video-embedded'); 129 vids.forEach(function(v){ 130 v.style.maxWidth = '100%'; 131 v.style.height = 'auto'; 132 v.style.background = '#000'; 133 }); 134 135 var lightboxVideo = document.getElementById('lightbox-video'); 136 if (lightboxVideo){ lightboxVideo.style.maxWidth = '100%'; lightboxVideo.style.maxHeight = '80vh'; lightboxVideo.style.display = 'none'; } 137 } 138 139 // Post page initialization: auto-scroll highlighted post and toggle flat/nested 140 function initPostPage(){ 141 // Auto-scroll to highlighted post 142 var highlighted = document.querySelector('.highlighted-post'); 143 if (highlighted) { 144 highlighted.scrollIntoView({behavior: 'smooth', block: 'center'}); 145 highlighted.style.transition = 'box-shadow 0.4s ease'; 146 highlighted.style.boxShadow = '0 0 0 3px rgba(255,204,51,0.6)'; 147 setTimeout(function(){ highlighted.style.boxShadow = ''; }, 2000); 148 } 149 150 // Toggle flat/nested views 151 (function() { 152 var flatBtn = document.getElementById('toggle-flat'); 153 var nestedBtn = document.getElementById('toggle-nested'); 154 var replies = document.getElementById('replies-container'); 155 if (!flatBtn || !nestedBtn || !replies) return; 156 flatBtn.addEventListener('click', function(){ 157 flatBtn.classList.add('active'); 158 nestedBtn.classList.remove('active'); 159 replies.classList.add('flat-view'); 160 }); 161 nestedBtn.addEventListener('click', function(){ 162 nestedBtn.classList.add('active'); 163 flatBtn.classList.remove('active'); 164 replies.classList.remove('flat-view'); 165 }); 166 })(); 167 } 168 169 // Reply input handling: create a slim chat-like reply input under a post or chat bubble 170 function closeOpenReplyInput(){ 171 var ex = document.querySelector('.reply-input-container.absolute'); 172 if (ex && ex.parentNode) ex.parentNode.removeChild(ex); 173 // remove transient listeners if any 174 try{ window.removeEventListener('scroll', closeOpenReplyInput); window.removeEventListener('resize', closeOpenReplyInput); } catch(e){} 175 } 176 177 function createReplyInput(refEl, insertAfterEl){ 178 // refEl is typically the clicked reply-button element; insertAfterEl is optional contextual element 179 if (!refEl) return null; 180 // Close any existing reply input 181 closeOpenReplyInput(); 182 183 var container = document.createElement('div'); 184 container.className = 'reply-input-container absolute active'; 185 container.setAttribute('role','region'); 186 container.setAttribute('aria-label','Reply input'); 187 188 // Try to obtain signed-in avatar from the page-level container data attribute 189 var siteContainer = document.querySelector('.container'); 190 var avatarUrl = siteContainer && siteContainer.dataset && siteContainer.dataset.signedInAvatar ? siteContainer.dataset.signedInAvatar : ''; 191 192 // Avatar square (if available) or placeholder 193 if (avatarUrl && avatarUrl !== ''){ 194 try{ 195 var avatarEl = document.createElement('img'); 196 avatarEl.className = 'reply-avatar-square'; 197 avatarEl.src = avatarUrl; 198 avatarEl.alt = 'Your avatar'; 199 avatarEl.setAttribute('aria-hidden','true'); 200 container.appendChild(avatarEl); 201 } catch(e){ /* ignore image construction errors */ } 202 } else { 203 var ph = document.createElement('div'); 204 ph.className = 'reply-avatar-placeholder'; 205 ph.setAttribute('aria-hidden','true'); 206 container.appendChild(ph); 207 } 208 209 var input = document.createElement('input'); 210 input.type = 'text'; 211 input.className = 'reply-input'; 212 input.setAttribute('placeholder', 'Write a reply...'); 213 input.setAttribute('aria-label', 'Write a reply'); 214 215 var btn = document.createElement('button'); 216 btn.className = 'reply-submit'; 217 btn.type = 'button'; 218 btn.textContent = 'Reply'; 219 // No-op click handler for now, keep a debug log 220 btn.addEventListener('click', function(ev){ ev.preventDefault(); try{ console.debug('Reply button clicked (no-op)'); } catch(e){} }); 221 222 container.appendChild(input); 223 container.appendChild(btn); 224 225 // Append to body to avoid layout shifts 226 document.body.appendChild(container); 227 228 // Compute position based on refEl (reply-button) bounding rect and available space 229 try{ 230 var rbRect = refEl.getBoundingClientRect(); 231 var containerRect = siteContainer ? siteContainer.getBoundingClientRect() : { left: 8, width: Math.min(window.innerWidth - 16, 1000) }; 232 233 // desired width: try to fit inside main container, account for avatar column (~44px) 234 var desiredWidth = Math.min(760, Math.max(320, Math.floor(containerRect.width - 56))); 235 236 // center the input around the reply button horizontally but keep it inside viewport/container 237 var left = window.scrollX + Math.max(containerRect.left + 12, Math.min(rbRect.left + window.scrollX - (desiredWidth/2) + (rbRect.width/2), containerRect.left + window.scrollX + containerRect.width - desiredWidth - 12)); 238 var top = window.scrollY + rbRect.top + rbRect.height + 8; // place just below the button 239 240 container.style.position = 'absolute'; 241 container.style.left = left + 'px'; 242 container.style.top = top + 'px'; 243 container.style.width = desiredWidth + 'px'; 244 container.style.zIndex = 9999; 245 } catch(e){ console.debug('reply input positioning error', e); } 246 247 // Focus the input for convenience 248 try{ input.focus(); } catch(e){} 249 250 // Close when user scrolls or resizes to avoid floating orphaned inputs 251 try{ window.addEventListener('scroll', closeOpenReplyInput, { passive: true }); window.addEventListener('resize', closeOpenReplyInput); } catch(e){} 252 253 return container; 254 } 255 256 function initReplyButtons(){ 257 // Toggle reply input when a reply button is clicked. Use event delegation. 258 document.addEventListener('click', function(e){ 259 var t = e.target; 260 if (!t || !t.closest) return; 261 262 // If a reply-button (or child) was clicked 263 var rb = t.closest('.reply-button'); 264 if (!rb) return; 265 e.preventDefault(); 266 267 // Find contextual element for width calculation (prefer post-content, then chat-bubble) 268 var postContent = rb.closest('.post-content'); 269 var chatBubble = rb.closest('.chat-bubble'); 270 var chatNode = rb.closest('.chat-node'); 271 var post = rb.closest('.post'); 272 273 var contextEl = postContent || chatBubble || chatNode || post; 274 275 // If there's already a reply input visible, toggle it closed 276 var existing = document.querySelector('.reply-input-container.absolute'); 277 if (existing){ 278 closeOpenReplyInput(); 279 // If the existing was opened for the same reply-button, stop here (toggle) 280 return; 281 } 282 283 createReplyInput(rb, contextEl); 284 }, false); 285 286 // Close the reply input when clicking outside of it or a reply-button 287 document.addEventListener('click', function(e){ 288 var t = e.target; 289 if (!t) return; 290 if (t.closest && (t.closest('.reply-input-container') || t.closest('.reply-button'))) return; 291 closeOpenReplyInput(); 292 }, false); 293 } 294 295 // On DOM ready 296 document.addEventListener('DOMContentLoaded', function(){ 297 initLightbox(); 298 initProfileBanners(); 299 initVideoStyling(); 300 initPostBoxForms(); 301 302 // ensure initial char count reflects current textarea 303 var ta = document.getElementById('status-input'); 304 if (ta) updateCharCount(140 - ta.value.length); 305 306 // init post page behaviour if present 307 initPostPage(); 308 309 // initialize reply button behaviour 310 initReplyButtons(); 311 }); 312 313 // expose initPostPage for compatibility with small inline stub 314 window.initPostPage = initPostPage; 315 316})();