@@ -2,9 +2,9 @@
Ported from prodcom-listener/public/app.js into the ProdDash module
Ported from prodcom-listener/public/app.js into the ProdDash module
contract: everything lives inside the tile's root, per-tile state
contract: everything lives inside the tile's root, per-tile state
(hidden channels, timestamps, text size) persists in instance settings,
(hidden channels, channel icons, timestamps, text size, flow direction)
and all network traffic goes through moduleApi (the module's server
persists in instance settings, and all network traffic goes through
proxy at /prodcom/*). */
moduleApi (the module's server proxy at /prodcom/*). */
export default function create ( { root , moduleApi } ) {
export default function create ( { root , moduleApi } ) {
/* ── per-instance state ─────────────────────────────────────────── */
/* ── per-instance state ─────────────────────────────────────────── */
@@ -12,7 +12,7 @@ export default function create({ root, moduleApi }) {
const channels = new Map ( ) ; // channelId -> {name, color}
const channels = new Map ( ) ; // channelId -> {name, color}
const groups = new Map ( ) ; // groupId -> {name, channelIds}
const groups = new Map ( ) ; // groupId -> {name, channelIds}
const entryEls = new Map ( ) ; // entryId -> element
const entryEls = new Map ( ) ; // entryId -> element
let pinnedToBottom = true ;
let pinnedToLatest = true ;
let hiddenChannels = new Set ( moduleApi . instanceSettings . hiddenChannels || [ ] ) ;
let hiddenChannels = new Set ( moduleApi . instanceSettings . hiddenChannels || [ ] ) ;
let stream = null ; // moduleApi.sse handle
let stream = null ; // moduleApi.sse handle
let bootTimer = null ;
let bootTimer = null ;
@@ -40,10 +40,14 @@ export default function create({ root, moduleApi }) {
const CLOCK _SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>' ;
const CLOCK _SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>' ;
const JUMP _SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v11"/><path d="m7 10 5 5 5-5"/><path d="M5 20h14"/></svg>' ;
const JUMP _SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v11"/><path d="m7 10 5 5 5-5"/><path d="M5 20h14"/></svg>' ;
const CLEAR _SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="m9 9 6 6M15 9l-6 6"/></svg>' ;
const CLEAR _SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="m9 9 6 6M15 9l-6 6"/></svg>' ;
const CHANNELS _SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 5h16l-6 7.4V19l-4 2v-8.6z"/></svg>' ;
// arrow shows where new messages land: down = oldest first, up = newest first
const FLOW _DOWN _SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 4v14"/><path d="m3 15 3 3 3-3"/><path d="M12 5h8"/><path d="M12 12h8"/><path d="M12 19h6"/></svg>' ;
const FLOW _UP _SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 20V6"/><path d="m3 9 3-3 3 3"/><path d="M12 5h8"/><path d="M12 12h8"/><path d="M12 19h6"/></svg>' ;
let channelMenuEl = null ; // the open menu's element (filled on each open)
let channelMenuEl = null ; // the open menu's element (filled on each open)
const channelsMenu = moduleApi . header . addMenu ( {
const channelsMenu = moduleApi . header . addMenu ( {
label : 'Channels ▾' ,
icon : CHANNELS _SVG ,
title : 'Choose visible channels' ,
title : 'Choose visible channels' ,
build ( menu ) {
build ( menu ) {
menu . classList . add ( 'pt-menu' ) ;
menu . classList . add ( 'pt-menu' ) ;
@@ -52,6 +56,11 @@ export default function create({ root, moduleApi }) {
} ,
} ,
} ) ;
} ) ;
channelsMenu . button . classList . add ( 'pt-channels-btn' ) ;
channelsMenu . button . classList . add ( 'pt-channels-btn' ) ;
// the icon carries the label; a count rides alongside it while filtering
const channelCountEl = document . createElement ( 'span' ) ;
channelCountEl . className = 'pt-count' ;
channelCountEl . hidden = true ;
channelsMenu . button . appendChild ( channelCountEl ) ;
let timeBtn = null ;
let timeBtn = null ;
timeBtn = moduleApi . header . addButton ( {
timeBtn = moduleApi . header . addButton ( {
@@ -71,13 +80,24 @@ export default function create({ root, moduleApi }) {
moduleApi . header . addButton ( { label : 'A− ' , title : 'Smaller text' , onClick : ( ) => bumpTextSize ( - 2 ) } ) ;
moduleApi . header . addButton ( { label : 'A− ' , title : 'Smaller text' , onClick : ( ) => bumpTextSize ( - 2 ) } ) ;
moduleApi . header . addButton ( { label : 'A+' , title : 'Larger text' , onClick : ( ) => bumpTextSize ( 2 ) } ) ;
moduleApi . header . addButton ( { label : 'A+' , title : 'Larger text' , onClick : ( ) => bumpTextSize ( 2 ) } ) ;
// icon and tooltip track the current direction — applyInstanceSettings sets both
const flowBtn = moduleApi . header . addButton ( {
icon : FLOW _DOWN _SVG ,
onClick ( ) {
moduleApi . saveInstanceSettings ( { newestFirst : ! moduleApi . instanceSettings . newestFirst } ) ;
applyInstanceSettings ( ) ;
// the two directions have opposite "latest" ends — follow the flip
if ( pinnedToLatest ) scrollToLatest ( ) ;
} ,
} ) ;
moduleApi . header . addButton ( {
moduleApi . header . addButton ( {
icon : JUMP _SVG ,
icon : JUMP _SVG ,
title : 'Jump to latest' ,
title : 'Jump to latest' ,
onClick ( ) {
onClick ( ) {
pinnedToBottom = true ;
pinnedToLatest = true ;
jumpBtn . hidden = true ;
jumpBtn . hidden = true ;
scrollToBottom ( ) ;
scrollToLatest ( ) ;
} ,
} ,
} ) ;
} ) ;
@@ -93,25 +113,20 @@ export default function create({ root, moduleApi }) {
const size = Math . min ( 48 , Math . max ( 10 , Number ( s . textSize ) || 15 ) ) ;
const size = Math . min ( 48 , Math . max ( 10 , Number ( s . textSize ) || 15 ) ) ;
wrap . style . setProperty ( '--pt-size' , size + 'px' ) ;
wrap . style . setProperty ( '--pt-size' , size + 'px' ) ;
wrap . classList . toggle ( 'show-times' , Boolean ( s . showTimes ) ) ;
wrap . classList . toggle ( 'show-times' , Boolean ( s . showTimes ) ) ;
wrap . classList . toggle ( 'hide-avatars' , s . showAvatars === false ) ;
timeBtn ? . classList . toggle ( 'active' , Boolean ( s . showTimes ) ) ;
timeBtn ? . classList . toggle ( 'active' , Boolean ( s . showTimes ) ) ;
const up = newestFirst ( ) ;
wrap . classList . toggle ( 'newest-first' , up ) ;
flowBtn . innerHTML = up ? FLOW _UP _SVG : FLOW _DOWN _SVG ;
flowBtn . classList . toggle ( 'active' , up ) ;
flowBtn . title = up
? 'Newest at top — click for newest at bottom'
: 'Newest at bottom — click for newest at top' ;
jumpBtn . textContent = ( up ? '↑' : '↓' ) + ' New messages' ;
}
}
applyInstanceSettings ( ) ;
applyInstanceSettings ( ) ;
/* ── channel filter (instance setting) ──────────────────────────── */
function channelFilter ( ) {
return String ( moduleApi . instanceSettings . channel || '' ) . trim ( ) . toLowerCase ( ) ;
}
/** Entry hidden by the hard per-tile filter (settings.channel)? */
function filteredOut ( channelId , channelName ) {
const want = channelFilter ( ) ;
if ( ! want ) return false ;
const known = channels . get ( channelId ) ;
const name = ( ( known && known . name ) || channelName || '' ) . toLowerCase ( ) ;
return name !== want && String ( channelId ) . toLowerCase ( ) !== want ;
}
/* ── channel visibility toggles ─────────────────────────────────── */
/* ── channel visibility toggles ─────────────────────────────────── */
function persistHidden ( ) {
function persistHidden ( ) {
@@ -119,9 +134,7 @@ export default function create({ root, moduleApi }) {
}
}
function applyFilter ( el ) {
function applyFilter ( el ) {
const hidden = hiddenChannels . has ( el . dataset . channelId )
el . classList . toggle ( 'hidden-by-filter' , hiddenChannels . has ( el . dataset . channelId ) ) ;
|| filteredOut ( el . dataset . channelId , el . dataset . channelName ) ;
el . classList . toggle ( 'hidden-by-filter' , hidden ) ;
}
}
function toggleChannel ( id ) {
function toggleChannel ( id ) {
@@ -130,7 +143,7 @@ export default function create({ root, moduleApi }) {
persistHidden ( ) ;
persistHidden ( ) ;
rebuildChannelMenu ( ) ;
rebuildChannelMenu ( ) ;
entryEls . forEach ( ( el ) => applyFilter ( el ) ) ;
entryEls . forEach ( ( el ) => applyFilter ( el ) ) ;
if ( pinnedToBottom ) scrollToBottom ( ) ;
if ( pinnedToLatest ) scrollToLatest ( ) ;
}
}
/** Show exactly this group's channels, hide the rest */
/** Show exactly this group's channels, hide the rest */
@@ -139,7 +152,7 @@ export default function create({ root, moduleApi }) {
persistHidden ( ) ;
persistHidden ( ) ;
rebuildChannelMenu ( ) ;
rebuildChannelMenu ( ) ;
entryEls . forEach ( ( el ) => applyFilter ( el ) ) ;
entryEls . forEach ( ( el ) => applyFilter ( el ) ) ;
if ( pinnedToBottom ) scrollToBottom ( ) ;
if ( pinnedToLatest ) scrollToLatest ( ) ;
}
}
function groupIsActive ( g ) {
function groupIsActive ( g ) {
@@ -150,18 +163,24 @@ export default function create({ root, moduleApi }) {
) ;
) ;
}
}
function updateChannelsLabel ( ) {
/* The button is an icon, so the "some channels are hidden" state shows as
an accent tint plus a compact count beside it. */
function updateChannelsButton ( ) {
let visibleCount = 0 ;
let visibleCount = 0 ;
for ( const id of channels . keys ( ) ) {
for ( const id of channels . keys ( ) ) {
if ( ! hiddenChannels . has ( id ) ) visibleCount ++ ;
if ( ! hiddenChannels . has ( id ) ) visibleCount ++ ;
}
}
channelsMenu . setLabel ( visibleCount = == channels . size
const filtering = channels . size > 0 && visibleCount ! == channels . size ;
? 'Channels ▾'
channelCountEl . textContent = filtering ? ` ${ visibleCount } / ${ channels . size } ` : '' ;
: ` Channels ( ${ visibleCount } / ${ channels . size } ) ▾ ` ) ;
channelCountEl . hidden = ! filtering ;
channelsMenu . button . classList . toggle ( 'active' , filtering ) ;
channelsMenu . button . title = filtering
? ` Channels — showing ${ visibleCount } of ${ channels . size } `
: 'Choose visible channels' ;
}
}
function rebuildChannelMenu ( ) {
function rebuildChannelMenu ( ) {
updateChannelsLabel ( ) ;
updateChannelsButton ( ) ;
const channelMenu = channelMenuEl ;
const channelMenu = channelMenuEl ;
if ( ! channelMenu ) return ; // menu not opened yet — the label is enough
if ( ! channelMenu ) return ; // menu not opened yet — the label is enough
channelMenu . innerHTML = '' ;
channelMenu . innerHTML = '' ;
@@ -225,25 +244,33 @@ export default function create({ root, moduleApi }) {
/* ── scrolling ──────────────────────────────────────────────────── */
/* ── scrolling ──────────────────────────────────────────────────── */
/** Newest entry at the top (bottom-up) instead of at the bottom? */
function newestFirst ( ) {
return Boolean ( moduleApi . instanceSettings . newestFirst ) ;
}
streamEl . addEventListener ( 'scroll' , ( ) => {
streamEl . addEventListener ( 'scroll' , ( ) => {
const nearBottom = streamEl . scrollHeight - streamEl . scrollTop - streamEl . clientHeight < 60 ;
const near = newestFirst ( )
pinnedToBottom = nearBottom ;
? streamEl . scrollTop < 60
if ( nearBottom ) jumpBtn . hidden = true ;
: streamEl . scrollHeight - streamEl . scrollTop - streamEl . clientHeight < 60 ;
pinnedToLatest = near ;
if ( near ) jumpBtn . hidden = true ;
} ) ;
} ) ;
jumpBtn . addEventListener ( 'click' , ( ) => {
jumpBtn . addEventListener ( 'click' , ( ) => {
pinnedToBottom = true ;
pinnedToLatest = true ;
jumpBtn . hidden = true ;
jumpBtn . hidden = true ;
scrollToBottom ( ) ;
scrollToLatest ( ) ;
} ) ;
} ) ;
function scrollToBottom ( ) {
/** The latest entry is at the top when flipped, at the bottom otherwise. */
streamEl . scrollTop = streamEl . scrollHeight ;
function scrollToLatest ( ) {
streamEl . scrollTop = newestFirst ( ) ? 0 : streamEl . scrollHeight ;
}
}
function afterInsert ( el ) {
function afterInsert ( el ) {
if ( el . classList . contains ( 'hidden-by-filter' ) ) return ;
if ( el . classList . contains ( 'hidden-by-filter' ) ) return ;
if ( pinnedToBottom ) scrollToBottom ( ) ;
if ( pinnedToLatest ) scrollToLatest ( ) ;
else jumpBtn . hidden = false ;
else jumpBtn . hidden = false ;
}
}
@@ -272,7 +299,6 @@ export default function create({ root, moduleApi }) {
el = document . createElement ( 'div' ) ;
el = document . createElement ( 'div' ) ;
el . className = 'pt-entry' ;
el . className = 'pt-entry' ;
el . dataset . channelId = entry . channelId || '' ;
el . dataset . channelId = entry . channelId || '' ;
el . dataset . channelName = entry . channelName || '' ;
const info = channelInfo ( entry ) ;
const info = channelInfo ( entry ) ;
el . style . setProperty ( '--ch' , info . color ) ;
el . style . setProperty ( '--ch' , info . color ) ;
@@ -314,7 +340,7 @@ export default function create({ root, moduleApi }) {
afterInsert ( el ) ;
afterInsert ( el ) ;
} else {
} else {
updateEntryEl ( el , entry ) ;
updateEntryEl ( el , entry ) ;
if ( pinnedToBottom && ! el . classList . contains ( 'hidden-by-filter' ) ) scrollToBottom ( ) ;
if ( pinnedToLatest && ! el . classList . contains ( 'hidden-by-filter' ) ) scrollToLatest ( ) ;
}
}
}
}
@@ -430,7 +456,7 @@ export default function create({ root, moduleApi }) {
bootTimer = setTimeout ( boot , 5000 ) ;
bootTimer = setTimeout ( boot , 5000 ) ;
return ;
return ;
}
}
scrollToBottom ( ) ;
scrollToLatest ( ) ;
connect ( ) ;
connect ( ) ;
}
}
@@ -445,7 +471,7 @@ export default function create({ root, moduleApi }) {
root . innerHTML = '' ; // header controls are removed by the shell
root . innerHTML = '' ; // header controls are removed by the shell
} ,
} ,
onResize ( ) {
onResize ( ) {
if ( pinnedToBottom ) scrollToBottom ( ) ;
if ( pinnedToLatest ) scrollToLatest ( ) ;
} ,
} ,
} ;
} ;
}
}