2025-08-16 21:38:49 -05:00
// Shared state
2025-08-17 19:27:36 -05:00
const st = { tailLoading : false , atBottom : true , current : '#' , earliest : null , sse : null , channels : [ ] , twLoaded : false , seen : new Set ( ) , loadingHistory : false } ;
2025-08-16 21:38:49 -05:00
2025-08-17 14:58:25 -05:00
function measureBars ( ) {
const hdr = document . querySelector ( 'header.nav' ) ;
const ftr = document . querySelector ( 'footer' ) ;
if ( hdr ) { document . documentElement . style . setProperty ( '--headerH' , hdr . getBoundingClientRect ( ) . height + 'px' ) ; }
if ( ftr ) { document . documentElement . style . setProperty ( '--footerH' , ftr . getBoundingClientRect ( ) . height + 'px' ) ; }
}
2025-08-16 21:38:49 -05:00
function colorFor ( nick ) { let h = 0 ; for ( let i = 0 ; i < nick . length ; i ++ ) { h = ( h * 31 + nick . charCodeAt ( i ) ) >>> 0 } return 'hsl(' + ( h % 360 ) + ',60%,' + ( window . matchMedia ( '(prefers-color-scheme: dark)' ) . matches ? '70%' : '35%' ) + ')' ; }
function escapeHtml ( s ) { return s . replace ( /[&<>"]/g , c => ( { '&' : '&' , '<' : '<' , '>' : '>' , '"' : '"' } [ c ] ) ) ; }
function linkify ( t ) { return t . replace ( /https?:\/\/\S+/g , function ( u ) { return '<a href="' + u + '" target="_blank" rel="noopener">' + u + '</a>' ; } ) ; }
function lineHTML ( m ) { const ts = '<span class=ts>[' + m . time + ']</span>' ; const nick = '<b style="color:' + colorFor ( m . author ) + '">' + m . author + '</b>' ; const body = escapeHtml ( m . body ) ; return ts + ' ' + nick + ': ' + linkify ( body ) ; }
2025-08-17 19:27:36 -05:00
function msgKey ( m ) { return m . time + '|' + m . author + '|' + m . body ; }
2025-08-17 14:58:25 -05:00
function snapBottom ( ) { window . scrollTo ( 0 , document . documentElement . scrollHeight ) ; }
function pinBottomMulti ( ) { if ( ! st . atBottom ) return ; [ 0 , 16 , 64 , 200 ] . forEach ( d => setTimeout ( ( ) => requestAnimationFrame ( snapBottom ) , d ) ) ; }
2025-08-16 21:38:49 -05:00
async function api ( path , params ) {
const url = new URL ( path , window . location . origin ) ;
if ( params && params . query ) { Object . entries ( params . query ) . forEach ( ( [ k , v ] ) => url . searchParams . set ( k , v ) ) ; }
const res = await fetch ( url ) ;
if ( ! res . ok ) throw new Error ( 'HTTP ' + res . status ) ;
const ct = res . headers . get ( 'content-type' ) || '' ;
if ( ct . includes ( 'application/json' ) ) return res . json ( ) ;
return res . text ( ) ;
}
2025-08-17 20:01:03 -05:00
function ensureTwitterWidgets ( ) { if ( st . twLoaded ) { if ( window . twttr && twttr . widgets && typeof twttr . widgets . load === 'function' ) { /* keep for later calls */ } return ; } st . twLoaded = true ; const s = document . createElement ( 'script' ) ; s . async = true ; s . src = 'https://platform.twitter.com/widgets.js' ; document . head . appendChild ( s ) ; }
2025-08-17 16:14:24 -05:00
2025-08-17 19:27:36 -05:00
function appendBatch ( arr ) { const el = document . getElementById ( 'tail' ) ; const frag = document . createDocumentFragment ( ) ; arr . forEach ( m => { const key = msgKey ( m ) ; if ( st . seen . has ( key ) ) return ; st . seen . add ( key ) ; const div = document . createElement ( 'div' ) ; div . className = 'msg' ; div . dataset . key = key ; div . innerHTML = lineHTML ( m ) ; frag . appendChild ( div ) ; processLinks ( div ) ; } ) ; el . appendChild ( frag ) ; pinBottomMulti ( ) ; }
function prependBatch ( arr ) { const el = document . getElementById ( 'tail' ) ; const oldTop = el . firstChild ; const beforeTop = oldTop ? oldTop . getBoundingClientRect ( ) . top : 0 ; const frag = document . createDocumentFragment ( ) ; arr . forEach ( m => { const key = msgKey ( m ) ; if ( st . seen . has ( key ) ) return ; st . seen . add ( key ) ; const div = document . createElement ( 'div' ) ; div . className = 'msg' ; div . dataset . key = key ; div . innerHTML = lineHTML ( m ) ; frag . appendChild ( div ) ; processLinks ( div ) ; } ) ; el . insertBefore ( frag , el . firstChild ) ; if ( oldTop ) { const afterTop = oldTop . getBoundingClientRect ( ) . top ; const delta = afterTop - beforeTop ; window . scrollBy ( 0 , delta ) ; } }
2025-08-16 21:38:49 -05:00
2025-08-16 21:43:06 -05:00
function processLinks ( scope ) { const links = scope . querySelectorAll ( 'a[href]:not([data-card])' ) ; links . forEach ( a => { a . setAttribute ( 'data-card' , '1' ) ;
// Fetch and render card
fetch ( '/api/linkcard?url=' + encodeURIComponent ( a . href ) ) . then ( r => r . json ( ) ) . then ( card => {
if ( ! card ) return ;
2025-08-17 16:03:22 -05:00
if ( card . title || card . description || card . image || card . html ) {
2025-08-17 16:26:29 -05:00
const c = document . createElement ( 'div' ) ; c . className = 'card' ;
const details = document . createElement ( 'div' ) ; details . className = 'card-details' ;
var html = '' ;
2025-08-16 21:43:06 -05:00
if ( card . image ) { html += '<div><img src="' + card . image + '" alt="" style="max-width:160px;max-height:120px;object-fit:cover;border-radius:.25rem"></div>' ; }
html += '<div style="flex:1;margin-left:.5rem">' ;
if ( card . title ) { html += '<div style="font-weight:600">' + escapeHtml ( card . title ) + '</div>' ; }
if ( card . description ) { html += '<div style="opacity:.8">' + escapeHtml ( card . description ) + '</div>' ; }
html += '</div>' ;
2025-08-17 18:02:59 -05:00
const row = document . createElement ( 'div' ) ; row . style . display = 'flex' ; row . style . alignItems = 'flex-start' ; row . style . gap = '.5rem' ; row . innerHTML = html ;
2025-08-17 16:26:29 -05:00
details . appendChild ( row ) ;
2025-08-17 20:01:03 -05:00
if ( card . html ) {
const wrap = document . createElement ( 'div' ) ;
wrap . innerHTML = card . html ;
// Tweak YouTube iframe sizing
const ifr = wrap . querySelector ( 'iframe' ) ;
if ( ifr ) { ifr . removeAttribute ( 'width' ) ; ifr . removeAttribute ( 'height' ) ; ifr . style . width = '100%' ; ifr . style . maxWidth = '640px' ; ifr . style . aspectRatio = '16/9' ; ifr . style . height = 'auto' ; ifr . style . borderRadius = '.5rem' ; }
details . appendChild ( wrap ) ;
// Twitter embed render pass
ensureTwitterWidgets ( ) ;
if ( window . twttr && twttr . widgets && typeof twttr . widgets . load === 'function' ) { try { twttr . widgets . load ( wrap ) ; } catch ( e ) { } }
}
2025-08-17 19:12:26 -05:00
details . querySelectorAll ( 'img' ) . forEach ( img => img . addEventListener ( 'load' , ( ) => pinBottomMulti ( ) ) ) ;
2025-08-17 16:26:29 -05:00
c . appendChild ( details ) ;
2025-08-17 19:12:26 -05:00
const sum = document . createElement ( 'div' ) ; sum . className = 'link-summary' ; sum . style . whiteSpace = 'pre-wrap' ; sum . style . marginTop = '.25rem' ; sum . style . display = 'none' ; c . appendChild ( sum ) ;
// Place actions inline next to the original link
const act = document . createElement ( 'span' ) ; act . style . marginLeft = '.35rem' ;
const sumBtn = document . createElement ( 'button' ) ; sumBtn . type = 'button' ; sumBtn . title = 'Summarize' ; sumBtn . textContent = '🌝' ; sumBtn . style . padding = '0 .35rem' ; sumBtn . style . fontSize = '.9rem' ;
const chevron = document . createElement ( 'button' ) ; chevron . type = 'button' ; chevron . title = 'Expand/collapse' ; chevron . textContent = '▾' ; chevron . style . padding = '0 .35rem' ; chevron . style . fontSize = '.9rem' ;
const spinner = document . createElement ( 'span' ) ; spinner . textContent = '' ; spinner . style . marginLeft = '.25rem' ;
act . appendChild ( sumBtn ) ; act . appendChild ( chevron ) ; act . appendChild ( spinner ) ;
a . insertAdjacentElement ( 'afterend' , act ) ;
// Insert card after the link line
a . parentNode . insertBefore ( c , act . nextSibling ) ;
const toggle = ( ) => { const hidden = c . style . display === 'none' ; c . style . display = hidden ? '' : 'none' ; chevron . textContent = hidden ? '▾' : '▸' ; pinBottomMulti ( ) ; } ;
chevron . onclick = ( ev ) => { ev . stopPropagation ( ) ; toggle ( ) ; } ;
sumBtn . onclick = async ( ) => {
if ( sum . style . display !== 'none' && sum . textContent ) { sum . style . display = 'none' ; sumBtn . textContent = '🌝' ; pinBottomMulti ( ) ; return ; }
// Ensure card is visible when showing summary
if ( c . style . display === 'none' ) { toggle ( ) ; }
sumBtn . disabled = true ; spinner . textContent = '…' ; sum . textContent = '' ; sum . style . display = '' ;
try { const data = await api ( '/api/linksummary' , { query : { url : a . href } } ) ; sum . textContent = ( data && data . summary ) ? data . summary : '(no summary)' ; sumBtn . textContent = '🌚' ; }
2025-08-16 21:43:06 -05:00
catch ( e ) { sum . textContent = 'error: ' + e ; }
2025-08-17 19:12:26 -05:00
spinner . textContent = '' ; sumBtn . disabled = false ; pinBottomMulti ( ) ;
2025-08-16 21:43:06 -05:00
} ;
}
} ) . catch ( ( ) => { } ) ;
} ) ; }
2025-08-16 21:38:49 -05:00
async function loadChannels ( ) { try { const data = await api ( '/api/channels' ) ; st . channels = data ; renderChannels ( ) ; if ( data . length > 0 ) { selectChannel ( data [ 0 ] ) ; } } catch ( e ) { } }
2025-08-17 15:43:25 -05:00
function renderChannels ( ) { const list = document . getElementById ( 'nav-chans' ) || document . getElementById ( 'brand-chans' ) ? . querySelector ( 'ul' ) ; if ( ! list ) return ; list . innerHTML = '' ; st . channels . forEach ( c => { const li = document . createElement ( 'li' ) ; const a = document . createElement ( 'a' ) ; a . href = '#' ; a . textContent = c + ( c === st . current ? ' ✓' : '' ) ; a . onclick = ( ev ) => { ev . preventDefault ( ) ; selectChannel ( c ) ; } ; li . appendChild ( a ) ; list . appendChild ( li ) ; } ) ; }
2025-08-16 21:38:49 -05:00
2025-08-17 19:27:36 -05:00
async function selectChannel ( ch ) { if ( st . sse ) { st . sse . close ( ) ; st . sse = null ; } st . current = ch ; st . seen = new Set ( ) ; st . loadingHistory = false ; renderChannels ( ) ; const el = document . getElementById ( 'tail' ) ; if ( ! el ) return ; el . textContent = '' ; const data = await api ( '/api/tail' , { query : { channel : ch , limit : 50 } } ) ; appendBatch ( data ) ; requestAnimationFrame ( ( ) => { snapBottom ( ) ; st . atBottom = true ; } ) ; st . earliest = data . length ? data [ 0 ] . time : null ; startStream ( ) ; initScrollHandlers ( ) ; }
function initScrollHandlers ( ) { const el = document . getElementById ( 'tail' ) ; if ( ! el ) return ; const onScroll = async ( ) => { const nearBottom = ( window . innerHeight + window . pageYOffset + 16 ) >= document . documentElement . scrollHeight ; st . atBottom = nearBottom ; const nearTop = window . pageYOffset <= 4 ; if ( nearTop && st . earliest && ! st . loadingHistory ) { try { st . loadingHistory = true ; const before = st . earliest ; const older = await api ( '/api/history' , { query : { channel : st . current , before : before , limit : 50 } } ) ; if ( older . length ) { prependBatch ( older ) ; st . earliest = older [ 0 ] . time ; } } catch ( e ) { } finally { st . loadingHistory = false ; } } } ; window . removeEventListener ( 'scroll' , st . _scrollHandler || ( ( ) => { } ) ) ; st . _scrollHandler = onScroll ; window . addEventListener ( 'scroll' , onScroll , { passive : true } ) ; }
2025-08-16 21:38:49 -05:00
function startStream ( ) { const el = document . getElementById ( 'tail' ) ; if ( ! el ) return ; const url = new URL ( '/api/stream' , window . location . origin ) ; url . searchParams . set ( 'channel' , st . current ) ; const es = new EventSource ( url ) ; st . sse = es ; es . onmessage = ( ev ) => { try { const m = JSON . parse ( ev . data ) ; appendBatch ( [ m ] ) ; } catch ( e ) { } } ; es . onerror = ( ) => { es . close ( ) ; st . sse = null ; setTimeout ( startStream , 3000 ) ; } }
async function summarize ( ) { const ch = document . getElementById ( 'channel' ) ; const win = document . getElementById ( 'window' ) ; const push = document . getElementById ( 'push' ) ; const btn = document . getElementById ( 'btn' ) ; const out = document . getElementById ( 'out' ) ; if ( ! ch || ! win || ! btn || ! out ) return ; btn . disabled = true ; out . textContent = '' ; try { const data = await api ( '/api/trigger' , { query : { channel : ch . value , window : win . value || '6h' , push : push && push . checked ? '1' : '0' } } ) ; if ( typeof data === 'string' ) { out . textContent = data ; } else { out . textContent = ( data . summary || '' ) ; } } catch ( e ) { out . textContent = 'error: ' + e ; } btn . disabled = false ; }
window . addEventListener ( 'DOMContentLoaded' , ( ) => {
2025-08-17 14:58:25 -05:00
if ( 'scrollRestoration' in history ) { history . scrollRestoration = 'manual' ; }
measureBars ( ) ;
2025-08-17 15:30:49 -05:00
loadChannels ( ) ;
2025-08-17 15:48:10 -05:00
// Open brand dropdown on hover (desktop) for the dashboard
const brandDetails = document . getElementById ( 'brand-chans' ) ;
if ( brandDetails ) {
brandDetails . addEventListener ( 'mouseenter' , ( ) => { brandDetails . setAttribute ( 'open' , '' ) ; } ) ;
brandDetails . addEventListener ( 'mouseleave' , ( ) => { brandDetails . removeAttribute ( 'open' ) ; } ) ;
}
2025-08-16 21:38:49 -05:00
if ( document . getElementById ( 'channel' ) ) {
fetch ( '/api/channels' ) . then ( r => r . json ( ) ) . then ( arr => { const sel = document . getElementById ( 'channel' ) ; arr . forEach ( c => { const o = document . createElement ( 'option' ) ; o . value = c ; o . textContent = c ; sel . appendChild ( o ) ; } ) ; } ) . catch ( ( ) => { } ) ;
}
} ) ;
2025-08-17 14:58:25 -05:00
window . addEventListener ( 'load' , measureBars ) ;
window . addEventListener ( 'resize' , ( ) => { measureBars ( ) ; if ( st . atBottom ) pinBottomMulti ( ) ; } ) ;