From 6f1e194545402f864dcd2b06f6620282101fa027 Mon Sep 17 00:00:00 2001 From: Patrick Balsiger Date: Mon, 15 Sep 2025 20:57:30 +0200 Subject: [PATCH] feat: add initial desktop view implementation --- .../components/ClusterMembersComponent.js | 107 +++++++++++++++++- .../components/NodeDetailsComponent.js | 9 +- public/styles/main.css | 67 ++++++++++- 3 files changed, 177 insertions(+), 6 deletions(-) diff --git a/public/scripts/components/ClusterMembersComponent.js b/public/scripts/components/ClusterMembersComponent.js index e6dcb14..8a33db5 100644 --- a/public/scripts/components/ClusterMembersComponent.js +++ b/public/scripts/components/ClusterMembersComponent.js @@ -19,6 +19,97 @@ class ClusterMembersComponent extends Component { this.render(); } }, 200); + + // Drawer state for desktop + this.detailsDrawer = null; + this.detailsDrawerContent = null; + this.detailsDrawerBackdrop = null; + this.activeDrawerComponent = null; + } + + // Determine if we should use desktop drawer behavior + isDesktop() { + try { + return window && window.innerWidth >= 1024; // desktop threshold + } catch (_) { + return false; + } + } + + ensureDrawer() { + if (this.detailsDrawer) return; + // Create backdrop + this.detailsDrawerBackdrop = document.createElement('div'); + this.detailsDrawerBackdrop.className = 'details-drawer-backdrop'; + document.body.appendChild(this.detailsDrawerBackdrop); + + // Create drawer + this.detailsDrawer = document.createElement('div'); + this.detailsDrawer.className = 'details-drawer'; + + // Header with close button + const header = document.createElement('div'); + header.className = 'details-drawer-header'; + header.innerHTML = ` +
Node Details
+ + `; + this.detailsDrawer.appendChild(header); + + // Content container + this.detailsDrawerContent = document.createElement('div'); + this.detailsDrawerContent.className = 'details-drawer-content'; + this.detailsDrawer.appendChild(this.detailsDrawerContent); + + document.body.appendChild(this.detailsDrawer); + + // Close handlers + const close = () => this.closeDrawer(); + header.querySelector('.drawer-close').addEventListener('click', close); + this.detailsDrawerBackdrop.addEventListener('click', close); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') close(); + }); + } + + openDrawerForMember(memberIp) { + this.ensureDrawer(); + + // Clear previous component if any + if (this.activeDrawerComponent && typeof this.activeDrawerComponent.unmount === 'function') { + try { this.activeDrawerComponent.unmount(); } catch (_) {} + } + this.detailsDrawerContent.innerHTML = '
Loading detailed information...
'; + + // Load and mount NodeDetails into drawer + const nodeDetailsVM = new NodeDetailsViewModel(); + const nodeDetailsComponent = new NodeDetailsComponent(this.detailsDrawerContent, nodeDetailsVM, this.eventBus); + this.activeDrawerComponent = nodeDetailsComponent; + + nodeDetailsVM.loadNodeDetails(memberIp).then(() => { + nodeDetailsComponent.mount(); + }).catch((error) => { + logger.error('Failed to load node details for drawer:', error); + this.detailsDrawerContent.innerHTML = ` +
+ Error loading node details:
+ ${this.escapeHtml(error.message)} +
+ `; + }); + + // Open drawer + this.detailsDrawer.classList.add('open'); + this.detailsDrawerBackdrop.classList.add('visible'); + } + + closeDrawer() { + if (this.detailsDrawer) this.detailsDrawer.classList.remove('open'); + if (this.detailsDrawerBackdrop) this.detailsDrawerBackdrop.classList.remove('visible'); } mount() { @@ -409,8 +500,16 @@ class ClusterMembersComponent extends Component { this.addEventListener(card, 'click', async (e) => { if (e.target === expandIcon) return; - const isExpanding = !card.classList.contains('expanded'); + // On desktop, open slide-in drawer instead of inline expand + if (this.isDesktop()) { + e.preventDefault(); + e.stopPropagation(); + this.openDrawerForMember(memberIp); + return; + } + // Mobile/low-res: keep inline expand/collapse + const isExpanding = !card.classList.contains('expanded'); if (isExpanding) { await this.expandCard(card, memberIp, memberDetails); } else { @@ -422,9 +521,11 @@ class ClusterMembersComponent extends Component { if (expandIcon) { this.addEventListener(expandIcon, 'click', async (e) => { e.stopPropagation(); - + if (this.isDesktop()) { + this.openDrawerForMember(memberIp); + return; + } const isExpanding = !card.classList.contains('expanded'); - if (isExpanding) { await this.expandCard(card, memberIp, memberDetails); } else { diff --git a/public/scripts/components/NodeDetailsComponent.js b/public/scripts/components/NodeDetailsComponent.js index cc23619..fe12e6e 100644 --- a/public/scripts/components/NodeDetailsComponent.js +++ b/public/scripts/components/NodeDetailsComponent.js @@ -475,9 +475,14 @@ class NodeDetailsComponent extends Component { uploadBtn.disabled = true; uploadBtn.textContent = '⏳ Uploading...'; - // Get the member IP from the card + // Get the member IP from the card if available, otherwise fallback to view model state const memberCard = this.container.closest('.member-card'); - const memberIp = memberCard.dataset.memberIp; + let memberIp = null; + if (memberCard && memberCard.dataset && memberCard.dataset.memberIp) { + memberIp = memberCard.dataset.memberIp; + } else if (this.viewModel && typeof this.viewModel.get === 'function') { + memberIp = this.viewModel.get('nodeIp'); + } if (!memberIp) { throw new Error('Could not determine target node IP address'); diff --git a/public/styles/main.css b/public/styles/main.css index 42835eb..8ac58df 100644 --- a/public/styles/main.css +++ b/public/styles/main.css @@ -413,7 +413,6 @@ p { } .endpoint-item:hover { - background: rgba(0, 0, 0, 0.25); border-color: rgba(255, 255, 255, 0.15); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); } @@ -2860,6 +2859,72 @@ p { display: inline-flex; } +/* Desktop slide-in details drawer */ +.details-drawer-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.3); + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease; + z-index: 999; +} +.details-drawer-backdrop.visible { opacity: 1; pointer-events: auto; } + +.details-drawer { + position: fixed; + top: 0; + right: 0; + height: 100vh; + width: clamp(33.333vw, 520px, 90vw); + background: var(--bg-primary); + color: var(--text-primary); + border-left: 1px solid var(--border-primary); + box-shadow: var(--shadow-primary); + transform: translateX(100%); + transition: transform 0.25s ease; + z-index: 1000; + display: flex; + flex-direction: column; +} +.details-drawer.open { transform: translateX(0); } + +.details-drawer-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border-secondary); + background: var(--bg-secondary); +} +.details-drawer-header .drawer-title { + font-weight: 600; +} +.drawer-close { + background: transparent; + border: 1px solid var(--border-secondary); + color: var(--text-secondary); + border-radius: 8px; + padding: 0.35rem; + cursor: pointer; +} +.drawer-close:hover { + background: var(--bg-hover); + color: var(--text-primary); +} +.drawer-close svg { width: 18px; height: 18px; } + +.details-drawer-content { + padding: 1rem; + overflow: auto; +} + +/* Only enable drawer on wider screens; on small keep inline cards */ +@media (max-width: 1023px) { + .details-drawer, + .details-drawer-backdrop { display: none; } +} + /* Labels section styling in node details */ .detail-section { margin-top: 20px;