Skip to content

Commit 0266a40

Browse files
committed
feat: add server dropdown menu in sidebar
Replace static 'My Servers' link with a toggleable dropdown that lists user's servers with status dots, allowing direct navigation to /server/ID pages. Servers are lazy-loaded on first open.
1 parent a3a84d0 commit 0266a40

2 files changed

Lines changed: 190 additions & 4 deletions

File tree

public/css/style.css

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,24 @@ cap-widget {
528528
gap: 0;
529529
}
530530

531+
.sidebar.collapsed .nav-parent {
532+
font-size: 0;
533+
justify-content: center;
534+
padding: 10px 0;
535+
gap: 0;
536+
}
537+
538+
.sidebar.collapsed .nav-parent-label,
539+
.sidebar.collapsed .nav-parent-chevron {
540+
display: none;
541+
}
542+
543+
.sidebar.collapsed .nav-parent svg {
544+
font-size: 1rem;
545+
width: 20px;
546+
height: 20px;
547+
}
548+
531549
.sidebar.collapsed .nav-indicator {
532550
left: 8px;
533551
width: calc(100% - 16px);
@@ -688,6 +706,120 @@ cap-widget {
688706
flex-shrink: 0;
689707
}
690708

709+
.nav-parent {
710+
display: flex;
711+
align-items: center;
712+
gap: 12px;
713+
padding: 10px 12px;
714+
border-radius: var(--radius-sm);
715+
color: var(--text-secondary);
716+
font-size: 0.9rem;
717+
font-weight: 500;
718+
cursor: pointer;
719+
transition: color var(--transition);
720+
text-decoration: none;
721+
margin-bottom: 2px;
722+
position: relative;
723+
z-index: 2;
724+
user-select: none;
725+
}
726+
727+
.nav-parent:hover {
728+
color: var(--text-primary);
729+
background: rgba(238, 129, 50, 0.08);
730+
}
731+
732+
.nav-parent svg {
733+
width: 18px;
734+
height: 18px;
735+
flex-shrink: 0;
736+
}
737+
738+
.nav-parent-label {
739+
flex: 1;
740+
min-width: 0;
741+
}
742+
743+
.nav-parent-chevron {
744+
width: 16px;
745+
height: 16px;
746+
flex-shrink: 0;
747+
transition: transform 0.2s ease;
748+
}
749+
750+
.nav-parent.open .nav-parent-chevron {
751+
transform: rotate(90deg);
752+
}
753+
754+
.nav-sub-list {
755+
max-height: 0;
756+
overflow: hidden;
757+
transition: max-height 0.25s ease;
758+
margin-bottom: 2px;
759+
}
760+
761+
.nav-sub-list.open {
762+
max-height: 400px;
763+
}
764+
765+
.sidebar.collapsed .nav-sub-list {
766+
display: none;
767+
}
768+
769+
.nav-sub-item {
770+
display: flex;
771+
align-items: center;
772+
gap: 10px;
773+
padding: 7px 12px 7px 42px;
774+
border-radius: var(--radius-sm);
775+
color: var(--text-secondary);
776+
font-size: 0.82rem;
777+
font-weight: 500;
778+
cursor: pointer;
779+
transition: color var(--transition);
780+
text-decoration: none;
781+
margin-bottom: 1px;
782+
position: relative;
783+
z-index: 2;
784+
white-space: nowrap;
785+
overflow: hidden;
786+
text-overflow: ellipsis;
787+
}
788+
789+
.nav-sub-item:hover {
790+
color: var(--text-primary);
791+
background: rgba(238, 129, 50, 0.08);
792+
}
793+
794+
.nav-sub-item.active {
795+
color: var(--accent-1);
796+
}
797+
798+
.nav-sub-item .nav-sub-dot {
799+
width: 6px;
800+
height: 6px;
801+
border-radius: 50%;
802+
flex-shrink: 0;
803+
}
804+
805+
.nav-sub-dot.dot-active {
806+
background: #22c55e;
807+
}
808+
809+
.nav-sub-dot.dot-installing {
810+
background: #f59e0b;
811+
}
812+
813+
.nav-sub-dot.dot-suspended {
814+
background: var(--accent-red);
815+
}
816+
817+
.nav-sub-empty {
818+
padding: 8px 12px 8px 42px;
819+
font-size: 0.8rem;
820+
color: var(--text-muted);
821+
}
822+
691823
.sidebar-footer {
692824
padding: 12px 12px 4px;
693825
border-top: 1px solid var(--border);

public/js/app.js

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ const state = {
1818
notifPanelOpen: false,
1919
sidebarMode: 'main',
2020
accountTab: 'info',
21+
sidebarServersOpen: false,
22+
sidebarServersLoading: false,
2123
};
2224

2325
function setRgpdConsent(preferences) {
@@ -1135,7 +1137,7 @@ function initSidebarTooltip() {
11351137
}
11361138

11371139
sidebarNav.addEventListener('mouseover', (e) => {
1138-
const item = e.target.closest('.nav-item');
1140+
const item = e.target.closest('.nav-item, .nav-parent');
11391141
if (!item) return;
11401142
if (!sidebar.classList.contains('collapsed')) return;
11411143

@@ -1199,10 +1201,27 @@ function renderSidebarNav() {
11991201
<i data-lucide="grid-3x3"></i>
12001202
Overview
12011203
</a>
1202-
<a class="nav-item ${state.currentPage === 'servers' ? 'active' : ''}" data-page="servers" href="/servers">
1204+
<div class="nav-parent ${state.sidebarServersOpen ? 'open' : ''}" id="nav-servers-toggle">
12031205
<i data-lucide="server"></i>
1204-
My Servers
1205-
</a>
1206+
<span class="nav-parent-label">My Servers</span>
1207+
<svg class="nav-parent-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>
1208+
</div>
1209+
<div class="nav-sub-list ${state.sidebarServersOpen ? 'open' : ''}" id="nav-servers-list">
1210+
${state.sidebarServersLoading ? html`<div class="nav-sub-empty"><span class="spinner"></span> Loading...</div>` :
1211+
state.servers.length === 0 ? html`<div class="nav-sub-empty">No servers</div>` :
1212+
state.servers.map(s => {
1213+
const isInstalling = s.status === 'installing' || s.installed === 0 || s.installed === '0' || s.installed === false;
1214+
const isSuspended = s.status === 'suspended';
1215+
const dotClass = isSuspended ? 'dot-suspended' : (isInstalling ? 'dot-installing' : 'dot-active');
1216+
const isActive = state.currentPage === 'server' && state.serverId === s.id;
1217+
return html`
1218+
<a class="nav-sub-item ${isActive ? 'active' : ''}" data-server-nav="${s.id}" href="/server/${s.id}">
1219+
<span class="nav-sub-dot ${dotClass}"></span>
1220+
${escapeHtml(s.name)}
1221+
</a>
1222+
`;
1223+
}).join('')}
1224+
</div>
12061225
<a class="nav-item" id="nav-notifications" href="#">
12071226
<span style="position:relative;display:inline-flex">
12081227
<i data-lucide="bell"></i>
@@ -1255,6 +1274,37 @@ function renderSidebarNav() {
12551274
});
12561275
}
12571276

1277+
const serversToggle = document.querySelector('#nav-servers-toggle');
1278+
if (serversToggle) {
1279+
serversToggle.addEventListener('click', async (e) => {
1280+
e.preventDefault();
1281+
state.sidebarServersOpen = !state.sidebarServersOpen;
1282+
serversToggle.classList.toggle('open', state.sidebarServersOpen);
1283+
const subList = document.querySelector('#nav-servers-list');
1284+
if (subList) subList.classList.toggle('open', state.sidebarServersOpen);
1285+
if (state.sidebarServersOpen && state.servers.length === 0 && !state.sidebarServersLoading) {
1286+
state.sidebarServersLoading = true;
1287+
const subListEl = document.querySelector('#nav-servers-list');
1288+
if (subListEl) subListEl.innerHTML = html`<div class="nav-sub-empty"><span class="spinner"></span> Loading...</div>`;
1289+
try {
1290+
const data = await api('/servers/list');
1291+
state.servers = data.servers || [];
1292+
} catch (err) {
1293+
state.servers = [];
1294+
}
1295+
state.sidebarServersLoading = false;
1296+
renderSidebarNav();
1297+
}
1298+
});
1299+
}
1300+
1301+
document.querySelectorAll('.nav-sub-item[data-server-nav]').forEach(item => {
1302+
item.addEventListener('click', (e) => {
1303+
e.preventDefault();
1304+
navigateTo('server/' + item.dataset.serverNav);
1305+
});
1306+
});
1307+
12581308
updateNavIndicator();
12591309
initIcons();
12601310
}
@@ -1343,6 +1393,8 @@ function navigateTo(page) {
13431393
if (state.sidebarMode !== newSidebarMode) {
13441394
state.sidebarMode = newSidebarMode;
13451395
renderSidebarNav();
1396+
} else if (basePage === 'server') {
1397+
renderSidebarNav();
13461398
}
13471399

13481400
const url = basePage === 'overview' && !param ? '/' : `/${page}`;
@@ -1457,6 +1509,8 @@ window.addEventListener('popstate', () => {
14571509
if (state.sidebarMode !== newSidebarMode) {
14581510
state.sidebarMode = newSidebarMode;
14591511
renderSidebarNav();
1512+
} else if (basePage === 'server') {
1513+
renderSidebarNav();
14601514
}
14611515

14621516
if (basePage === 'server' && state.serverId) {

0 commit comments

Comments
 (0)