1292 lines
45 KiB
TypeScript
1292 lines
45 KiB
TypeScript
import { readFile, writeFile, unlink } from 'fs/promises';
|
|
import { resolve as pathResolve } from 'path';
|
|
import { existsSync } from 'fs';
|
|
import { env } from '../../config/env';
|
|
import { logger } from '../../utils/logger';
|
|
import { headerConfigSchema } from './header-builder.schemas';
|
|
import type { HeaderConfig, HeaderNavItem } from './header-builder.schemas';
|
|
|
|
const OVERRIDES_DIR = pathResolve(env.MKDOCS_DOCS_PATH, 'overrides');
|
|
const CONFIG_PATH = pathResolve(OVERRIDES_DIR, 'header-config.json');
|
|
const MAIN_HTML_PATH = pathResolve(OVERRIDES_DIR, 'main.html');
|
|
|
|
/** Default built-in navigation items (pre-populated when no config exists) */
|
|
const DEFAULT_ITEMS: HeaderNavItem[] = [
|
|
{ id: 'campaigns', label: 'Campaigns', path: '/campaigns', icon: 'campaign', enabled: true, order: 0, type: 'builtin' },
|
|
{ id: 'map', label: 'Map', path: '/map', icon: 'map', enabled: true, order: 1, type: 'builtin' },
|
|
{ id: 'shifts', label: 'Volunteer', path: '/shifts', icon: 'groups', enabled: true, order: 2, type: 'builtin' },
|
|
{ id: 'gallery', label: 'Gallery', path: '/gallery', icon: 'play_circle', enabled: false, order: 3, type: 'builtin' },
|
|
{ id: 'responses', label: 'Responses', path: '/responses', icon: 'forum', enabled: false, order: 4, type: 'builtin' },
|
|
{ id: 'donate', label: 'Donate', path: '/donate', icon: 'favorite', enabled: false, order: 5, type: 'builtin' },
|
|
{ id: 'login', label: 'Sign In', path: '/login', icon: 'login', enabled: true, order: 6, type: 'builtin' },
|
|
];
|
|
|
|
const DEFAULT_CONFIG: HeaderConfig = {
|
|
enabled: false,
|
|
items: DEFAULT_ITEMS,
|
|
style: {
|
|
backgroundColor: '#6f42c1',
|
|
textColor: '#ffffff',
|
|
hoverColor: 'rgba(255,255,255,0.15)',
|
|
height: '40px',
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Escape a string for safe embedding inside a Jinja2/HTML template.
|
|
* Prevents XSS if user-supplied labels or paths contain special chars.
|
|
*/
|
|
function escapeHtml(str: string): string {
|
|
return str
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
/** Map Ant Design icon IDs to Material Icons Outlined names for MkDocs */
|
|
const ANT_ICON_TO_MATERIAL: Record<string, string> = {
|
|
HomeOutlined: 'home',
|
|
SendOutlined: 'send',
|
|
EnvironmentOutlined: 'place',
|
|
ScheduleOutlined: 'schedule',
|
|
CalendarOutlined: 'event',
|
|
BarChartOutlined: 'bar_chart',
|
|
PlayCircleOutlined: 'play_circle',
|
|
HeartOutlined: 'favorite_border',
|
|
DollarOutlined: 'attach_money',
|
|
ShoppingOutlined: 'shopping_bag',
|
|
LinkOutlined: 'link',
|
|
GlobalOutlined: 'language',
|
|
BookOutlined: 'menu_book',
|
|
TagOutlined: 'sell',
|
|
VideoCameraOutlined: 'videocam',
|
|
FileTextOutlined: 'description',
|
|
TrophyOutlined: 'emoji_events',
|
|
FolderOutlined: 'folder',
|
|
AppstoreOutlined: 'apps',
|
|
WalletOutlined: 'account_balance_wallet',
|
|
};
|
|
|
|
/**
|
|
* Convert an Ant Design icon name to a Material Icons ligature name.
|
|
* Uses the explicit mapping first, then falls back to stripping the
|
|
* Outlined/Filled/TwoTone suffix and converting PascalCase to snake_case.
|
|
*/
|
|
function toMaterialIcon(antIcon: string): string {
|
|
if (ANT_ICON_TO_MATERIAL[antIcon]) return ANT_ICON_TO_MATERIAL[antIcon];
|
|
// Fallback: strip suffix, convert PascalCase → snake_case
|
|
const base = antIcon.replace(/(Outlined|Filled|TwoTone)$/, '');
|
|
return base.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase();
|
|
}
|
|
|
|
interface NavConfigItem {
|
|
id: string;
|
|
label: string;
|
|
path: string;
|
|
icon: string;
|
|
enabled: boolean;
|
|
order: number;
|
|
type: 'builtin' | 'custom' | 'group';
|
|
featureFlag?: string;
|
|
external?: boolean;
|
|
children?: NavConfigItem[];
|
|
}
|
|
|
|
/** Extended nav item with rendering hints (not persisted in schema) */
|
|
interface RenderNavItem extends HeaderNavItem {
|
|
/** When true, path is rendered as direct href (not rewritten via data-path JS) */
|
|
isAbsoluteHref?: boolean;
|
|
}
|
|
|
|
/** A group of nav items rendered as a dropdown on desktop / expandable section on mobile */
|
|
interface RenderNavGroup {
|
|
id: string;
|
|
label: string;
|
|
icon: string;
|
|
type: 'group';
|
|
children: RenderNavItem[];
|
|
}
|
|
|
|
/** A renderable item: either a single link or a group dropdown */
|
|
type RenderItem = RenderNavItem | RenderNavGroup;
|
|
|
|
class HeaderBuilderService {
|
|
/**
|
|
* Read the current header config from disk.
|
|
* Returns defaults if no config file exists.
|
|
*/
|
|
async readConfig(): Promise<HeaderConfig> {
|
|
try {
|
|
if (!existsSync(CONFIG_PATH)) {
|
|
return { ...DEFAULT_CONFIG };
|
|
}
|
|
const raw = await readFile(CONFIG_PATH, 'utf-8');
|
|
const parsed = JSON.parse(raw);
|
|
const validated = headerConfigSchema.parse(parsed);
|
|
return validated;
|
|
} catch (err) {
|
|
logger.warn('Failed to read header config, returning defaults', err);
|
|
return { ...DEFAULT_CONFIG };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate, save config, and regenerate main.html.
|
|
*/
|
|
async writeConfig(config: HeaderConfig): Promise<void> {
|
|
// Validate with Zod
|
|
const validated = headerConfigSchema.parse(config);
|
|
|
|
// Write config JSON
|
|
await writeFile(CONFIG_PATH, JSON.stringify(validated, null, 2), 'utf-8');
|
|
logger.info('Header config saved');
|
|
|
|
// Generate or remove main.html
|
|
if (validated.enabled) {
|
|
const html = this.generateMainHtml(validated);
|
|
await writeFile(MAIN_HTML_PATH, html, 'utf-8');
|
|
logger.info('Generated main.html with header nav bar');
|
|
} else {
|
|
// Write minimal passthrough so landing pages still extend main.html
|
|
const passthrough = '{# Auto-generated by Changemaker Lite Header Builder — header disabled #}\n{% extends "base.html" %}\n';
|
|
await writeFile(MAIN_HTML_PATH, passthrough, 'utf-8');
|
|
logger.info('Generated passthrough main.html (header disabled)');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reset to defaults: remove config file and main.html.
|
|
*/
|
|
async resetToDefaults(): Promise<void> {
|
|
try { await unlink(CONFIG_PATH); } catch { /* file may not exist */ }
|
|
try { await unlink(MAIN_HTML_PATH); } catch { /* file may not exist */ }
|
|
logger.info('Header config reset to defaults');
|
|
}
|
|
|
|
/**
|
|
* Generate the Jinja2 main.html template from config.
|
|
* Mirrors the PublicNavBar style: 56px gradient bar, left brand, right nav links.
|
|
*/
|
|
generateMainHtml(config: HeaderConfig | { enabled: boolean; items: RenderNavItem[]; style: HeaderConfig['style'] & { colorBgBase?: string; colorBgContainer?: string } }): string {
|
|
const enabledItems = config.items
|
|
.filter((item) => item.enabled)
|
|
.sort((a, b) => a.order - b.order);
|
|
|
|
const links = enabledItems.map((item) => this.renderNavLink(item)).join('\n ');
|
|
|
|
const { backgroundColor, textColor } = config.style;
|
|
const colorBgBase = ('colorBgBase' in config.style ? config.style.colorBgBase : undefined) || '#0d1b2a';
|
|
const colorBgContainer = ('colorBgContainer' in config.style ? config.style.colorBgContainer : undefined) || '#1b2838';
|
|
|
|
return `{# Auto-generated by Changemaker Lite Header Builder — do not edit manually #}
|
|
{% extends "base.html" %}
|
|
|
|
{% block announce %}
|
|
<link href="https://fonts.googleapis.com/icon?family=Material+Icons+Outlined" rel="stylesheet">
|
|
<nav class="cm-header-nav" role="navigation" aria-label="Application">
|
|
<div class="cm-header-nav__brand">
|
|
<a href="#" data-path="/home" class="cm-header-nav__brand-link">
|
|
<span class="cm-header-nav__brand-text">{{ config.site_name }}</span>
|
|
</a>
|
|
</div>
|
|
<div class="cm-header-nav__links">
|
|
<div class="cm-header-nav__links-inner">
|
|
${links}
|
|
<a href="#" data-path="/app" class="cm-header-nav__link">
|
|
<span class="material-icons-outlined">dashboard</span>
|
|
<span class="cm-header-nav__label">Admin</span>
|
|
</a>
|
|
</div>
|
|
<button class="cm-header-nav__hamburger" aria-label="Open navigation menu">
|
|
<span class="material-icons-outlined">menu</span>
|
|
</button>
|
|
</div>
|
|
</nav>
|
|
<div class="cm-header-nav__mobile-drawer" id="cm-mobile-drawer">
|
|
<div class="cm-header-nav__mobile-header">
|
|
<span class="cm-header-nav__brand-text">{{ config.site_name }}</span>
|
|
<button class="cm-header-nav__mobile-close" aria-label="Close navigation menu">
|
|
<span class="material-icons-outlined">close</span>
|
|
</button>
|
|
</div>
|
|
<div class="cm-header-nav__mobile-links">
|
|
${enabledItems.map((item) => this.renderMobileNavLink(item)).join('\n ')}
|
|
<a href="#" data-path="/app" class="cm-header-nav__mobile-link">
|
|
<span class="material-icons-outlined">dashboard</span>
|
|
<span>Admin</span>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
<div class="cm-header-nav__mobile-overlay" id="cm-mobile-overlay"></div>
|
|
<script>
|
|
(function() {
|
|
var h = location.hostname;
|
|
var base;
|
|
if (h === 'localhost' || h === '127.0.0.1') {
|
|
base = location.protocol + '//localhost:' + ({{ config.extra.admin_port | default(0) }} || 3000);
|
|
} else {
|
|
var parts = h.split('.');
|
|
if (parts.length >= 3) { parts[0] = 'app'; }
|
|
else { parts.unshift('app'); }
|
|
base = location.protocol + '//' + parts.join('.');
|
|
}
|
|
var links = document.querySelectorAll('[data-path]');
|
|
for (var i = 0; i < links.length; i++) {
|
|
links[i].setAttribute('href', base + links[i].getAttribute('data-path'));
|
|
}
|
|
// Highlight active nav link based on current path
|
|
var path = location.pathname;
|
|
var activeLink = null;
|
|
if (path.indexOf('/docs') === 0) activeLink = 'docs';
|
|
document.querySelectorAll('.cm-header-nav__link[data-nav-id], .cm-header-nav__mobile-link[data-nav-id]').forEach(function(el) {
|
|
if (el.getAttribute('data-nav-id') === activeLink) {
|
|
el.classList.add('cm-header-nav__link--active');
|
|
}
|
|
});
|
|
// Hamburger toggle
|
|
var hamburger = document.querySelector('.cm-header-nav__hamburger');
|
|
var drawer = document.getElementById('cm-mobile-drawer');
|
|
var overlay = document.getElementById('cm-mobile-overlay');
|
|
var closeBtn = document.querySelector('.cm-header-nav__mobile-close');
|
|
function openDrawer() { drawer.classList.add('open'); overlay.classList.add('open'); }
|
|
function closeDrawer() { drawer.classList.remove('open'); overlay.classList.remove('open'); }
|
|
if (hamburger) hamburger.addEventListener('click', openDrawer);
|
|
if (closeBtn) closeBtn.addEventListener('click', closeDrawer);
|
|
if (overlay) overlay.addEventListener('click', closeDrawer);
|
|
})();
|
|
</script>
|
|
<style>
|
|
.md-banner {
|
|
background: ${escapeHtml(backgroundColor)} !important;
|
|
color: ${escapeHtml(textColor)} !important;
|
|
padding: 0 !important;
|
|
}
|
|
.md-banner__button {
|
|
display: none !important;
|
|
}
|
|
.cm-header-nav {
|
|
background: ${escapeHtml(backgroundColor)};
|
|
height: 56px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0 24px;
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
z-index: 10;
|
|
box-sizing: border-box;
|
|
}
|
|
.cm-header-nav a {
|
|
color: rgba(255, 255, 255, 0.85) !important;
|
|
}
|
|
.cm-header-nav__brand-link {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
text-decoration: none !important;
|
|
color: #fff !important;
|
|
}
|
|
.cm-header-nav__brand-text {
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
color: #fff !important;
|
|
}
|
|
.cm-header-nav__links {
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
.cm-header-nav__links-inner {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
}
|
|
.cm-header-nav__link {
|
|
color: rgba(255, 255, 255, 0.85) !important;
|
|
text-decoration: none !important;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
font-size: 14px;
|
|
transition: color 0.2s, border-color 0.2s;
|
|
white-space: nowrap;
|
|
padding-bottom: 2px;
|
|
border-bottom: 2px solid transparent;
|
|
}
|
|
.cm-header-nav__link:hover {
|
|
color: #fff !important;
|
|
text-decoration: none !important;
|
|
}
|
|
.cm-header-nav__link--active,
|
|
.cm-header-nav__link--active:hover {
|
|
color: #fff !important;
|
|
font-weight: 600;
|
|
border-bottom-color: #fff;
|
|
}
|
|
.cm-header-nav__link .material-icons-outlined {
|
|
font-size: 16px;
|
|
}
|
|
.cm-header-nav__hamburger {
|
|
display: none;
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
padding: 4px 8px;
|
|
color: #fff;
|
|
}
|
|
.cm-header-nav__hamburger .material-icons-outlined {
|
|
font-size: 24px;
|
|
}
|
|
/* Mobile drawer */
|
|
.cm-header-nav__mobile-drawer {
|
|
position: fixed;
|
|
top: 0;
|
|
right: -280px;
|
|
width: 280px;
|
|
height: 100vh;
|
|
background: ${escapeHtml(colorBgBase)};
|
|
z-index: 10001;
|
|
transition: right 0.3s ease;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
.cm-header-nav__mobile-drawer.open {
|
|
right: 0;
|
|
}
|
|
.cm-header-nav__mobile-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 16px 24px;
|
|
border-bottom: 1px solid rgba(255,255,255,0.1);
|
|
background: ${escapeHtml(colorBgContainer)};
|
|
}
|
|
.cm-header-nav__mobile-close {
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
color: rgba(255,255,255,0.85);
|
|
padding: 4px;
|
|
}
|
|
.cm-header-nav__mobile-links {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
padding: 16px 0;
|
|
}
|
|
.cm-header-nav__mobile-link {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 12px 24px;
|
|
color: rgba(255,255,255,0.85) !important;
|
|
text-decoration: none !important;
|
|
font-size: 15px;
|
|
border-radius: 4px;
|
|
}
|
|
.cm-header-nav__mobile-link:hover {
|
|
background: rgba(255,255,255,0.1);
|
|
color: #fff !important;
|
|
text-decoration: none !important;
|
|
}
|
|
.cm-header-nav__mobile-link--active {
|
|
color: #fff !important;
|
|
font-weight: 600;
|
|
background: rgba(255,255,255,0.1);
|
|
}
|
|
.cm-header-nav__mobile-link .material-icons-outlined {
|
|
font-size: 18px;
|
|
}
|
|
.cm-header-nav__mobile-overlay {
|
|
display: none;
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(0,0,0,0.5);
|
|
z-index: 10000;
|
|
}
|
|
.cm-header-nav__mobile-overlay.open {
|
|
display: block;
|
|
}
|
|
@media (max-width: 768px) {
|
|
.cm-header-nav { padding: 0 16px; }
|
|
.cm-header-nav__links-inner { display: none; }
|
|
.cm-header-nav__hamburger { display: block; }
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Regenerate main.html from the centralized navConfig stored in SiteSettings.
|
|
* Called when navConfig changes via the settings API.
|
|
*/
|
|
async regenerateFromNavConfig(
|
|
navConfigItems: NavConfigItem[],
|
|
settings?: {
|
|
publicHeaderGradient?: string;
|
|
publicColorBgBase?: string;
|
|
publicColorBgContainer?: string;
|
|
// Feature flags
|
|
enableInfluence?: boolean | null;
|
|
enableMap?: boolean | null;
|
|
enableMediaFeatures?: boolean | null;
|
|
enablePayments?: boolean | null;
|
|
enableEvents?: boolean | null;
|
|
enableMeetingPlanner?: boolean | null;
|
|
enableTicketedEvents?: boolean | null;
|
|
enableSocial?: boolean | null;
|
|
enableMeet?: boolean | null;
|
|
enableLandingPages?: boolean | null;
|
|
},
|
|
): Promise<void> {
|
|
try {
|
|
// Opt-out flags: visible by default, hidden only when explicitly false
|
|
const OPT_OUT_FLAGS = new Set(['enableInfluence', 'enableMap', 'enableMediaFeatures', 'enableEvents']);
|
|
|
|
const featureFlagMap: Record<string, boolean | null | undefined> = {
|
|
enableInfluence: settings?.enableInfluence,
|
|
enableMap: settings?.enableMap,
|
|
enableMediaFeatures: settings?.enableMediaFeatures,
|
|
enablePayments: settings?.enablePayments,
|
|
enableEvents: settings?.enableEvents,
|
|
enableMeetingPlanner: settings?.enableMeetingPlanner,
|
|
enableTicketedEvents: settings?.enableTicketedEvents,
|
|
enableSocial: settings?.enableSocial,
|
|
enableMeet: settings?.enableMeet,
|
|
enableLandingPages: settings?.enableLandingPages,
|
|
};
|
|
|
|
/** Check if an item passes its feature flag filter */
|
|
const passesFeatureFlag = (item: NavConfigItem): boolean => {
|
|
if (!item.featureFlag) return true;
|
|
if (OPT_OUT_FLAGS.has(item.featureFlag)) {
|
|
return featureFlagMap[item.featureFlag] !== false;
|
|
}
|
|
return featureFlagMap[item.featureFlag] === true;
|
|
};
|
|
|
|
/** Convert a NavConfigItem to a RenderNavItem */
|
|
const toRenderItem = (item: NavConfigItem): RenderNavItem => {
|
|
let resolvedPath = item.path;
|
|
if (item.path === '$landing') resolvedPath = '/';
|
|
else if (item.path === '$docs') resolvedPath = '/docs/';
|
|
|
|
return {
|
|
id: item.id,
|
|
label: item.label,
|
|
path: resolvedPath,
|
|
icon: toMaterialIcon(item.icon),
|
|
enabled: true,
|
|
order: item.order,
|
|
type: item.type as 'builtin' | 'custom',
|
|
openInNewTab: item.path.startsWith('$') ? false : item.external,
|
|
isAbsoluteHref: item.path.startsWith('$'),
|
|
};
|
|
};
|
|
|
|
// Build group-aware RenderItem[] preserving dropdown structure
|
|
const renderItems: RenderItem[] = [];
|
|
const sortedTopLevel = [...navConfigItems].sort((a, b) => a.order - b.order);
|
|
|
|
for (const item of sortedTopLevel) {
|
|
if (!item.enabled) continue;
|
|
|
|
if (item.type === 'group' && item.children) {
|
|
// Check group-level feature flag
|
|
if (!passesFeatureFlag(item)) continue;
|
|
|
|
// Filter children individually
|
|
const enabledChildren = item.children
|
|
.filter((child) => child.enabled && passesFeatureFlag(child))
|
|
.sort((a, b) => a.order - b.order)
|
|
.map(toRenderItem);
|
|
|
|
if (enabledChildren.length === 0) continue;
|
|
|
|
// If only one child survives, render it as a flat item instead of a dropdown
|
|
if (enabledChildren.length === 1) {
|
|
renderItems.push(enabledChildren[0]);
|
|
} else {
|
|
renderItems.push({
|
|
id: item.id,
|
|
label: item.label,
|
|
icon: toMaterialIcon(item.icon),
|
|
type: 'group',
|
|
children: enabledChildren,
|
|
});
|
|
}
|
|
} else {
|
|
if (!passesFeatureFlag(item)) continue;
|
|
renderItems.push(toRenderItem(item));
|
|
}
|
|
}
|
|
|
|
if (renderItems.length === 0) {
|
|
const passthrough = '{# Auto-generated by Changemaker Lite Header Builder — header disabled #}\n{% extends "base.html" %}\n';
|
|
await writeFile(MAIN_HTML_PATH, passthrough, 'utf-8');
|
|
logger.info('Generated passthrough main.html (no nav items enabled)');
|
|
return;
|
|
}
|
|
|
|
const backgroundColor = settings?.publicHeaderGradient || 'linear-gradient(135deg, #005a9c 0%, #007acc 100%)';
|
|
const colorBgBase = settings?.publicColorBgBase || '#0d1b2a';
|
|
const colorBgContainer = settings?.publicColorBgContainer || '#1b2838';
|
|
|
|
const html = this.generateMainHtmlV2(renderItems, {
|
|
backgroundColor,
|
|
textColor: '#ffffff',
|
|
colorBgBase,
|
|
colorBgContainer,
|
|
});
|
|
await writeFile(MAIN_HTML_PATH, html, 'utf-8');
|
|
logger.info('Regenerated main.html from navConfig');
|
|
} catch (err) {
|
|
logger.error('Failed to regenerate main.html from navConfig', err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate main.html from a mixed RenderItem[] (groups + flat items).
|
|
* Groups render as CSS hover dropdowns on desktop and expandable sections on mobile.
|
|
*/
|
|
private generateMainHtmlV2(
|
|
items: RenderItem[],
|
|
style: { backgroundColor: string; textColor: string; colorBgBase: string; colorBgContainer: string },
|
|
): string {
|
|
const { backgroundColor, textColor, colorBgBase, colorBgContainer } = style;
|
|
|
|
const desktopLinks = items
|
|
.map((item) =>
|
|
item.type === 'group'
|
|
? this.renderNavDropdown(item as RenderNavGroup)
|
|
: this.renderNavLink(item as RenderNavItem),
|
|
)
|
|
.join('\n ');
|
|
|
|
const mobileLinks = items
|
|
.map((item) =>
|
|
item.type === 'group'
|
|
? this.renderMobileGroup(item as RenderNavGroup)
|
|
: this.renderMobileNavLink(item as RenderNavItem),
|
|
)
|
|
.join('\n ');
|
|
|
|
return `{# Auto-generated by Changemaker Lite Header Builder — do not edit manually #}
|
|
{% extends "base.html" %}
|
|
|
|
{% block announce %}
|
|
<link href="https://fonts.googleapis.com/icon?family=Material+Icons+Outlined" rel="stylesheet">
|
|
<nav class="cm-header-nav" role="navigation" aria-label="Application">
|
|
<div class="cm-header-nav__brand">
|
|
<a href="#" data-path="/home" class="cm-header-nav__brand-link">
|
|
<span class="cm-header-nav__brand-text">{{ config.site_name }}</span>
|
|
</a>
|
|
</div>
|
|
<div class="cm-header-nav__links">
|
|
<div class="cm-header-nav__links-inner">
|
|
${desktopLinks}
|
|
<label for="__search" class="cm-header-nav__utility" title="Search">
|
|
<span class="material-icons-outlined">search</span>
|
|
</label>
|
|
<button class="cm-header-nav__utility" id="cm-palette-toggle" title="Toggle dark mode" type="button">
|
|
<span class="material-icons-outlined">dark_mode</span>
|
|
</button>
|
|
<a href="#" data-path="/login" class="cm-header-nav__link" id="cm-signin-link">
|
|
<span class="material-icons-outlined">login</span>
|
|
<span class="cm-header-nav__label">Sign In</span>
|
|
</a>
|
|
<div class="cm-header-nav__dropdown" id="cm-admin-dropdown" style="display:none">
|
|
<span class="cm-header-nav__link cm-header-nav__dropdown-trigger">
|
|
<span class="material-icons-outlined">person</span>
|
|
<span class="cm-header-nav__label">Admin</span>
|
|
<span class="material-icons-outlined cm-header-nav__chevron">expand_more</span>
|
|
</span>
|
|
<div class="cm-header-nav__dropdown-menu cm-header-nav__dropdown-menu--right">
|
|
<a href="#" data-path="/app" class="cm-header-nav__dropdown-item"><span class="material-icons-outlined">dashboard</span><span>Admin Panel</span></a>
|
|
<a href="#" data-path="/volunteer" class="cm-header-nav__dropdown-item"><span class="material-icons-outlined">volunteer_activism</span><span>Volunteer Portal</span></a>
|
|
<a href="#" data-path="/volunteer/profile" class="cm-header-nav__dropdown-item"><span class="material-icons-outlined">account_circle</span><span>My Profile</span></a>
|
|
<a href="#" data-path="/logout" class="cm-header-nav__dropdown-item"><span class="material-icons-outlined">logout</span><span>Logout</span></a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<button class="cm-header-nav__hamburger" aria-label="Open navigation menu">
|
|
<span class="material-icons-outlined">menu</span>
|
|
</button>
|
|
</div>
|
|
</nav>
|
|
<div class="cm-header-nav__mobile-drawer" id="cm-mobile-drawer">
|
|
<div class="cm-header-nav__mobile-header">
|
|
<span class="cm-header-nav__brand-text">{{ config.site_name }}</span>
|
|
<button class="cm-header-nav__mobile-close" aria-label="Close navigation menu">
|
|
<span class="material-icons-outlined">close</span>
|
|
</button>
|
|
</div>
|
|
<div class="cm-header-nav__mobile-links">
|
|
${mobileLinks}
|
|
<div class="cm-header-nav__mobile-divider"></div>
|
|
<label for="__search" class="cm-header-nav__mobile-link" style="cursor:pointer">
|
|
<span class="material-icons-outlined">search</span>
|
|
<span>Search</span>
|
|
</label>
|
|
<button class="cm-header-nav__mobile-link cm-header-nav__utility-btn" id="cm-mobile-palette-toggle" type="button">
|
|
<span class="material-icons-outlined">dark_mode</span>
|
|
<span>Dark Mode</span>
|
|
</button>
|
|
<button class="cm-header-nav__mobile-link cm-header-nav__utility-btn" id="cm-docs-sidebar-toggle" type="button">
|
|
<span class="material-icons-outlined">menu_book</span>
|
|
<span>Docs Navigation</span>
|
|
</button>
|
|
<div class="cm-header-nav__mobile-divider"></div>
|
|
<a href="#" data-path="/login" class="cm-header-nav__mobile-link" id="cm-mobile-signin-link">
|
|
<span class="material-icons-outlined">login</span>
|
|
<span>Sign In</span>
|
|
</a>
|
|
<div class="cm-header-nav__mobile-group" data-group-id="admin" id="cm-mobile-admin-group" style="display:none">
|
|
<span class="cm-header-nav__mobile-link cm-header-nav__mobile-group-trigger" role="button">
|
|
<span class="material-icons-outlined">person</span>
|
|
<span style="flex:1">Admin</span>
|
|
<span class="material-icons-outlined cm-header-nav__mobile-chevron">expand_more</span>
|
|
</span>
|
|
<div class="cm-header-nav__mobile-group-children">
|
|
<a href="#" data-path="/app" class="cm-header-nav__mobile-link" style="padding-left:48px"><span class="material-icons-outlined">dashboard</span><span>Admin Panel</span></a>
|
|
<a href="#" data-path="/volunteer" class="cm-header-nav__mobile-link" style="padding-left:48px"><span class="material-icons-outlined">volunteer_activism</span><span>Volunteer Portal</span></a>
|
|
<a href="#" data-path="/volunteer/profile" class="cm-header-nav__mobile-link" style="padding-left:48px"><span class="material-icons-outlined">account_circle</span><span>My Profile</span></a>
|
|
<a href="#" data-path="/logout" class="cm-header-nav__mobile-link" style="padding-left:48px"><span class="material-icons-outlined">logout</span><span>Logout</span></a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="cm-header-nav__mobile-overlay" id="cm-mobile-overlay"></div>
|
|
<script>
|
|
(function() {
|
|
var h = location.hostname;
|
|
var base;
|
|
if (h === 'localhost' || h === '127.0.0.1') {
|
|
base = location.protocol + '//localhost:' + ({{ config.extra.admin_port | default(0) }} || 3000);
|
|
} else {
|
|
var parts = h.split('.');
|
|
if (parts.length >= 3) { parts[0] = 'app'; }
|
|
else { parts.unshift('app'); }
|
|
base = location.protocol + '//' + parts.join('.');
|
|
}
|
|
var links = document.querySelectorAll('[data-path]');
|
|
for (var i = 0; i < links.length; i++) {
|
|
links[i].setAttribute('href', base + links[i].getAttribute('data-path'));
|
|
}
|
|
// Highlight active nav link based on current path
|
|
var path = location.pathname;
|
|
var activeLink = null;
|
|
if (path.indexOf('/docs') === 0) activeLink = 'docs';
|
|
document.querySelectorAll('.cm-header-nav__link[data-nav-id], .cm-header-nav__mobile-link[data-nav-id]').forEach(function(el) {
|
|
if (el.getAttribute('data-nav-id') === activeLink) {
|
|
el.classList.add('cm-header-nav__link--active');
|
|
}
|
|
});
|
|
// Hamburger toggle
|
|
var hamburger = document.querySelector('.cm-header-nav__hamburger');
|
|
var drawer = document.getElementById('cm-mobile-drawer');
|
|
var overlay = document.getElementById('cm-mobile-overlay');
|
|
var closeBtn = document.querySelector('.cm-header-nav__mobile-close');
|
|
function openDrawer() { drawer.classList.add('open'); overlay.classList.add('open'); }
|
|
function closeDrawer() { drawer.classList.remove('open'); overlay.classList.remove('open'); }
|
|
if (hamburger) hamburger.addEventListener('click', openDrawer);
|
|
if (closeBtn) closeBtn.addEventListener('click', closeDrawer);
|
|
if (overlay) overlay.addEventListener('click', closeDrawer);
|
|
// Mobile group expand/collapse toggles
|
|
document.querySelectorAll('.cm-header-nav__mobile-group-trigger').forEach(function(trigger) {
|
|
trigger.addEventListener('click', function() {
|
|
var group = this.closest('.cm-header-nav__mobile-group');
|
|
var children = group.querySelector('.cm-header-nav__mobile-group-children');
|
|
var isExpanded = group.classList.contains('expanded');
|
|
if (isExpanded) {
|
|
group.classList.remove('expanded');
|
|
children.style.display = 'none';
|
|
} else {
|
|
group.classList.add('expanded');
|
|
children.style.display = 'block';
|
|
}
|
|
});
|
|
});
|
|
// Auth-aware: show Admin dropdown for logged-in users, Sign In for guests.
|
|
// Uses hidden iframe + postMessage to read auth state from the app's origin.
|
|
function showAdminMenu() {
|
|
var s1 = document.getElementById('cm-signin-link');
|
|
var s2 = document.getElementById('cm-mobile-signin-link');
|
|
var a1 = document.getElementById('cm-admin-dropdown');
|
|
var a2 = document.getElementById('cm-mobile-admin-group');
|
|
if (s1) s1.style.display = 'none';
|
|
if (s2) s2.style.display = 'none';
|
|
if (a1) a1.style.display = '';
|
|
if (a2) a2.style.display = '';
|
|
}
|
|
// 1. Same-origin check (works when MkDocs served from same origin as app)
|
|
try {
|
|
var stored = localStorage.getItem('cml-auth');
|
|
if (stored) {
|
|
var parsed = JSON.parse(stored);
|
|
if (parsed && parsed.state && parsed.state.accessToken) {
|
|
showAdminMenu();
|
|
}
|
|
}
|
|
} catch(e) {}
|
|
// 2. Cross-origin check via hidden iframe + postMessage
|
|
var iframe = document.createElement('iframe');
|
|
iframe.style.display = 'none';
|
|
iframe.src = base + '/auth-check.html?origin=' + encodeURIComponent(location.origin);
|
|
window.addEventListener('message', function(event) {
|
|
if (event.origin !== base) return;
|
|
if (event.data && event.data.type === 'cml-auth-status' && event.data.authenticated) {
|
|
showAdminMenu();
|
|
}
|
|
});
|
|
document.body.appendChild(iframe);
|
|
// Palette toggle (dark/light mode)
|
|
function togglePalette() {
|
|
var inputs = document.querySelectorAll('.cm-palette-container input[name="__palette"]');
|
|
for (var i = 0; i < inputs.length; i++) {
|
|
if (!inputs[i].checked) { inputs[i].click(); break; }
|
|
}
|
|
setTimeout(updatePaletteIcon, 50);
|
|
}
|
|
function updatePaletteIcon() {
|
|
var scheme = document.body.getAttribute('data-md-color-scheme') || 'default';
|
|
var isDark = scheme === 'slate';
|
|
var icon = isDark ? 'light_mode' : 'dark_mode';
|
|
document.querySelectorAll('#cm-palette-toggle .material-icons-outlined, #cm-mobile-palette-toggle .material-icons-outlined').forEach(function(el) {
|
|
el.textContent = icon;
|
|
});
|
|
var ml = document.querySelector('#cm-mobile-palette-toggle span:not(.material-icons-outlined)');
|
|
if (ml) ml.textContent = isDark ? 'Light Mode' : 'Dark Mode';
|
|
}
|
|
var ptBtn = document.getElementById('cm-palette-toggle');
|
|
var ptBtnM = document.getElementById('cm-mobile-palette-toggle');
|
|
if (ptBtn) ptBtn.addEventListener('click', togglePalette);
|
|
if (ptBtnM) ptBtnM.addEventListener('click', function() { togglePalette(); closeDrawer(); });
|
|
// Docs sidebar toggle (opens Material's docs navigation drawer)
|
|
var docsSidebarBtn = document.getElementById('cm-docs-sidebar-toggle');
|
|
if (docsSidebarBtn) {
|
|
docsSidebarBtn.addEventListener('click', function() {
|
|
closeDrawer();
|
|
var dt = document.getElementById('__drawer');
|
|
if (dt) { dt.checked = !dt.checked; dt.dispatchEvent(new Event('change')); }
|
|
});
|
|
}
|
|
// Close custom drawer when search label is clicked on mobile + auto-focus input
|
|
document.querySelectorAll('label[for="__search"]').forEach(function(el) {
|
|
el.addEventListener('click', function() {
|
|
closeDrawer();
|
|
setTimeout(function() {
|
|
var input = document.querySelector('.md-search__input');
|
|
if (input) input.focus();
|
|
}, 150);
|
|
});
|
|
});
|
|
// Search activation: mirror checkbox state as a body class for CSS targeting.
|
|
// This avoids reliance on the ~ sibling combinator (fragile with template blocks).
|
|
var searchToggle = document.getElementById('__search');
|
|
if (searchToggle) {
|
|
function syncSearchClass() {
|
|
document.body.classList.toggle('cm-search-active', searchToggle.checked);
|
|
}
|
|
searchToggle.addEventListener('change', syncSearchClass);
|
|
syncSearchClass();
|
|
// Click-outside to dismiss search
|
|
document.addEventListener('click', function(e) {
|
|
if (!searchToggle.checked) return;
|
|
var panel = document.querySelector('.md-search__inner');
|
|
if (panel && panel.contains(e.target)) return;
|
|
if (e.target.closest && e.target.closest('label[for="__search"]')) return;
|
|
searchToggle.checked = false;
|
|
syncSearchClass();
|
|
});
|
|
// Also sync on Escape key (Material toggles checkbox via JS)
|
|
document.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Escape') setTimeout(syncSearchClass, 50);
|
|
});
|
|
}
|
|
// Init palette icon + observe changes
|
|
setTimeout(updatePaletteIcon, 100);
|
|
new MutationObserver(function() { updatePaletteIcon(); })
|
|
.observe(document.body, { attributes: true, attributeFilter: ['data-md-color-scheme'] });
|
|
})();
|
|
</script>
|
|
<style>
|
|
.md-banner {
|
|
background: ${escapeHtml(backgroundColor)} !important;
|
|
color: ${escapeHtml(textColor)} !important;
|
|
padding: 0 !important;
|
|
overflow: visible !important;
|
|
border: none !important;
|
|
box-shadow: none !important;
|
|
}
|
|
.md-banner__inner {
|
|
overflow: visible !important;
|
|
margin: 0 !important;
|
|
padding: 0 !important;
|
|
max-width: 100% !important;
|
|
}
|
|
.md-banner__button {
|
|
display: none !important;
|
|
}
|
|
.cm-header-nav {
|
|
background: ${escapeHtml(backgroundColor)};
|
|
height: 56px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0 24px;
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
position: relative;
|
|
z-index: 100;
|
|
box-sizing: border-box;
|
|
}
|
|
.cm-header-nav a {
|
|
color: rgba(255, 255, 255, 0.85) !important;
|
|
}
|
|
.cm-header-nav__brand-link {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
text-decoration: none !important;
|
|
color: #fff !important;
|
|
}
|
|
.cm-header-nav__brand-text {
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
color: #fff !important;
|
|
}
|
|
.cm-header-nav__links {
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
.cm-header-nav__links-inner {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
}
|
|
.cm-header-nav__link {
|
|
color: rgba(255, 255, 255, 0.85) !important;
|
|
text-decoration: none !important;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
font-size: 14px;
|
|
transition: color 0.2s, border-color 0.2s;
|
|
white-space: nowrap;
|
|
padding-bottom: 2px;
|
|
border-bottom: 2px solid transparent;
|
|
}
|
|
.cm-header-nav__link:hover {
|
|
color: #fff !important;
|
|
text-decoration: none !important;
|
|
}
|
|
.cm-header-nav__link--active,
|
|
.cm-header-nav__link--active:hover {
|
|
color: #fff !important;
|
|
font-weight: 600;
|
|
border-bottom-color: #fff;
|
|
}
|
|
.cm-header-nav__link .material-icons-outlined {
|
|
font-size: 16px;
|
|
}
|
|
.cm-header-nav__hamburger {
|
|
display: none;
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
padding: 4px 8px;
|
|
color: #fff;
|
|
}
|
|
.cm-header-nav__hamburger .material-icons-outlined {
|
|
font-size: 24px;
|
|
}
|
|
/* Desktop dropdown menus */
|
|
.cm-header-nav__dropdown {
|
|
position: relative;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
}
|
|
.cm-header-nav__dropdown-trigger {
|
|
cursor: pointer;
|
|
user-select: none;
|
|
}
|
|
.cm-header-nav__dropdown-trigger .cm-header-nav__chevron {
|
|
font-size: 14px;
|
|
transition: transform 0.2s;
|
|
}
|
|
.cm-header-nav__dropdown:hover .cm-header-nav__chevron {
|
|
transform: rotate(180deg);
|
|
}
|
|
.cm-header-nav__dropdown-menu {
|
|
display: none;
|
|
position: absolute;
|
|
top: 100%;
|
|
left: 0;
|
|
min-width: 180px;
|
|
background: ${escapeHtml(colorBgContainer)};
|
|
border-radius: 8px;
|
|
padding: 6px 0;
|
|
box-shadow: 0 6px 16px rgba(0,0,0,0.3);
|
|
z-index: 100;
|
|
margin-top: 4px;
|
|
}
|
|
.cm-header-nav__dropdown:hover .cm-header-nav__dropdown-menu {
|
|
display: block;
|
|
}
|
|
.cm-header-nav__dropdown-menu--right {
|
|
left: auto;
|
|
right: 0;
|
|
}
|
|
.cm-header-nav__dropdown-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 8px 16px;
|
|
color: rgba(255, 255, 255, 0.85) !important;
|
|
text-decoration: none !important;
|
|
font-size: 14px;
|
|
white-space: nowrap;
|
|
transition: background 0.15s;
|
|
}
|
|
.cm-header-nav__dropdown-item:hover {
|
|
background: rgba(255,255,255,0.1);
|
|
color: #fff !important;
|
|
text-decoration: none !important;
|
|
}
|
|
.cm-header-nav__dropdown-item .material-icons-outlined {
|
|
font-size: 16px;
|
|
}
|
|
/* Mobile drawer */
|
|
.cm-header-nav__mobile-drawer {
|
|
position: fixed;
|
|
top: 0;
|
|
right: -280px;
|
|
width: 280px;
|
|
height: 100vh;
|
|
background: ${escapeHtml(colorBgBase)};
|
|
z-index: 10001;
|
|
transition: right 0.3s ease;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
.cm-header-nav__mobile-drawer.open {
|
|
right: 0;
|
|
}
|
|
.cm-header-nav__mobile-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 16px 24px;
|
|
border-bottom: 1px solid rgba(255,255,255,0.1);
|
|
background: ${escapeHtml(colorBgContainer)};
|
|
}
|
|
.cm-header-nav__mobile-close {
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
color: rgba(255,255,255,0.85);
|
|
padding: 4px;
|
|
}
|
|
.cm-header-nav__mobile-links {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
padding: 16px 0;
|
|
}
|
|
.cm-header-nav__mobile-link {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 12px 24px;
|
|
color: rgba(255,255,255,0.85) !important;
|
|
text-decoration: none !important;
|
|
font-size: 15px;
|
|
border-radius: 4px;
|
|
}
|
|
.cm-header-nav__mobile-link:hover {
|
|
background: rgba(255,255,255,0.1);
|
|
color: #fff !important;
|
|
text-decoration: none !important;
|
|
}
|
|
.cm-header-nav__mobile-link--active {
|
|
color: #fff !important;
|
|
font-weight: 600;
|
|
background: rgba(255,255,255,0.1);
|
|
}
|
|
.cm-header-nav__mobile-link .material-icons-outlined {
|
|
font-size: 18px;
|
|
}
|
|
/* Mobile group expand/collapse */
|
|
.cm-header-nav__mobile-group-trigger {
|
|
cursor: pointer;
|
|
user-select: none;
|
|
}
|
|
.cm-header-nav__mobile-chevron {
|
|
font-size: 14px !important;
|
|
transition: transform 0.2s;
|
|
}
|
|
.cm-header-nav__mobile-group.expanded .cm-header-nav__mobile-chevron {
|
|
transform: rotate(180deg);
|
|
}
|
|
.cm-header-nav__mobile-group-children {
|
|
display: none;
|
|
}
|
|
.cm-header-nav__mobile-overlay {
|
|
display: none;
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(0,0,0,0.5);
|
|
z-index: 10000;
|
|
}
|
|
.cm-header-nav__mobile-overlay.open {
|
|
display: block;
|
|
}
|
|
@media (max-width: 768px) {
|
|
.cm-header-nav { padding: 0 16px; }
|
|
.cm-header-nav__links-inner { display: none; }
|
|
.cm-header-nav__hamburger { display: block; }
|
|
.cm-header-nav__dropdown-menu { display: none !important; }
|
|
}
|
|
/* Hidden Material header — stays at 0 height normally */
|
|
.md-header--cm-hidden {
|
|
height: 0 !important;
|
|
min-height: 0 !important;
|
|
padding: 0 !important;
|
|
margin: 0 !important;
|
|
border: 0 !important;
|
|
overflow: visible !important;
|
|
background: transparent !important;
|
|
box-shadow: none !important;
|
|
}
|
|
|
|
/* === DESKTOP SEARCH (>= 60em / 960px) === */
|
|
@media screen and (min-width: 60em) {
|
|
/* When search is active, make the search panel a fixed dropdown below custom header */
|
|
body.cm-search-active .md-header--cm-hidden .md-search__inner {
|
|
position: fixed !important;
|
|
top: 56px !important;
|
|
right: 16px !important;
|
|
left: auto !important;
|
|
width: min(34rem, calc(100vw - 32px)) !important;
|
|
background: var(--md-default-bg-color) !important;
|
|
border-radius: 0 0 8px 8px !important;
|
|
box-shadow: 0 4px 24px rgba(0,0,0,0.25) !important;
|
|
z-index: 300 !important;
|
|
}
|
|
|
|
/* Dark overlay behind search panel */
|
|
body.cm-search-active .md-header--cm-hidden .md-search__overlay {
|
|
position: fixed !important;
|
|
top: 0 !important;
|
|
left: 0 !important;
|
|
width: 100vw !important;
|
|
height: 100vh !important;
|
|
background: rgba(0,0,0,0.54) !important;
|
|
opacity: 1 !important;
|
|
z-index: 299 !important;
|
|
border-radius: 0 !important;
|
|
transform: none !important;
|
|
}
|
|
}
|
|
|
|
/* === MOBILE SEARCH (< 60em / 960px) === */
|
|
@media screen and (max-width: 59.984375em) {
|
|
/* Full-screen search takeover on mobile */
|
|
body.cm-search-active .md-header--cm-hidden .md-search__inner {
|
|
position: fixed !important;
|
|
top: 0 !important;
|
|
left: 0 !important;
|
|
right: 0 !important;
|
|
bottom: 0 !important;
|
|
width: 100% !important;
|
|
height: 100% !important;
|
|
opacity: 1 !important;
|
|
overflow: visible !important;
|
|
transform: none !important;
|
|
z-index: 300 !important;
|
|
background: var(--md-default-bg-color) !important;
|
|
}
|
|
}
|
|
|
|
/* Force search results to show when active (both breakpoints) */
|
|
body.cm-search-active .md-header--cm-hidden .md-search__output {
|
|
opacity: 1 !important;
|
|
}
|
|
body.cm-search-active .md-header--cm-hidden .md-search__scrollwrap {
|
|
max-height: 75vh !important;
|
|
}
|
|
.cm-palette-container {
|
|
height: 0 !important;
|
|
overflow: hidden !important;
|
|
}
|
|
/* Hide Material tabs — custom header covers navigation */
|
|
.md-tabs { display: none !important; }
|
|
/* Utility icon styling */
|
|
.cm-header-nav__utility {
|
|
background: none;
|
|
border: none;
|
|
color: rgba(255, 255, 255, 0.7);
|
|
cursor: pointer;
|
|
padding: 4px;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
transition: color 0.2s;
|
|
}
|
|
.cm-header-nav__utility:hover { color: #fff; }
|
|
.cm-header-nav__utility .material-icons-outlined { font-size: 20px; }
|
|
.cm-header-nav__utility-btn {
|
|
background: none;
|
|
border: none;
|
|
color: rgba(255,255,255,0.85);
|
|
cursor: pointer;
|
|
font-size: 15px;
|
|
font-family: inherit;
|
|
width: 100%;
|
|
text-align: left;
|
|
}
|
|
.cm-header-nav__mobile-divider {
|
|
height: 1px;
|
|
background: rgba(255,255,255,0.1);
|
|
margin: 8px 24px;
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block header %}
|
|
<header class="md-header md-header--cm-hidden" data-md-component="header">
|
|
<div class="cm-palette-container">
|
|
{% if config.theme.palette %}
|
|
{% if not config.theme.palette is mapping %}
|
|
{% include "partials/palette.html" %}
|
|
{% endif %}
|
|
{% endif %}
|
|
</div>
|
|
{% if "material/search" in config.plugins %}
|
|
{% include "partials/search.html" %}
|
|
{% endif %}
|
|
</header>
|
|
{% endblock %}
|
|
|
|
{% block tabs %}{% endblock %}
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Render a desktop dropdown menu for a nav group.
|
|
* Uses pure CSS :hover — no JavaScript needed for desktop.
|
|
*/
|
|
private renderNavDropdown(group: RenderNavGroup): string {
|
|
const iconHtml = group.icon
|
|
? `<span class="material-icons-outlined">${escapeHtml(group.icon)}</span>`
|
|
: '';
|
|
|
|
const childLinks = group.children
|
|
.map((child) => {
|
|
const isAbsolute = child.isAbsoluteHref || child.path.startsWith('http://') || child.path.startsWith('https://');
|
|
const target = child.openInNewTab ? ' target="_blank" rel="noopener noreferrer"' : '';
|
|
const navId = child.id ? ` data-nav-id="${escapeHtml(child.id)}"` : '';
|
|
const childIcon = child.icon
|
|
? `<span class="material-icons-outlined">${escapeHtml(child.icon)}</span>`
|
|
: '';
|
|
const href = isAbsolute
|
|
? `href="${escapeHtml(child.path)}"`
|
|
: `href="#" data-path="${escapeHtml(child.path)}"`;
|
|
return ` <a ${href} class="cm-header-nav__dropdown-item"${navId}${target}>${childIcon}<span>${escapeHtml(child.label)}</span></a>`;
|
|
})
|
|
.join('\n');
|
|
|
|
return `<div class="cm-header-nav__dropdown">
|
|
<span class="cm-header-nav__link cm-header-nav__dropdown-trigger">
|
|
${iconHtml}
|
|
<span class="cm-header-nav__label">${escapeHtml(group.label)}</span>
|
|
<span class="material-icons-outlined cm-header-nav__chevron">expand_more</span>
|
|
</span>
|
|
<div class="cm-header-nav__dropdown-menu">
|
|
${childLinks}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
/**
|
|
* Render a mobile expandable group section.
|
|
* Uses JS click handler to toggle visibility.
|
|
*/
|
|
private renderMobileGroup(group: RenderNavGroup): string {
|
|
const iconHtml = group.icon
|
|
? `<span class="material-icons-outlined">${escapeHtml(group.icon)}</span>`
|
|
: '';
|
|
|
|
const childLinks = group.children
|
|
.map((child) => {
|
|
const isAbsolute = child.isAbsoluteHref || child.path.startsWith('http://') || child.path.startsWith('https://');
|
|
const target = child.openInNewTab ? ' target="_blank" rel="noopener noreferrer"' : '';
|
|
const navId = child.id ? ` data-nav-id="${escapeHtml(child.id)}"` : '';
|
|
const childIcon = child.icon
|
|
? `<span class="material-icons-outlined">${escapeHtml(child.icon)}</span>`
|
|
: '';
|
|
const href = isAbsolute
|
|
? `href="${escapeHtml(child.path)}"`
|
|
: `href="#" data-path="${escapeHtml(child.path)}"`;
|
|
return ` <a ${href} class="cm-header-nav__mobile-link"${navId}${target} style="padding-left:48px">${childIcon}<span>${escapeHtml(child.label)}</span></a>`;
|
|
})
|
|
.join('\n');
|
|
|
|
return `<div class="cm-header-nav__mobile-group" data-group-id="${escapeHtml(group.id)}">
|
|
<span class="cm-header-nav__mobile-link cm-header-nav__mobile-group-trigger" role="button">
|
|
${iconHtml}
|
|
<span style="flex:1">${escapeHtml(group.label)}</span>
|
|
<span class="material-icons-outlined cm-header-nav__mobile-chevron">expand_more</span>
|
|
</span>
|
|
<div class="cm-header-nav__mobile-group-children">
|
|
${childLinks}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
/**
|
|
* Render a single nav link element.
|
|
* Items with isAbsoluteHref use direct href (e.g. $token-resolved paths like / or /docs/).
|
|
*/
|
|
private renderNavLink(item: RenderNavItem): string {
|
|
const isAbsolute = item.isAbsoluteHref || item.path.startsWith('http://') || item.path.startsWith('https://');
|
|
const target = item.openInNewTab ? ' target="_blank" rel="noopener noreferrer"' : '';
|
|
const navId = item.id ? ` data-nav-id="${escapeHtml(item.id)}"` : '';
|
|
const iconHtml = item.icon
|
|
? `<span class="material-icons-outlined">${escapeHtml(item.icon)}</span>`
|
|
: '';
|
|
|
|
if (isAbsolute) {
|
|
return `<a href="${escapeHtml(item.path)}" class="cm-header-nav__link"${navId}${target}>${iconHtml}<span class="cm-header-nav__label">${escapeHtml(item.label)}</span></a>`;
|
|
}
|
|
|
|
return `<a href="#" data-path="${escapeHtml(item.path)}" class="cm-header-nav__link"${navId}${target}>${iconHtml}<span class="cm-header-nav__label">${escapeHtml(item.label)}</span></a>`;
|
|
}
|
|
|
|
/**
|
|
* Render a single mobile drawer nav link.
|
|
*/
|
|
private renderMobileNavLink(item: RenderNavItem): string {
|
|
const isAbsolute = item.isAbsoluteHref || item.path.startsWith('http://') || item.path.startsWith('https://');
|
|
const target = item.openInNewTab ? ' target="_blank" rel="noopener noreferrer"' : '';
|
|
const navId = item.id ? ` data-nav-id="${escapeHtml(item.id)}"` : '';
|
|
const iconHtml = item.icon
|
|
? `<span class="material-icons-outlined">${escapeHtml(item.icon)}</span>`
|
|
: '';
|
|
|
|
if (isAbsolute) {
|
|
return `<a href="${escapeHtml(item.path)}" class="cm-header-nav__mobile-link"${navId}${target}>${iconHtml}<span>${escapeHtml(item.label)}</span></a>`;
|
|
}
|
|
|
|
return `<a href="#" data-path="${escapeHtml(item.path)}" class="cm-header-nav__mobile-link"${navId}${target}>${iconHtml}<span>${escapeHtml(item.label)}</span></a>`;
|
|
}
|
|
}
|
|
|
|
export const headerBuilderService = new HeaderBuilderService();
|