2025-08-16 21:38:49 -05:00
// Shared state
const st = { tailLoading : false , atBottom : true , current : '#' , earliest : null , sse : null , channels : [ ] } ;
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 ) ; }
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 14:18:16 -05:00
function appendBatch ( arr ) { const el = document . getElementById ( 'tail' ) ; const frag = document . createDocumentFragment ( ) ; arr . forEach ( m => { const div = document . createElement ( 'div' ) ; div . className = 'msg' ; div . innerHTML = lineHTML ( m ) ; frag . appendChild ( div ) ; processLinks ( div ) ; } ) ; el . appendChild ( frag ) ; if ( st . atBottom ) { window . scrollTo ( { top : document . body . scrollHeight , behavior : 'instant' } ) ; } }
function prependBatch ( arr ) { const el = document . getElementById ( 'tail' ) ; const oldTop = el . firstChild ; const prevPageOffset = window . pageYOffset ; const frag = document . createDocumentFragment ( ) ; arr . forEach ( m => { const div = document . createElement ( 'div' ) ; div . className = 'msg' ; div . innerHTML = lineHTML ( m ) ; frag . appendChild ( div ) ; processLinks ( div ) ; } ) ; el . insertBefore ( frag , el . firstChild ) ; if ( oldTop ) { oldTop . scrollIntoView ( ) ; window . scrollTo ( { top : oldTop . getBoundingClientRect ( ) . top + window . pageYOffset - 80 , behavior : 'instant' } ) ; } else { window . scrollTo ( { top : prevPageOffset , behavior : 'instant' } ) ; } }
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 ;
if ( card . title || card . description || card . image ) {
const c = document . createElement ( 'div' ) ; c . className = 'card' ; var html = '' ;
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>' ;
const row = document . createElement ( 'div' ) ; row . style . display = 'flex' ; row . style . alignItems = 'flex-start' ; row . style . gap = '.5rem' ; row . innerHTML = html ;
c . appendChild ( row ) ;
// Summary control row
const ctrl = document . createElement ( 'div' ) ; ctrl . style . marginTop = '.25rem' ;
const btn = document . createElement ( 'button' ) ; btn . type = 'button' ; btn . title = 'Summarize this link' ; btn . textContent = '\u25B6' ; btn . style . padding = '0 .4rem' ; btn . style . fontSize = '.9rem' ;
const spinner = document . createElement ( 'span' ) ; spinner . textContent = '' ; spinner . style . marginLeft = '.5rem' ;
const sum = document . createElement ( 'div' ) ; sum . className = 'link-summary' ; sum . style . whiteSpace = 'pre-wrap' ; sum . style . marginTop = '.25rem' ;
btn . onclick = async ( ) => {
btn . disabled = true ; spinner . textContent = '…' ; sum . textContent = '' ;
try { const data = await api ( '/api/linksummary' , { query : { url : a . href } } ) ; sum . textContent = ( data && data . summary ) ? data . summary : '(no summary)' ; }
catch ( e ) { sum . textContent = 'error: ' + e ; }
spinner . textContent = '' ; btn . disabled = false ;
} ;
ctrl . appendChild ( btn ) ; ctrl . appendChild ( spinner ) ;
c . appendChild ( ctrl ) ;
c . appendChild ( sum ) ;
a . parentNode . insertBefore ( c , a . nextSibling ) ;
}
} ) . 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 ) { } }
function renderChannels ( ) { const list = document . getElementById ( 'chanlist' ) ; if ( ! list ) return ; list . innerHTML = '' ; st . channels . forEach ( c => { const a = document . createElement ( 'a' ) ; a . href = '#' ; a . textContent = c ; a . onclick = ( ev ) => { ev . preventDefault ( ) ; selectChannel ( c ) } ; if ( c === st . current ) a . className = 'active' ; list . appendChild ( a ) ; } ) ; }
async function selectChannel ( ch ) { if ( st . sse ) { st . sse . close ( ) ; st . sse = null ; } st . current = ch ; renderChannels ( ) ; const el = document . getElementById ( 'tail' ) ; if ( ! el ) return ; el . textContent = '' ; const data = await api ( '/api/tail' , { query : { channel : ch , limit : 50 } } ) ; appendBatch ( data ) ; el . scrollTop = el . scrollHeight ; st . atBottom = true ; st . earliest = data . length ? data [ 0 ] . time : null ; startStream ( ) ; initScrollHandlers ( ) ; }
2025-08-17 14:18:16 -05:00
function initScrollHandlers ( ) { const el = document . getElementById ( 'tail' ) ; if ( ! el ) return ; const onScroll = async ( ) => { const nearBottom = ( window . innerHeight + window . pageYOffset + 16 ) >= document . body . offsetHeight ; st . atBottom = nearBottom ; if ( window . pageYOffset === 0 && st . earliest ) { try { const older = await api ( '/api/history' , { query : { channel : st . current , before : st . earliest , limit : 50 } } ) ; if ( older . length ) { prependBatch ( older ) ; st . earliest = older [ 0 ] . time ; } } catch ( e ) { } } } ; 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' , ( ) => {
if ( document . getElementById ( 'chanlist' ) ) { loadChannels ( ) ; }
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 ( ( ) => { } ) ;
}
} ) ;