gentelella
Version:
Gentelella v4 — free admin template. 60 pages, 20 chart variants, fully interactive inbox & kanban, live theme generator, component playground, PWA-ready. Vite 8, vanilla JS, no Bootstrap, no jQuery.
521 lines (474 loc) • 22 kB
JavaScript
// Gentelella 2026 v4 — runtime shell mount
// At build/dev time the Vite plugin (vite.config.js) injects sidebar/topbar/
// footer directly into each production/*.html. mountShell() is the runtime
// fallback: if the shell isn't already in the DOM (e.g. opening a raw HTML
// file), render it from the same string templates. Either way, mountShell()
// always wires up runtime behavior (mobile drawer, theme toggle).
import { renderShell } from './shell-render.js';
import { openPanel, openMenu } from './menus.js';
import { showToast } from './toast.js';
import { showModal } from './modal.js';
function injectShellIfMissing() {
const body = document.body;
if (body.querySelector('.sidebar')) {return;}
const activeKey = body.dataset.page || '';
const breadcrumb = body.dataset.breadcrumb
? body.dataset.breadcrumb.split('>').map((s) => s.trim()).filter(Boolean)
: ['Home'];
const { sidebar, topbar, footer } = renderShell({ activeKey, breadcrumb });
const tpl = document.createElement('template');
tpl.innerHTML = sidebar.trim();
body.insertBefore(tpl.content.firstElementChild, body.firstChild);
const mainEl = body.querySelector('main.main');
tpl.innerHTML = topbar.trim();
if (mainEl) {
body.insertBefore(tpl.content.firstElementChild, mainEl);
tpl.innerHTML = footer.trim();
mainEl.appendChild(tpl.content.firstElementChild);
}
}
// Sidebar submenus — accordion behavior + sessionStorage memory.
//
// On page load, the group containing the active page auto-opens (server-rendered
// markup). User can manually expand/collapse any group; opening one closes all
// others. The chosen state persists across navigation via sessionStorage so
// the sidebar doesn't snap back to "auto-open" when the user moves to a child
// page that's not in their preferred group.
const SUBMENU_STATE_KEY = 'gentelella:nav-open';
function getStoredOpenIndex() {
try {
const raw = sessionStorage.getItem(SUBMENU_STATE_KEY);
if (raw === null) {return null;}
const n = parseInt(raw, 10);
return Number.isNaN(n) ? null : n;
} catch (_e) { return null; }
}
function setStoredOpenIndex(idx) {
try {
if (idx === null) {sessionStorage.removeItem(SUBMENU_STATE_KEY);}
else {sessionStorage.setItem(SUBMENU_STATE_KEY, String(idx));}
} catch (_e) { /* private mode */ }
}
function bindNavSubmenus() {
const trees = [...document.querySelectorAll('.sidebar .nav-tree')];
if (!trees.length) {return;}
const closeAll = (except) => {
trees.forEach((t) => {
if (t === except) {return;}
t.classList.remove('open');
const btn = t.querySelector('.nav-toggle');
if (btn) {btn.setAttribute('aria-expanded', 'false');}
});
};
// Restore the user's last manually-toggled group, if any. Otherwise the
// server-rendered .open (auto-applied to the active page's group) wins.
const stored = getStoredOpenIndex();
if (stored !== null && trees[stored]) {
closeAll(trees[stored]);
trees[stored].classList.add('open');
const btn = trees[stored].querySelector('.nav-toggle');
if (btn) {btn.setAttribute('aria-expanded', 'true');}
}
trees.forEach((tree, i) => {
const btn = tree.querySelector('.nav-toggle');
if (!btn) {return;}
btn.addEventListener('click', (e) => {
e.preventDefault();
const willOpen = !tree.classList.contains('open');
closeAll(willOpen ? tree : null);
tree.classList.toggle('open', willOpen);
btn.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
setStoredOpenIndex(willOpen ? i : null);
});
});
}
// Sidebar toggle — desktop collapses to a 64px rail; mobile opens a drawer.
// Same button, viewport-aware behavior. Rail state persists in localStorage.
const RAIL_KEY = 'gentelella:sidebar-rail';
function isDesktop() { return window.matchMedia('(min-width: 769px)').matches; }
function applyRailLabels() {
// Sets data-rail-label on every nav-link so the CSS tooltip has text to show.
document.querySelectorAll('.sidebar .nav-link').forEach((link) => {
const text = link.querySelector('.nav-text')?.textContent.trim();
if (text) {link.setAttribute('data-rail-label', text);}
});
}
function bindRailFlyouts() {
// In rail mode, clicking a parent (.nav-toggle) opens its children as a
// flyout menu instead of expanding inline. Click again to dismiss.
// Captures clicks before bindNavSubmenus' handler so the inline-expand
// behavior never fires when we're collapsed.
document.querySelectorAll('.sidebar .nav-toggle').forEach((btn) => {
btn.addEventListener('click', (e) => {
if (!document.body.classList.contains('sidebar-rail')) {return;}
if (!isDesktop()) {return;}
e.preventDefault();
e.stopPropagation();
const tree = btn.closest('.nav-tree');
if (!tree) {return;}
const items = [...tree.querySelectorAll('.nav-sublink')].map((a) => ({
label: a.textContent.trim(),
action: () => { window.location.href = a.getAttribute('href'); }
}));
openMenu(btn, items);
}, true); // capture phase — runs before bindNavSubmenus
});
}
function bindSidebarToggle() {
const sidebar = document.querySelector('.sidebar');
const toggle = document.querySelector('.sidebar-toggle');
if (!sidebar || !toggle) {return;}
let backdrop = document.querySelector('.sidebar-backdrop');
if (!backdrop) {
backdrop = document.createElement('div');
backdrop.className = 'sidebar-backdrop';
backdrop.hidden = true;
document.body.appendChild(backdrop);
}
// ── Mobile drawer ──
const drawerClose = () => {
sidebar.classList.remove('open');
backdrop.hidden = true;
toggle.setAttribute('aria-expanded', 'false');
document.body.classList.remove('sidebar-open');
};
const drawerOpen = () => {
sidebar.classList.add('open');
backdrop.hidden = false;
toggle.setAttribute('aria-expanded', 'true');
document.body.classList.add('sidebar-open');
};
// ── Desktop rail ──
const setRail = (on) => {
document.body.classList.toggle('sidebar-rail', on);
toggle.setAttribute('aria-pressed', on ? 'true' : 'false');
toggle.setAttribute('aria-label', on ? 'Expand sidebar' : 'Collapse sidebar');
try { localStorage.setItem(RAIL_KEY, on ? '1' : '0'); } catch (_e) { /* ignore */ }
if (on) {applyRailLabels();}
};
// Restore stored rail preference (desktop only). Mobile ignores it so the
// drawer/sidebar isn't shown rail-style on small screens.
let stored = '0';
try { stored = localStorage.getItem(RAIL_KEY) || '0'; } catch (_e) { /* ignore */ }
if (stored === '1' && isDesktop()) {setRail(true);}
toggle.addEventListener('click', () => {
if (isDesktop()) {
setRail(!document.body.classList.contains('sidebar-rail'));
} else {
sidebar.classList.contains('open') ? drawerClose() : drawerOpen();
}
});
backdrop.addEventListener('click', drawerClose);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && sidebar.classList.contains('open')) {drawerClose();}
});
// Viewport changes: desktop ↔ mobile. Reset state coherently.
const mq = window.matchMedia('(min-width: 769px)');
mq.addEventListener('change', (e) => {
if (e.matches) {
// Now desktop — close any drawer, restore rail state.
drawerClose();
let v = '0';
try { v = localStorage.getItem(RAIL_KEY) || '0'; } catch (_err) { /* ignore */ }
setRail(v === '1');
} else {
// Now mobile — drop rail mode (drawer takes over).
document.body.classList.remove('sidebar-rail');
}
});
bindRailFlyouts();
}
function bindThemeToggle() {
const btn = document.querySelector('.theme-toggle');
if (!btn) {return;}
const apply = (theme) => {
document.documentElement.setAttribute('data-theme', theme);
btn.setAttribute('aria-pressed', theme === 'dark' ? 'true' : 'false');
};
// Sync aria-pressed with the theme set by the pre-paint script.
const current = document.documentElement.getAttribute('data-theme') || 'light';
btn.setAttribute('aria-pressed', current === 'dark' ? 'true' : 'false');
btn.addEventListener('click', () => {
const next = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
try { localStorage.setItem('theme', next); } catch (_e) { /* private mode */ }
apply(next);
});
// Follow OS theme changes when the user hasn't explicitly chosen.
const mq = window.matchMedia('(prefers-color-scheme: dark)');
mq.addEventListener('change', (e) => {
let stored;
try { stored = localStorage.getItem('theme'); } catch (_e) { /* ignore */ }
if (stored) {return;}
apply(e.matches ? 'dark' : 'light');
});
}
// ────────────────────────
// TOPBAR DROPDOWNS
// ────────────────────────
const NOTIFICATIONS = [
{ kind: 'info', from: 'Stripe', text: 'Payment of $499.00 received', time: '2m', unread: true },
{ kind: 'task', from: 'GitHub', text: 'PR #248 ready for review', time: '14m', unread: true },
{ kind: 'alert', from: 'Linear', text: 'GEN-128 marked as urgent', time: '1h', unread: true },
{ kind: 'info', from: 'Vercel', text: 'Deployment succeeded in 28s', time: '3h', unread: false },
{ kind: 'info', from: 'Notion', text: 'You were mentioned in Q2 OKRs', time: 'Yesterday', unread: false }
];
const MESSAGES = [
{ from: 'Sarah K.', text: 'Can you take a look at the design?', initials: 'SK', color: 'var(--primary)', time: '4m', unread: true },
{ from: 'Michael R.', text: 'Lunch tomorrow at noon?', initials: 'MR', color: 'var(--blue)', time: '32m', unread: true },
{ from: 'Emily W.', text: 'Sprint retro notes posted', initials: 'EW', color: 'var(--purple)', time: '2h', unread: false },
{ from: 'Diego R.', text: 'Customer feedback summary ready', initials: 'DR', color: 'var(--yellow)', time: 'Mon', unread: false }
];
function openShortcutsModal() {
const row = (k, label) => `<div style="display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid var(--border-color-light);font-size:13px"><span style="color:var(--text)">${label}</span><span>${k.split('+').map((key) => `<kbd style="font-family:var(--font);font-size:11px;background:var(--bg-surface-secondary);border:1px solid var(--border-color);border-radius:3px;padding:2px 6px;margin-left:3px">${key}</kbd>`).join('')}</span></div>`;
showModal({
title: 'Keyboard shortcuts',
size: 'md',
body: `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0 24px">
<div>
<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-muted);margin:4px 0 6px">Global</div>
${row('⌘+K', 'Open command palette')}
${row('⌘+/', 'This help')}
${row('Esc', 'Close modal / palette')}
<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-muted);margin:14px 0 6px">Navigation</div>
${row('G then D', 'Go to dashboard')}
${row('G then I', 'Go to inbox')}
${row('G then K', 'Go to kanban')}
</div>
<div>
<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-muted);margin:4px 0 6px">Inbox</div>
${row('J', 'Next message')}
${row('K', 'Previous message')}
${row('R', 'Reply')}
${row('S', 'Star message')}
${row('#', 'Move to trash')}
${row('C', 'Compose new')}
<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-muted);margin:14px 0 6px">Editor</div>
${row('⌘+B', 'Bold')}
${row('⌘+I', 'Italic')}
${row('⌘+K', 'Insert link')}
</div>
</div>
`,
actions: [{ label: 'Close', variant: 'primary' }]
});
}
function openSignOutModal() {
showModal({
title: 'Sign out?',
size: 'sm',
body: '<p style="font-size:13px;color:var(--text-secondary);line-height:1.6;margin:0">You\'ll need to sign back in to access your dashboard. Any unsaved changes will be lost.</p>',
actions: [
{ label: 'Cancel', variant: 'ghost' },
{
label: 'Sign out',
variant: 'primary',
action: () => {
showToast('Signed out', { variant: 'success' });
setTimeout(() => { window.location.href = 'login.html'; }, 600);
}
}
]
});
}
const USER_MENU = [
{ label: 'Profile', action: () => { window.location.href = 'profile.html'; } },
{ label: 'Account settings', action: () => { window.location.href = 'settings.html'; } },
{ label: 'Theme generator', action: () => { window.location.href = 'theme.html'; } },
{ label: 'Keyboard shortcuts', action: openShortcutsModal },
'-',
{ label: 'Help & support', action: () => { window.location.href = 'faq.html'; } },
{ label: 'Lock screen', action: () => { window.location.href = 'lock_screen.html'; } },
{ label: 'Sign out', action: openSignOutModal }
];
function buildNotificationsPanel() {
const unreadCount = NOTIFICATIONS.filter((n) => n.unread).length;
const wrap = document.createElement('div');
wrap.className = 'panel-content';
wrap.innerHTML = `
<div class="panel-header">
<span class="panel-title">Notifications</span>
${unreadCount ? `<span class="panel-badge">${unreadCount} new</span>` : ''}
<button type="button" class="panel-action" data-action="mark-all">Mark all read</button>
</div>
<div class="panel-list">
${NOTIFICATIONS.map((n, i) => `
<button type="button" class="panel-row${n.unread ? ' unread' : ''}" data-i="${i}">
<span class="panel-icon panel-icon-${n.kind}" aria-hidden="true">
${n.kind === 'alert' ? '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 1l7 13H1L8 1z"/><path d="M8 6v4"/><circle cx="8" cy="12" r="0.5"/></svg>'
: n.kind === 'task' ? '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 8l3 3 7-7"/></svg>'
: '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6"/><path d="M8 5v3M8 11h.01"/></svg>'}
</span>
<span class="panel-body">
<span class="panel-from">${n.from}</span>
<span class="panel-text">${n.text}</span>
</span>
<span class="panel-time">${n.time}</span>
</button>
`).join('')}
</div>
<div class="panel-footer">
<a href="notifications.html" class="panel-link">View all notifications</a>
</div>
`;
return wrap;
}
function buildMessagesPanel() {
const unreadCount = MESSAGES.filter((m) => m.unread).length;
const wrap = document.createElement('div');
wrap.className = 'panel-content';
wrap.innerHTML = `
<div class="panel-header">
<span class="panel-title">Messages</span>
${unreadCount ? `<span class="panel-badge">${unreadCount} new</span>` : ''}
<a href="inbox.html" class="panel-action">Open inbox</a>
</div>
<div class="panel-list">
${MESSAGES.map((m, i) => `
<button type="button" class="panel-row${m.unread ? ' unread' : ''}" data-i="${i}">
<span class="panel-avatar" style="background:${m.color}">${m.initials}</span>
<span class="panel-body">
<span class="panel-from">${m.from}</span>
<span class="panel-text">${m.text}</span>
</span>
<span class="panel-time">${m.time}</span>
</button>
`).join('')}
</div>
<div class="panel-footer">
<a href="inbox.html" class="panel-link">View all messages</a>
</div>
`;
return wrap;
}
function openNotificationDetail(n) {
showModal({
title: n.from,
size: 'sm',
body: `
<div style="display:flex;gap:12px;align-items:flex-start;margin-bottom:14px">
<div style="width:36px;height:36px;border-radius:8px;background:var(--${n.kind === 'alert' ? 'red' : n.kind === 'task' ? 'green' : 'blue'}-lt);color:var(--${n.kind === 'alert' ? 'red' : n.kind === 'task' ? 'green' : 'blue'});display:flex;align-items:center;justify-content:center;flex-shrink:0">
${n.kind === 'alert' ? '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 1l7 13H1L8 1z"/><path d="M8 6v4"/></svg>'
: n.kind === 'task' ? '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 8l3 3 7-7"/></svg>'
: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6"/><path d="M8 5v3M8 11h.01"/></svg>'}
</div>
<div style="flex:1;min-width:0">
<div style="font-size:13.5px;color:var(--text);line-height:1.5;margin-bottom:6px">${n.text}</div>
<div style="font-size:11.5px;color:var(--text-muted)">${n.time}</div>
</div>
</div>
`,
actions: [
{ label: 'Dismiss', variant: 'ghost' },
{ label: 'View all', variant: 'outline', action: () => { window.location.href = 'notifications.html'; } }
]
});
}
function openMessageDetail(m) {
showModal({
title: m.from,
size: 'md',
body: `
<div style="display:flex;gap:12px;align-items:center;margin-bottom:14px;padding-bottom:12px;border-bottom:1px solid var(--border-color-light)">
<div style="width:38px;height:38px;border-radius:50%;background:${m.color};color:white;display:flex;align-items:center;justify-content:center;font-weight:600;font-size:13px">${m.initials}</div>
<div style="flex:1">
<div style="font-size:13.5px;font-weight:600;color:var(--text)">${m.from}</div>
<div style="font-size:11.5px;color:var(--text-muted)">${m.time}</div>
</div>
</div>
<div style="font-size:13.5px;color:var(--text);line-height:1.6;margin-bottom:16px">${m.text}</div>
<textarea class="form-control" rows="3" placeholder="Type a reply…" style="margin-bottom:0"></textarea>
`,
actions: [
{ label: 'Cancel', variant: 'ghost' },
{ label: 'Open in inbox', variant: 'outline', action: () => { window.location.href = 'inbox.html'; } },
{ label: 'Send reply', variant: 'primary', action: () => showToast('Reply sent', { variant: 'success' }) }
]
});
}
function bindTopbarPanels() {
const bell = document.querySelector('.tb-notifications');
if (bell) {
bell.addEventListener('click', (e) => {
e.preventDefault(); e.stopPropagation();
const panel = buildNotificationsPanel();
panel.addEventListener('click', (ev) => {
const markAll = ev.target.closest('[data-action="mark-all"]');
if (markAll) {
ev.stopPropagation();
NOTIFICATIONS.forEach((n) => { n.unread = false; });
panel.querySelectorAll('.panel-row.unread').forEach((r) => r.classList.remove('unread'));
panel.querySelector('.panel-badge')?.remove();
bell.querySelector('.dot')?.style.setProperty('display', 'none');
showToast('All notifications marked read', { variant: 'success' });
return;
}
const row = ev.target.closest('.panel-row');
if (row) {
ev.stopPropagation();
const i = parseInt(row.dataset.i, 10);
NOTIFICATIONS[i].unread = false;
row.classList.remove('unread');
// Close the panel before opening the modal so they don't fight.
row.closest('.menu-popover')?.remove();
openNotificationDetail(NOTIFICATIONS[i]);
}
});
openPanel(bell, panel, { className: 'panel-notifications', width: 360 });
});
}
const msg = document.querySelector('.tb-messages');
if (msg) {
msg.addEventListener('click', (e) => {
e.preventDefault(); e.stopPropagation();
const panel = buildMessagesPanel();
panel.addEventListener('click', (ev) => {
const row = ev.target.closest('.panel-row');
if (row) {
ev.stopPropagation();
const i = parseInt(row.dataset.i, 10);
MESSAGES[i].unread = false;
row.classList.remove('unread');
row.closest('.menu-popover')?.remove();
openMessageDetail(MESSAGES[i]);
}
});
openPanel(msg, panel, { className: 'panel-messages', width: 360 });
});
}
const avatar = document.querySelector('.tb-avatar');
if (avatar) {
avatar.addEventListener('click', (e) => {
e.preventDefault(); e.stopPropagation();
openMenu(avatar, USER_MENU);
});
}
const sidebarMore = document.querySelector('.sidebar-user .more-btn');
if (sidebarMore) {
sidebarMore.addEventListener('click', (e) => {
e.preventDefault(); e.stopPropagation();
openMenu(sidebarMore, USER_MENU);
});
}
}
/**
* Mount the admin shell (sidebar + topbar + footer + interactivity).
*
* Reads three `<body>` data attributes:
* - `data-shell="admin"` — opt-in. No-op if absent.
* - `data-page="key"` — matches a {@link import('./shell-render.js').NAV} item to highlight.
* - `data-breadcrumb="A > B > C"` — `>`-separated; last segment is current.
*
* Idempotent: if the build-time Vite plugin already injected the shell HTML
* (the common case), this only wires up runtime behavior — mobile drawer,
* theme toggle, notifications/messages/avatar dropdowns.
*/
export function mountShell() {
const body = document.body;
if (body.dataset.shell !== 'admin') {return;}
injectShellIfMissing();
bindNavSubmenus();
bindSidebarToggle();
bindThemeToggle();
bindTopbarPanels();
}