feat: add initial desktop view implementation
This commit is contained in:
@@ -19,6 +19,97 @@ class ClusterMembersComponent extends Component {
|
|||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
}, 200);
|
}, 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 = `
|
||||||
|
<div class="drawer-title">Node Details</div>
|
||||||
|
<button class="drawer-close" aria-label="Close">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M18 6L6 18M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
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 = '<div class="loading-details">Loading detailed information...</div>';
|
||||||
|
|
||||||
|
// 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 = `
|
||||||
|
<div class="error">
|
||||||
|
<strong>Error loading node details:</strong><br>
|
||||||
|
${this.escapeHtml(error.message)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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() {
|
mount() {
|
||||||
@@ -409,8 +500,16 @@ class ClusterMembersComponent extends Component {
|
|||||||
this.addEventListener(card, 'click', async (e) => {
|
this.addEventListener(card, 'click', async (e) => {
|
||||||
if (e.target === expandIcon) return;
|
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) {
|
if (isExpanding) {
|
||||||
await this.expandCard(card, memberIp, memberDetails);
|
await this.expandCard(card, memberIp, memberDetails);
|
||||||
} else {
|
} else {
|
||||||
@@ -422,9 +521,11 @@ class ClusterMembersComponent extends Component {
|
|||||||
if (expandIcon) {
|
if (expandIcon) {
|
||||||
this.addEventListener(expandIcon, 'click', async (e) => {
|
this.addEventListener(expandIcon, 'click', async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
if (this.isDesktop()) {
|
||||||
|
this.openDrawerForMember(memberIp);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const isExpanding = !card.classList.contains('expanded');
|
const isExpanding = !card.classList.contains('expanded');
|
||||||
|
|
||||||
if (isExpanding) {
|
if (isExpanding) {
|
||||||
await this.expandCard(card, memberIp, memberDetails);
|
await this.expandCard(card, memberIp, memberDetails);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -475,9 +475,14 @@ class NodeDetailsComponent extends Component {
|
|||||||
uploadBtn.disabled = true;
|
uploadBtn.disabled = true;
|
||||||
uploadBtn.textContent = '⏳ Uploading...';
|
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 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) {
|
if (!memberIp) {
|
||||||
throw new Error('Could not determine target node IP address');
|
throw new Error('Could not determine target node IP address');
|
||||||
|
|||||||
@@ -413,7 +413,6 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.endpoint-item:hover {
|
.endpoint-item:hover {
|
||||||
background: rgba(0, 0, 0, 0.25);
|
|
||||||
border-color: rgba(255, 255, 255, 0.15);
|
border-color: rgba(255, 255, 255, 0.15);
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
@@ -2860,6 +2859,72 @@ p {
|
|||||||
display: inline-flex;
|
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 */
|
/* Labels section styling in node details */
|
||||||
.detail-section {
|
.detail-section {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
|
|||||||
Reference in New Issue
Block a user