1/* ============================================================ 2 WikiLive â YouTube Video Search Module 3 Triggered automatically on every search query. 4 ============================================================ */ 5(function () { 6 'use strict'; 7 8 // ââ Utility helpers ââââââââââââââââââââââââââââââââââââââ 9 function fmtDuration(iso) { 10 if (!iso) return ''; 11 const m = iso.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/); 12 if (!m) return ''; 13 const h = parseInt(m[1] || 0), min = parseInt(m[2] || 0), s = parseInt(m[3] || 0); 14 if (h) return `${h}:${String(min).padStart(2,'0')}:${String(s).padStart(2,'0')}`; 15 return `${min}:${String(s).padStart(2,'0')}`; 16 } 17 18 function fmtCount(n) { 19 if (n == null) return ''; 20 if (n >= 1e9) return (n / 1e9).toFixed(1).replace(/\.0$/, '') + 'B'; 21 if (n >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M'; 22 if (n >= 1e3) return (n / 1e3).toFixed(1).replace(/\.0$/, '') + 'K'; 23 return n.toLocaleString(); 24 } 25 26 function fmtSubs(n) { 27 if (!n) return ''; 28 const num = parseInt(n); 29 return fmtCount(num) + ' subscribers'; 30 } 31 32 function relativeTime(iso) { 33 if (!iso) return ''; 34 const diff = Date.now() - new Date(iso).getTime(); 35 const s = Math.floor(diff / 1000); 36 if (s < 60) return 'just now'; 37 if (s < 3600) return `${Math.floor(s/60)}m ago`; 38 if (s < 86400) return `${Math.floor(s/3600)}h ago`; 39 if (s < 86400*30) return `${Math.floor(s/86400)}d ago`; 40 if (s < 86400*365) return `${Math.floor(s/(86400*30))}mo ago`; 41 return `${Math.floor(s/(86400*365))}y ago`; 42 } 43 44 function esc(str) { 45 return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); 46 } 47 48 // ââ Watch history (localStorage) âââââââââââââââââââââââââ 49 const WatchHistory = { 50 KEY: 'yt_watch_history', 51 get() { try { return JSON.parse(localStorage.getItem(this.KEY) || '[]'); } catch { return []; } }, 52 add(video) { 53 let h = this.get().filter(v => v.id !== video.id); 54 h.unshift({ id: video.id, title: video.title, thumb: video.thumbnail.medium, ts: Date.now() }); 55 if (h.length > 30) h = h.slice(0, 30); 56 localStorage.setItem(this.KEY, JSON.stringify(h)); 57 }, 58 has(id) { return this.get().some(v => v.id === id); } 59 }; 60 61 // ââ Saved / Watch Later âââââââââââââââââââââââââââââââââââ 62 const Saved = { 63 KEY: 'yt_saved', 64 get() { try { return JSON.parse(localStorage.getItem(this.KEY) || '[]'); } catch { return []; } }, 65 toggle(video) { 66 let s = this.get(); 67 const i = s.findIndex(v => v.id === video.id); 68 if (i >= 0) { s.splice(i, 1); } else { s.unshift({ ...video, savedAt: Date.now() }); } 69 localStorage.setItem(this.KEY, JSON.stringify(s)); 70 return i < 0; // true = added 71 }, 72 has(id) { return this.get().some(v => v.id === id); } 73 }; 74 75 // ââ Main module âââââââââââââââââââââââââââââââââââââââââââ 76 const YTSearch = { 77 panel: null, 78 grid: null, 79 playerArea: null, 80 currentQ: '', 81 currentOrder:'relevance', 82 nextToken: null, 83 prevToken: null, 84 loading: false, 85 currentVideos: [], 86 87 init() { 88 this._createPanel(); 89 }, 90 91 _createPanel() { 92 if (document.getElementById('yt-panel')) return; 93 const wrap = document.createElement('div'); 94 wrap.id = 'yt-panel'; 95 wrap.className = 'yt-panel yt-hidden'; 96 wrap.innerHTML = ` 97 <div class="yt-header"> 98 <div class="yt-header-left">
99 <span class="yt-logo">â¶</span> 100 <span class="yt-title">YouTube Videos</span> 101 <span class="yt-count" id="yt-count"></span> 102 </div> 103 <div class="yt-tabs" id="yt-tabs"> 104 <button class="yt-tab yt-tab-active" data-order="relevance">Relevance</button> 105 <button class="yt-tab" data-order="date">Latest</button> 106 <button class="yt-tab" data-order="viewCount">Most Viewed</button> 107 <button class="yt-tab" data-order="rating">Top Rated</button> 108 </div> 109 <div class="yt-header-right"> 110 <button class="yt-saved-btn" id="yt-saved-btn" title="Watch Later">ð Saved</button> 111 <button class="yt-collapse-btn" id="yt-collapse-btn" title="Collapse">â²</button> 112 </div> 113 </div> 114 <div class="yt-player-area yt-hidden" id="yt-player-area"></div> 115 <div class="yt-grid" id="yt-grid"> 116 <div class="yt-placeholder" id="yt-placeholder">Search for something to see YouTube videos</div> 117 </div> 118 <div class="yt-footer" id="yt-footer"></div>`; 119 120 // Insert after chatContainer 121 const chat = document.getElementById('chatContainer'); 122 if (chat && chat.parentNode) { 123 chat.parentNode.insertBefore(wrap, chat.nextSibling); 124 } else { 125 document.body.appendChild(wrap); 126 } 127 128 this.panel = wrap; 129 this.grid = wrap.querySelector('#yt-grid'); 130 this.playerArea = wrap.querySelector('#yt-player-area'); 131 132 // Tabs 133 wrap.querySelector('#yt-tabs').addEventListener('click', (e) => { 134 const btn = e.target.closest('.yt-tab'); 135 if (!btn) return; 136 this.currentOrder = btn.dataset.order; 137 wrap.querySelectorAll('.yt-tab').forEach(b => b.classList.remove('yt-tab-active')); 138 btn.classList.add('yt-tab-active'); 139 if (this.currentQ) this.search(this.currentQ, null, true); 140 }); 141 142 // Collapse toggle 143 wrap.querySelector('#yt-collapse-btn').addEventListener('click', () => this._toggleCollapse()); 144 145 // Saved panel 146 wrap.querySelector('#yt-saved-btn').addEventListener('click', () => this._showSaved()); 147 }, 148 149 async search(query, pageToken = null, resetPlayer = false) { 150 if (this.loading) return; 151 this.currentQ = query; 152 if (!pageToken) this.nextToken = null; 153 if (resetPlayer) { this._closePlayer(); } 154 155 this.panel.classList.remove('yt-hidden'); 156 this.loading = true; 157 if (!pageToken) { 158 this._setLoading(true); 159 } else { 160 this._setLoadMoreBtn('Loadingâ¦', true); 161 } 162 163 try { 164 const qs = new URLSearchParams({ 165 q: query, 166 order: this.currentOrder, 167 maxResults: 12, 168 ...(pageToken ? { pageToken } : {}) 169 }); 170 const res = await fetch(`/.netlify/functions/youtube-search?${qs}`); 171 const data = await res.json(); 172 173 if (!res.ok) { 174 const msg = data.error || 'Could not load videos'; 175 const isKeyMissing = msg.toLowerCase().includes('api key') || msg.toLowerCase().includes('not configured'); 176 this._showError(isKeyMissing 177 ? 'ð YouTube API key not set â add YOUTUBE_API_KEY to Netlify environment variables, then redeploy.' 178 : msg); 179 return; 180 } 181 182 this.nextToken = data.nextPageToken || null; 183 this.prevToken = data.prevPageToken || null; 184 this.currentVideos = data.videos || []; 185 186 const countEl = document.getElementById('yt-count'); 187 if (countEl) countEl.textContent = data.totalResults ? `~${fmtCount(data.totalResults)} results` : `${this.currentVideos.length} videos`; 188 189 this._renderGrid(this.currentVideos, !pageToken); 190 this._renderFooter(); 191 } catch (err) { 192 this._showError('Network error loading videos'); 193 } finally { 194 this.loading = false;
195 if (!pageToken) this._setLoading(false); 196 this.grid.querySelector('.yt-loading-grid')?.remove(); 197 } 198 }, 199 200 _renderGrid(videos, clear = true) { 201 if (clear) this.grid.innerHTML = ''; 202 if (!videos.length) { 203 this.grid.innerHTML = '<div class="yt-placeholder">No videos found for this search</div>'; 204 return; 205 } 206 207 videos.forEach(v => { 208 const duration = fmtDuration(v.duration); 209 const views = v.viewCount != null ? `ð ${fmtCount(v.viewCount)}` : ''; 210 const likes = v.likeCount != null ? `ð ${fmtCount(v.likeCount)}` : ''; 211 const time = relativeTime(v.publishedAt); 212 const watched = WatchHistory.has(v.id); 213 const saved = Saved.has(v.id); 214 const thumb = v.thumbnail.high || v.thumbnail.medium || v.thumbnail.default; 215 216 const card = document.createElement('div'); 217 card.className = `yt-card${watched ? ' yt-watched' : ''}`; 218 card.dataset.id = v.id; 219 card.innerHTML = ` 220 <div class="yt-thumb-wrap"> 221 <img class="yt-thumb" src="${esc(thumb)}" alt="${esc(v.title)}" loading="lazy"> 222 <div class="yt-play-overlay"><div class="yt-play-icon">â¶</div></div> 223 ${duration ? `<span class="yt-duration">${esc(duration)}</span>` : ''} 224 ${watched ? '<span class="yt-watched-badge">â Watched</span>' : ''} 225 <button class="yt-save-btn ${saved ? 'yt-saved' : ''}" title="${saved ? 'Remove from saved' : 'Save to Watch Later'}">${saved ? 'ð' : 'ï¼'}</button> 226 </div> 227 <div class="yt-card-body"> 228 <div class="yt-card-title" title="${esc(v.title)}">${esc(v.title)}</div> 229 <div class="yt-card-meta"> 230 ${v.channelThumb ? `<img class="yt-chan-thumb" src="${esc(v.channelThumb)}" alt="">` : ''} 231 <div class="yt-card-meta-text"> 232 <span class="yt-channel">${esc(v.channelTitle)}</span> 233 <span class="yt-stats">${[views, likes, time].filter(Boolean).join(' · ')}</span> 234 </div> 235 </div> 236 ${v.description ? `<div class="yt-desc">${esc(v.description.slice(0,100))}â¦</div>` : ''} 237 </div>`; 238 239 // Click card â open player 240 card.querySelector('.yt-thumb-wrap').addEventListener('click', () => this._openPlayer(v, card)); 241 242 // Save button 243 card.querySelector('.yt-save-btn').addEventListener('click', (e) => { 244 e.stopPropagation(); 245 const added = Saved.toggle(v); 246 const btn = card.querySelector('.yt-save-btn'); 247 btn.textContent = added ? 'ð' : 'ï¼'; 248 btn.classList.toggle('yt-saved', added); 249 btn.title = added ? 'Remove from saved' : 'Save to Watch Later'; 250 }); 251 252 this.grid.appendChild(card); 253 }); 254 }, 255 256 _openPlayer(video, card) { 257 WatchHistory.add(video); 258 if (card) { card.classList.add('yt-watched'); const wb = card.querySelector('.yt-watched-badge'); if (!wb) { const span = document.createElement('span'); span.className='yt-watched-badge'; span.textContent='â Watched'; card.querySelector('.yt-thumb-wrap').appendChild(span); } } 259 260 this.playerArea.classList.remove('yt-hidden'); 261 const subs = fmtSubs(video.channelSubs); 262 263 this.playerArea.innerHTML = ` 264 <div class="yt-player-inner"> 265 <div class="yt-player-embed-wrap"> 266 <iframe class="yt-player-iframe" 267 src="https://www.youtube.com/embed/${esc(video.id)}?autoplay=1&rel=0&modestbranding=1" 268 allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" 269 allowfullscreen loading="lazy"></iframe> 270 </div>
271 <div class="yt-player-details"> 272 <div class="yt-player-title">${esc(video.title)}</div> 273 <div class="yt-player-meta"> 274 ${video.channelThumb ? `<img class="yt-player-chan-thumb" src="${esc(video.channelThumb)}" alt="">` : ''} 275 <div> 276 <div class="yt-player-channel">${esc(video.channelTitle)}${subs ? `<span class="yt-player-subs"> · ${subs}</span>` : ''}</div> 277 <div class="yt-player-stats"> 278 ${video.viewCount != null ? `ð <b>${fmtCount(video.viewCount)}</b> views` : ''} 279 ${video.likeCount != null ? ` · ð <b>${fmtCount(video.likeCount)}</b>` : ''} 280 ${video.commentCount != null ? ` · ð¬ <b>${fmtCount(video.commentCount)}</b>` : ''} 281 ${video.publishedAt ? ` · ð ${relativeTime(video.publishedAt)}` : ''} 282 </div> 283 </div> 284 </div> 285 ${video.description ? `<div class="yt-player-desc" id="yt-desc-text">${esc(video.description.slice(0,300))}${video.description.length>300?`⦠<button class="yt-desc-more" onclick="this.parentNode.innerHTML=\`${esc(video.description)}\`">more</button>`:''}` : ''} 286 <div class="yt-player-actions"> 287 <a class="yt-player-yt-link" href="https://www.youtube.com/watch?v=${esc(video.id)}" target="_blank" rel="noopener">â¶ Open on YouTube</a> 288 <a class="yt-player-yt-link yt-player-chan-link" href="https://www.youtube.com/channel/${esc(video.channelId)}" target="_blank" rel="noopener">ðº Channel</a> 289 <button class="yt-player-close" id="yt-player-close">â Close Player</button> 290 </div> 291 </div> 292 </div>`; 293 294 document.getElementById('yt-player-close').addEventListener('click', () => this._closePlayer()); 295 this.playerArea.scrollIntoView({ behavior: 'smooth', block: 'start' }); 296 }, 297 298 _closePlayer() { 299 this.playerArea.innerHTML = ''; 300 this.playerArea.classList.add('yt-hidden'); 301 }, 302 303 _renderFooter() { 304 const footer = document.getElementById('yt-footer'); 305 if (!footer) return; 306 footer.innerHTML = ''; 307 if (this.nextToken) { 308 const btn = document.createElement('button'); 309 btn.className = 'yt-load-more'; 310 btn.textContent = 'Load More Videos â'; 311 btn.addEventListener('click', () => this.search(this.currentQ, this.nextToken, false)); 312 footer.appendChild(btn); 313 } 314 }, 315 316 _showSaved() { 317 const saved = Saved.get(); 318 if (!saved.length) { alert('No saved videos yet. Click ï¼ on any video card to save it.'); return; } 319 this._renderGrid(saved.map(v => ({ ...v, thumbnail: { high: v.thumb, medium: v.thumb } })), true); 320 const countEl = document.getElementById('yt-count'); 321 if (countEl) countEl.textContent = `${saved.length} saved`; 322 }, 323 324 _toggleCollapse() { 325 const collapsed = this.panel.classList.toggle('yt-collapsed'); 326 const btn = document.getElementById('yt-collapse-btn'); 327 if (btn) btn.textContent = collapsed ? 'â¼' : 'â²'; 328 }, 329 330 _setLoading(on) { 331 const placeholder = document.getElementById('yt-placeholder'); 332 if (on) { 333 this.grid.innerHTML = `<div class="yt-loading-grid">${Array(6).fill('<div class="yt-skeleton"></div>').join('')}</div>`; 334 } else { 335 this.grid.querySelector('.yt-loading-grid')?.remove(); 336 if (placeholder) placeholder.remove(); 337 } 338 }, 339 340 _setLoadMoreBtn(text, disabled) { 341 const btn = document.querySelector('#yt-footer .yt-load-more'); 342 if (btn) { btn.textContent = text; btn.disabled = disabled; } 343 }, 344 345 _showError(msg) { 346 this.grid.innerHTML = `<div class="yt-error">â ï¸ ${esc(msg)}</div>`; 347 }, 348 349 hide() { 350 if (this.panel) { this.panel.classList.add('yt-hidden'); this._closePlayer(); } 351 }, 352 353 reset() { 354 this.currentQ = ''; 355 this.nextToken = null; 356 if (this.grid) this.grid.innerHTML = '<div class="yt-placeholder">Search for something to see YouTube videos</div>'; 357 if (document.getElementById('yt-count')) document.getElementById('yt-count').textContent = ''; 358 this._closePlayer(); 359 } 360 }; 361 362 // ââ Expose globally âââââââââââââââââââââââââââââââââââââââ 363 window.YTSearch = YTSearch; 364 365 // Auto-init when DOM ready 366 if (document.readyState === 'loading') { 367 document.addEventListener('DOMContentLoaded', () => YTSearch.init()); 368 } else { 369 YTSearch.init(); 370 } 371 372})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.