tuiter 2006
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})();