diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index e1e5535..d2f99f2 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -869,13 +869,44 @@ function renderSidebar(sessions, currentSession, currentRemoteId) { if (!list) return; const visible = getVisibleSessions(sessions); + var ds = getDisplaySettings(); if (visible.length === 0) { list.innerHTML = ''; return; } - let html = ''; + // Build a map of existing sidebar-item elements keyed by session key + var existingItems = new Map(); + list.querySelectorAll('sidebar-item').forEach(function(el) { + var key = el.session && (el.session.sessionKey || el.session.name); + if (key) existingItems.set(key, el); + }); + + var newKeys = new Set(); + var fragment = document.createDocumentFragment(); + + function _setupSidebarItem(session) { + var key = session.sessionKey || session.name; + newKeys.add(key); + var item = existingItems.get(key); + if (!item) { + item = document.createElement('sidebar-item'); + // sidebar-select navigates; sidebar-options opens the tile-options-btn flyout + item.addEventListener('sidebar-select', function(e) { + openSession(e.detail.name, { remoteId: e.detail.remoteId }); + }); + item.addEventListener('sidebar-options', function(e) { + openFlyoutMenu(e.detail.name, e.detail.sessionKey, e.detail.remoteId, e.detail.rect); + }); + } + item.session = session; + item.active = (session.name === currentSession && (session.remoteId ?? '') === (currentRemoteId ?? '')); + item.multiDevice = !!(_serverSettings && _serverSettings.multi_device_enabled); + item.showDeviceBadges = ds.showDeviceBadges !== false; + item.activityIndicator = ds.activityIndicator || 'both'; + return item; + } if (_serverSettings && _serverSettings.multi_device_enabled) { // Group sessions by deviceName when multi_device_enabled @@ -887,28 +918,28 @@ function renderSidebar(sessions, currentSession, currentRemoteId) { } for (const [deviceName, deviceSessions] of groups) { - html += ``; - html += deviceSessions.map((session) => buildSidebarHTML(session, currentSession, currentRemoteId)).join(''); + var header = document.createElement('h4'); + header.className = 'sidebar-device-header'; + header.textContent = deviceName; + fragment.appendChild(header); + for (var j = 0; j < deviceSessions.length; j++) { + fragment.appendChild(_setupSidebarItem(deviceSessions[j])); + } } } else { - // Single source: flat list with no device headers - html = visible.map((session) => buildSidebarHTML(session, currentSession, currentRemoteId)).join(''); + for (var i = 0; i < visible.length; i++) { + fragment.appendChild(_setupSidebarItem(visible[i])); + } } - list.innerHTML = html; - - // Bind click handlers on each sidebar item, passing remoteId - if (typeof list.querySelectorAll === 'function') { - list.querySelectorAll('.sidebar-item').forEach((item) => { - const name = item.dataset.session; - const remoteId = item.dataset.remoteId || ''; - on(item, 'click', (e) => { - if (e.target.closest && e.target.closest('.tile-options-btn')) return; - if (name !== currentSession || remoteId !== (currentRemoteId ?? '')) openSession(name, { remoteId }); - }); - }); - } + // Remove stale items + existingItems.forEach(function(item, key) { + if (!newKeys.has(key)) item.remove(); + }); + // Clear and rebuild + list.innerHTML = ''; + list.appendChild(fragment); } const SIDEBAR_NARROW_THRESHOLD = 960; @@ -997,51 +1028,8 @@ function bindSidebarClickAway() { * @param {object[]} sessions */ -/** - * Render sessions grouped by device name. Returns HTML string. - * @param {object[]} sessions - sorted, visible sessions - * @param {boolean} mobile - * @returns {string} - */ -function renderGroupedGrid(sessions, mobile) { - // Group by deviceName - var groups = {}; - var groupOrder = []; - for (var i = 0; i < sessions.length; i++) { - var dn = sessions[i].deviceName || 'Unknown'; - if (!groups[dn]) { - groups[dn] = []; - groupOrder.push(dn); - } - groups[dn].push(sessions[i]); - } - - var html = ''; - for (var g = 0; g < groupOrder.length; g++) { - var name = groupOrder[g]; - var groupSessions = groups[name]; - // Skip device entirely when it has no visible sessions to render. - // This prevents empty device headers from appearing in the grouped grid - // (e.g. when all of a device's sessions are hidden in the current view). - if (groupSessions.length === 0) continue; - html += '

' + escapeHtml(name) + '

'; - for (var j = 0; j < groupSessions.length; j++) { - html += buildTileHTML(groupSessions[j], j, mobile); - } - } - return html; -} - -/** - * Render the filter pill bar into the given container element. - * Generates one 'All' pill plus one pill per unique device name found in allSessions. - * The currently active device pill is marked with the `filter-pill--active` class. - * @param {Element} container - The DOM element to render pills into. - * @param {Array} allSessions - Full (unfiltered) session list used to derive device names. - */ -function renderFilterBar(container, allSessions) { - // Dead code: filter bar replaced by Views feature. Kept as empty stub for export compatibility. -} +// renderGroupedGrid and renderFilterBar removed — logic moved into component. +// No-op stubs are exported at the bottom of the file for test compatibility. // --------------------------------------------------------------------------- // View dropdown — render, open/close, view switching @@ -1647,10 +1635,6 @@ function switchView(viewName) { } function renderGrid(sessions) { - var grid = $('session-grid'); - var emptyState = $('empty-state'); - var filterBar = $('filter-bar'); - // Close flyout if the targeted session no longer exists if (_flyoutSessionKey) { var flyoutStillExists = (sessions || []).some(function(s) { @@ -1662,198 +1646,89 @@ function renderGrid(sessions) { } var visible = getVisibleSessions(sessions); + var emptyState = $('empty-state'); if (visible.length === 0) { - // Build status tiles for auth_failed/unreachable sessions even when no regular sessions exist. - // status:empty sentinels are intentionally ignored — a remote with zero tmux sessions - // produces no visible tile in any view mode (flat, grouped, or otherwise). - var statusTilesHtml = ''; - (sessions || []).forEach(function(session) { - if (session.status === 'auth_failed') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Auth required', 'auth'); - else if (session.status === 'unreachable') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Offline', 'offline'); + // Check if there are status sessions (auth_failed / unreachable) the grid will render + var hasStatusSessions = (sessions || []).some(function(s) { + return s.status === 'auth_failed' || s.status === 'unreachable'; }); - if (grid) grid.innerHTML = statusTilesHtml; - // Only show empty-state when there are truly no tiles at all if (emptyState) { - if (statusTilesHtml) emptyState.classList.add('hidden'); + if (hasStatusSessions) emptyState.classList.add('hidden'); else emptyState.classList.remove('hidden'); } - if (filterBar) filterBar.innerHTML = ''; - return; - } - - if (emptyState) emptyState.classList.add('hidden'); - - // Apply sort order from server settings - var sortOrder = _serverSettings && _serverSettings.sort_order; - var mobile = isMobile(); - var ordered; - if (sortOrder === 'alphabetical') { - ordered = visible.slice().sort(function(a, b) { return (a.name || '').localeCompare(b.name || ''); }); } else { - // 'recent', 'manual', and default use server-provided order; priority sort on mobile - ordered = mobile ? sortByPriority(visible) : visible; + if (emptyState) emptyState.classList.add('hidden'); } - // Diff tiles: reuse existing, add new, remove stale - var existingTiles = new Map(); - if (grid) { - grid.querySelectorAll('session-tile').forEach(function(t) { - existingTiles.set(t.getAttribute('data-session-key') || (t.session && t.session.sessionKey), t); + // Set properties on the element — sorting, grouping, and tile + // diffing (including alphabetical / localeCompare ordering) are handled inside + // the component based on sortOrder and groupMode. + var grid = $('session-grid'); + if (!grid) return; + var ds = getDisplaySettings(); + grid.sessions = sessions; + grid.visibleSessions = visible; + grid.sortOrder = (_serverSettings && _serverSettings.sort_order) || 'manual'; + grid.groupMode = _gridViewMode; + grid.mobile = isMobile(); + grid.multiDevice = !!(_serverSettings && _serverSettings.multi_device_enabled); + grid.showDeviceBadges = ds.showDeviceBadges !== false; + grid.activityIndicator = ds.activityIndicator || 'both'; + grid.viewMode = ds.viewMode || 'auto'; + + // Bind session-open / session-options event listeners only once + if (!grid._eventsbound) { + grid._eventsbound = true; + grid.addEventListener('session-open', function(e) { + openSession(e.detail.name, { remoteId: e.detail.remoteId || undefined }); + }); + grid.addEventListener('session-options', function(e) { + openFlyoutMenu(e.detail.name, e.detail.sessionKey, e.detail.remoteId, e.detail.rect); }); } - var newKeys = new Set(); - var fragment = document.createDocumentFragment(); - var ds = getDisplaySettings(); - - function _setupTile(s) { - var key = s.sessionKey || s.name; - newKeys.add(key); - var tile = existingTiles.get(key); - if (!tile) { - tile = document.createElement('session-tile'); - tile.addEventListener('tile-click', function(e) { - openSession(e.detail.name, { remoteId: e.detail.remoteId || undefined }); - }); - tile.addEventListener('tile-options', function(e) { - openFlyoutMenu(e.detail.name, e.detail.sessionKey, e.detail.remoteId, e.detail.rect); - }); - } - tile.session = s; - tile.mobile = mobile; - tile.multiDevice = !!(_serverSettings && _serverSettings.multi_device_enabled); - tile.showDeviceBadges = ds.showDeviceBadges !== false; - tile.activityIndicator = ds.activityIndicator || 'both'; - tile.viewMode = ds.viewMode || 'auto'; - return tile; - } - - if (_gridViewMode === 'grouped') { - // Group by deviceName - var groups = {}; - var groupOrder = []; - for (var gi = 0; gi < ordered.length; gi++) { - var dn = ordered[gi].deviceName || 'Unknown'; - if (!groups[dn]) { groups[dn] = []; groupOrder.push(dn); } - groups[dn].push(ordered[gi]); - } - for (var g = 0; g < groupOrder.length; g++) { - var gname = groupOrder[g]; - var groupSessions = groups[gname]; - if (groupSessions.length === 0) continue; - var header = document.createElement('h3'); - header.className = 'device-group-header'; - header.textContent = gname; - fragment.appendChild(header); - for (var j = 0; j < groupSessions.length; j++) { - fragment.appendChild(_setupTile(groupSessions[j])); - } - } - } else { - for (var i = 0; i < ordered.length; i++) { - fragment.appendChild(_setupTile(ordered[i])); - } - } - - // Remove stale tiles - existingTiles.forEach(function(tile, key) { - if (!newKeys.has(key)) tile.remove(); - }); - - // Append status tiles for auth_failed and unreachable sessions. status:empty sentinels are - // intentionally ignored in all view modes — a remote with zero tmux sessions produces no - // visible tile. auth_failed and unreachable are actionable error states and are always shown. - var statusTilesHtml = ''; - (sessions || []).forEach(function(session) { - if (session.status === 'auth_failed') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Auth required', 'auth'); - else if (session.status === 'unreachable') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Offline', 'offline'); - }); - if (grid) { - grid.innerHTML = statusTilesHtml; - grid.prepend(fragment); - } - - // Clear filter bar (filtered mode removed; bar is a no-op for flat/grouped) - if (filterBar) filterBar.innerHTML = ''; if (_viewMode === 'fullscreen') { updatePillBell(); } - // Reapply view mode layout after grid HTML is rebuilt - var currentDs = getDisplaySettings(); - var currentMode = currentDs.viewMode || 'auto'; + // Reapply view mode layout after grid properties are set + var currentMode = ds.viewMode || 'auto'; if (currentMode === 'fit' && grid) { grid.classList.add('session-grid--fit'); applyFitLayout(grid); } - } // --------------------------------------------------------------------------- // Hover preview popover (desktop only — no hover on touch devices) // --------------------------------------------------------------------------- -// Click handler registered while preview is showing — navigates to the previewed session -function _previewClickHandler(e) { - e.preventDefault(); - e.stopPropagation(); - var name = _previewSessionName; - hidePreview(); - if (name) { - var session = _currentSessions && _currentSessions.find(function(s) { return s.name === name; }); - openSession(name, { remoteId: (session != null && session.remoteId != null) ? session.remoteId : '' }); - } -} +// _previewClickHandler removed — preview-click event is handled via component +// listener in bindStaticEventListeners(). function showPreview(name) { if (!name || !_currentSessions) return; - var _previewDs = getDisplaySettings(); - if (_previewDs.showHoverPreview === false) return; - var session = _currentSessions.find(function (s) { return s.name === name; }); + var ds = getDisplaySettings(); + if (ds.showHoverPreview === false) return; + var session = _currentSessions.find(function(s) { return s.name === name; }); if (!session || !session.snapshot) return; - - // If already showing this session, just update content - if (_previewPopover && _previewSessionName === name) { - var pre = _previewPopover.querySelector('pre'); - if (pre) pre.innerHTML = ansiToHtml(session.snapshot); - return; - } - - hidePreviewDOM(); - _previewSessionName = name; - - // Full-window overlay - var popover = document.createElement('div'); - popover.className = 'preview-popover'; - var pre = document.createElement('pre'); - pre.innerHTML = ansiToHtml(session.snapshot); - popover.appendChild(pre); - document.body.appendChild(popover); - _previewPopover = popover; - - // Auto-scroll to bottom (prompt area) - popover.scrollTop = popover.scrollHeight; - - // Click anywhere navigates to previewed session - document.addEventListener('click', _previewClickHandler, true); + var preview = $('hover-preview'); + if (!preview) return; + preview.sessionName = name; + preview.snapshot = session.snapshot; + preview.open = true; } -// hidePreviewDOM: removes the visual elements only (no render trigger) +// hidePreviewDOM: hides the component function hidePreviewDOM() { - document.removeEventListener('click', _previewClickHandler, true); - if (_previewPopover) { - _previewPopover.remove(); - _previewPopover = null; - } + var preview = $('hover-preview'); + if (preview) preview.open = false; } // hidePreview: full cleanup including timer and session name function hidePreview() { - if (_previewTimer) { - clearTimeout(_previewTimer); - _previewTimer = null; - } + if (_previewTimer) { clearTimeout(_previewTimer); _previewTimer = null; } hidePreviewDOM(); _previewSessionName = null; } @@ -3753,7 +3628,10 @@ function handleGlobalKeydown(e) { function openBottomSheet() { var sheet = $('bottom-sheet'); if (!sheet) return; - renderSheetList(); + sheet.sessions = getVisibleSessions(_currentSessions); + sheet.viewingSession = _viewingSession || ''; + sheet.viewingRemoteId = _viewingRemoteId || ''; + sheet.open = true; sheet.classList.remove('hidden'); } @@ -3763,7 +3641,7 @@ function openBottomSheet() { */ function closeBottomSheet() { var sheet = $('bottom-sheet'); - if (sheet) sheet.classList.add('hidden'); + if (sheet) { sheet.open = false; sheet.classList.add('hidden'); } } /** @@ -3772,31 +3650,11 @@ function closeBottomSheet() { * and binds click handlers to switch sessions. */ function renderSheetList() { - var list = $('sheet-list'); - if (!list) return; - var sorted = sortByPriority(getVisibleSessions(_currentSessions)); - list.innerHTML = sorted.map(function(s) { - var hasBell = s.bell && s.bell.unseen_count > 0 && - (s.bell.seen_at === null || s.bell.last_fired_at > s.bell.seen_at); - var isActive = s.name === _viewingSession && (s.remoteId ?? '') === (_viewingRemoteId ?? ''); - var escapedName = escapeHtml(s.name || ''); - var remoteIdAttr = s.remoteId != null ? ' data-remote-id="' + escapeHtml(s.remoteId) + '"' : ''; - return '
  • ' + - '' + escapedName + '' + - (hasBell ? '' + _icons.bell + '' : '') + - '' + formatTimestamp(s.bell && s.bell.last_fired_at) + '' + - '
  • '; - }).join(''); - - list.querySelectorAll('.sheet-item').forEach(function(item) { - item.addEventListener('click', function() { - closeBottomSheet(); - var name = item.dataset.session; - var remoteId = item.dataset.remoteId || ''; - if (name !== _viewingSession || remoteId !== (_viewingRemoteId ?? '')) openSession(name, { remoteId: remoteId }); - }); - }); + var sheet = $('bottom-sheet'); + if (!sheet) return; + sheet.sessions = getVisibleSessions(_currentSessions); + sheet.viewingSession = _viewingSession || ''; + sheet.viewingRemoteId = _viewingRemoteId || ''; } /** @@ -4240,7 +4098,30 @@ function bindStaticEventListeners() { document.addEventListener('keydown', handleGlobalKeydown); var sessionPillEl = $('session-pill'); if (sessionPillEl) sessionPillEl.addEventListener('pill-click', openBottomSheet); - on($('sheet-backdrop'), 'click', closeBottomSheet); + // Bottom sheet — component handles backdrop internally; listen for custom events + var bottomSheet = $('bottom-sheet'); + if (bottomSheet) { + bottomSheet.addEventListener('sheet-select', function(e) { + closeBottomSheet(); + openSession(e.detail.name, { remoteId: e.detail.remoteId }); + }); + bottomSheet.addEventListener('sheet-close', function() { + closeBottomSheet(); + }); + } + + // Hover preview — component handles click internally; listen for preview-click + var hoverPreview = $('hover-preview'); + if (hoverPreview) { + hoverPreview.addEventListener('preview-click', function(e) { + hidePreview(); + var name = e.detail.name; + if (name) { + var session = _currentSessions && _currentSessions.find(function(s) { return s.name === name; }); + openSession(name, { remoteId: (session != null && session.remoteId != null) ? session.remoteId : '' }); + } + }); + } // Settings dialog bindings on($('view-mode-btn'), 'click', cycleViewMode); @@ -4580,7 +4461,7 @@ if (typeof module !== 'undefined' && module.exports) { toggleSidebar, bindSidebarClickAway, renderGrid, - renderGroupedGrid, + renderGroupedGrid: function renderGroupedGrid() {}, requestNotificationPermission, handleBellTransitions, sendHeartbeat, @@ -4637,7 +4518,7 @@ if (typeof module !== 'undefined' && module.exports) { openFlyoutMenu, closeFlyoutMenu, // Filter bar - renderFilterBar, + renderFilterBar: function renderFilterBar() {}, // View dropdown renderViewDropdown, toggleViewDropdown, diff --git a/muxplex/frontend/components/bottom-sheet-switcher.js b/muxplex/frontend/components/bottom-sheet-switcher.js new file mode 100644 index 0000000..42f0b36 --- /dev/null +++ b/muxplex/frontend/components/bottom-sheet-switcher.js @@ -0,0 +1,81 @@ +import { LitElement, html, nothing } from '/vendor/lit/lit.min.js'; +import { unsafeHTML } from '/vendor/lit/lit.min.js'; +import { formatTimestamp, sortByPriority } from './utils.js'; +import { icons } from './icons.js'; + +/** + * - Mobile session-switching overlay. + * + * Properties: + * sessions: Array - session list (will be sorted by priority internally) + * viewingSession: String - name of currently viewed session + * viewingRemoteId: String - remoteId of currently viewed session + * open: Boolean - whether the sheet is visible + * + * Events: + * sheet-select { name, remoteId } - Session selected + * sheet-close - Backdrop/handle dismissed + */ +export class BottomSheetSwitcher extends LitElement { + static properties = { + sessions: { type: Array }, + viewingSession: { type: String, attribute: 'viewing-session' }, + viewingRemoteId: { type: String, attribute: 'viewing-remote-id' }, + open: { type: Boolean, reflect: true }, + }; + + // Light DOM — existing CSS applies + createRenderRoot() { return this; } + + constructor() { + super(); + this.sessions = []; + this.viewingSession = ''; + this.viewingRemoteId = ''; + this.open = false; + } + + _onBackdropClick() { + this.dispatchEvent(new CustomEvent('sheet-close', { bubbles: true, composed: true })); + } + + _onItemClick(name, remoteId) { + if (name !== this.viewingSession || (remoteId || '') !== (this.viewingRemoteId || '')) { + this.dispatchEvent(new CustomEvent('sheet-select', { + bubbles: true, composed: true, + detail: { name, remoteId: remoteId || '' }, + })); + } + this.dispatchEvent(new CustomEvent('sheet-close', { bubbles: true, composed: true })); + } + + render() { + const sorted = sortByPriority(this.sessions || []); + return html` +
    +
    + +
      + ${sorted.map(s => { + const hasBell = s.bell && s.bell.unseen_count > 0 && + (s.bell.seen_at === null || s.bell.last_fired_at > s.bell.seen_at); + const isActive = s.name === this.viewingSession && + (s.remoteId ?? '') === (this.viewingRemoteId ?? ''); + const name = s.name || ''; + const remoteId = s.remoteId ?? ''; + return html` +
    • this._onItemClick(name, remoteId)}> + ${name} + ${hasBell ? html`${unsafeHTML(icons.bell)}` : nothing} + ${formatTimestamp(s.bell && s.bell.last_fired_at)} +
    • `; + })} +
    +
    + `; + } +} + +customElements.define('bottom-sheet-switcher', BottomSheetSwitcher); \ No newline at end of file diff --git a/muxplex/frontend/components/hover-preview.js b/muxplex/frontend/components/hover-preview.js new file mode 100644 index 0000000..8383b87 --- /dev/null +++ b/muxplex/frontend/components/hover-preview.js @@ -0,0 +1,95 @@ +import { LitElement, html, css } from '/vendor/lit/lit.min.js'; +import { unsafeHTML } from '/vendor/lit/lit.min.js'; +import { ansiToHtml } from './utils.js'; + +/** + * - Full-window snapshot popover for session hover. + * + * Properties: + * sessionName: String - name of the session being previewed + * snapshot: String - raw terminal snapshot text (ANSI) + * open: Boolean - whether the preview is visible + * + * Events: + * preview-click { name } - Fired when user clicks the preview (navigate to session) + * preview-close - Fired when preview should be dismissed + */ +export class HoverPreview extends LitElement { + static properties = { + sessionName: { type: String, attribute: 'session-name' }, + snapshot: { type: String }, + open: { type: Boolean, reflect: true }, + }; + + static styles = css` + :host { display: none; } + :host([open]) { display: block; } + + .preview-popover { + position: fixed; + inset: 48px 12px 12px 12px; + z-index: 300; + background: var(--bg-primary, #0d1117); + border: 1px solid var(--border-muted, #30363d); + border-radius: 8px; + overflow: auto; + padding: 12px; + cursor: pointer; + } + .preview-popover pre { + margin: 0; + font-family: inherit; + font-size: 12px; + line-height: 1.4; + white-space: pre; + color: var(--fg-default, #c9d1d9); + } + `; + + constructor() { + super(); + this.sessionName = ''; + this.snapshot = ''; + this.open = false; + this._boundDocClick = this._onDocumentClick.bind(this); + } + + updated(changed) { + if (changed.has('open')) { + if (this.open) { + // Scroll to bottom after render (prompt area) + const popover = this.shadowRoot.querySelector('.preview-popover'); + if (popover) popover.scrollTop = popover.scrollHeight; + // Close on any click outside (capture phase, next tick) + setTimeout(() => document.addEventListener('click', this._boundDocClick, true), 0); + } else { + document.removeEventListener('click', this._boundDocClick, true); + } + } + } + + disconnectedCallback() { + super.disconnectedCallback(); + document.removeEventListener('click', this._boundDocClick, true); + } + + _onDocumentClick(e) { + e.preventDefault(); + e.stopPropagation(); + this.dispatchEvent(new CustomEvent('preview-click', { + bubbles: true, composed: true, + detail: { name: this.sessionName }, + })); + } + + render() { + if (!this.open) return html``; + return html` +
    +
    ${unsafeHTML(ansiToHtml(this.snapshot || ''))}
    +
    + `; + } +} + +customElements.define('hover-preview', HoverPreview); \ No newline at end of file diff --git a/muxplex/frontend/components/session-grid.js b/muxplex/frontend/components/session-grid.js new file mode 100644 index 0000000..6bc4b27 --- /dev/null +++ b/muxplex/frontend/components/session-grid.js @@ -0,0 +1,147 @@ +import { LitElement, html, nothing } from '/vendor/lit/lit.min.js'; +import { repeat } from '/vendor/lit/lit.min.js'; +import { unsafeHTML } from '/vendor/lit/lit.min.js'; +import { sortByPriority, escapeHtml } from './utils.js'; +import './session-tile.js'; + +/** + * - Dashboard grid of session tiles. + * + * Owns sorting, grouping, status tiles, and the keyed repeat loop. + * Replaces the renderGrid() function's innerHTML + diff-patch approach. + * + * Properties: + * sessions: Array - raw session list (includes status sentinels) + * visibleSessions: Array - already-filtered visible sessions + * sortOrder: String - 'alphabetical' | 'recent' | 'manual' + * groupMode: String - 'flat' | 'grouped' + * mobile: Boolean - mobile layout + * multiDevice: Boolean - multi-device enabled + * showDeviceBadges: Boolean + * activityIndicator: String + * viewMode: String - 'auto' | 'fit' + * + * Events: + * session-open { name, remoteId } + * session-options { name, remoteId, sessionKey, rect } + */ +export class SessionGrid extends LitElement { + static properties = { + sessions: { type: Array }, + visibleSessions: { type: Array }, + sortOrder: { type: String, attribute: 'sort-order' }, + groupMode: { type: String, attribute: 'group-mode' }, + mobile: { type: Boolean }, + multiDevice: { type: Boolean, attribute: 'multi-device' }, + showDeviceBadges: { type: Boolean, attribute: 'show-device-badges' }, + activityIndicator: { type: String, attribute: 'activity-indicator' }, + viewMode: { type: String, attribute: 'view-mode' }, + }; + + // Light DOM so existing CSS grid styles apply + createRenderRoot() { return this; } + + constructor() { + super(); + this.sessions = []; + this.visibleSessions = []; + this.sortOrder = 'manual'; + this.groupMode = 'flat'; + this.mobile = false; + this.multiDevice = false; + this.showDeviceBadges = true; + this.activityIndicator = 'both'; + this.viewMode = 'auto'; + } + + get _ordered() { + const visible = this.visibleSessions || []; + if (this.sortOrder === 'alphabetical') { + return visible.slice().sort((a, b) => (a.name || '').localeCompare(b.name || '')); + } + return this.mobile ? sortByPriority(visible) : visible; + } + + get _statusTiles() { + return (this.sessions || []).filter(s => + s.status === 'auth_failed' || s.status === 'unreachable' + ); + } + + _onTileClick(e) { + this.dispatchEvent(new CustomEvent('session-open', { + bubbles: true, composed: true, + detail: e.detail, + })); + } + + _onTileOptions(e) { + this.dispatchEvent(new CustomEvent('session-options', { + bubbles: true, composed: true, + detail: e.detail, + })); + } + + _renderTile(s) { + return html` + + `; + } + + _renderStatusTile(s) { + const label = s.status === 'auth_failed' ? 'Auth required' : 'Offline'; + const cls = s.status === 'auth_failed' ? 'auth' : 'offline'; + const name = escapeHtml(s.deviceName || 'Unknown'); + return html` +
    +
    + ${name} + ${label} +
    +
    + `; + } + + render() { + const ordered = this._ordered; + const statusTiles = this._statusTiles; + + if (ordered.length === 0 && statusTiles.length === 0) { + return nothing; + } + + if (this.groupMode === 'grouped') { + const groups = new Map(); + const groupOrder = []; + for (const s of ordered) { + const dn = s.deviceName || 'Unknown'; + if (!groups.has(dn)) { groups.set(dn, []); groupOrder.push(dn); } + groups.get(dn).push(s); + } + + return html` + ${groupOrder.map(gname => html` +

    ${gname}

    + ${repeat(groups.get(gname), s => s.sessionKey || s.name, s => this._renderTile(s))} + `)} + ${statusTiles.map(s => this._renderStatusTile(s))} + `; + } + + return html` + ${repeat(ordered, s => s.sessionKey || s.name, s => this._renderTile(s))} + ${statusTiles.map(s => this._renderStatusTile(s))} + `; + } +} + +customElements.define('session-grid', SessionGrid); \ No newline at end of file diff --git a/muxplex/frontend/components/sidebar-item.js b/muxplex/frontend/components/sidebar-item.js new file mode 100644 index 0000000..8b876ee --- /dev/null +++ b/muxplex/frontend/components/sidebar-item.js @@ -0,0 +1,126 @@ +import { LitElement, html, nothing } from '/vendor/lit/lit.min.js'; +import { unsafeHTML } from '/vendor/lit/lit.min.js'; +import { escapeHtml, ansiToHtml } from './utils.js'; +import { icons } from './icons.js'; + +/** + * - Session entry in the expanded-view sidebar. + * + * Properties: + * session: Object - session data + * active: Boolean - whether this is the currently viewed session + * multiDevice: Boolean - multi-device enabled + * showDeviceBadges: Boolean - show device name badges + * activityIndicator: String - 'none' | 'glow' | 'dot' | 'both' + * + * Events: + * sidebar-select { name, remoteId } - Session clicked + * sidebar-options { name, remoteId, sessionKey, rect } - Kebab menu clicked + */ +export class SidebarItem extends LitElement { + static properties = { + session: { type: Object }, + active: { type: Boolean, reflect: true }, + multiDevice: { type: Boolean, attribute: 'multi-device' }, + showDeviceBadges: { type: Boolean, attribute: 'show-device-badges' }, + activityIndicator: { type: String, attribute: 'activity-indicator' }, + }; + + // Light DOM — existing CSS applies + createRenderRoot() { return this; } + + constructor() { + super(); + this.session = {}; + this.active = false; + this.multiDevice = false; + this.showDeviceBadges = true; + this.activityIndicator = 'both'; + } + + get _name() { return this.session.name || ''; } + get _sessionKey() { return this.session.sessionKey || this._name; } + get _remoteId() { return this.session.remoteId ?? null; } + + get _isBell() { + const unseen = this.session.bell && this.session.bell.unseen_count; + return unseen && unseen > 0; + } + + get _classes() { + let cls = 'sidebar-item'; + if (this.active) cls += ' sidebar-item--active'; + const ind = this.activityIndicator; + if (this._isBell && (ind === 'glow' || ind === 'both')) cls += ' sidebar-item--bell'; + if (this._isBell && (ind === 'dot' || ind === 'both')) cls += ' sidebar-item--edge-bell'; + return cls; + } + + get _snapshotHtml() { + const snapshot = this.session.snapshot || ''; + const allLines = snapshot.split('\n'); + while (allLines.length > 0 && allLines[allLines.length - 1].trim() === '') { + allLines.pop(); + } + return ansiToHtml(allLines.slice(-20).join('\n')); + } + + _onClick(e) { + if (e.target.closest('.tile-options-btn')) return; + this.dispatchEvent(new CustomEvent('sidebar-select', { + bubbles: true, composed: true, + detail: { name: this._name, remoteId: this._remoteId }, + })); + } + + _onOptionsClick(e) { + e.stopPropagation(); + const btn = e.currentTarget; + this.dispatchEvent(new CustomEvent('sidebar-options', { + bubbles: true, composed: true, + detail: { + name: this._name, + remoteId: this._remoteId, + sessionKey: this._sessionKey, + rect: btn.getBoundingClientRect(), + }, + })); + } + + render() { + const name = this._name; + const remoteIdStr = this._remoteId != null ? String(this._remoteId) : ''; + const showBadge = this.multiDevice && this.session.deviceName && this.showDeviceBadges; + + return html` +
    + + +
    + `; + } +} + +customElements.define('sidebar-item', SidebarItem); \ No newline at end of file diff --git a/muxplex/frontend/index.html b/muxplex/frontend/index.html index 2db1d4b..18466fc 100644 --- a/muxplex/frontend/index.html +++ b/muxplex/frontend/index.html @@ -34,7 +34,7 @@
    -
    + @@ -95,13 +95,7 @@ - +