PageSourceSearch

https://dynamic-salmiakki-095693.netlify.app/youtube-search.js

js dynamic-salmiakki-095693.netlify.app collected 2026-10-03 10:40:49 UTC 18,352 bytes, 372 lines download raw bytes

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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
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.