feat: implement dynamic sidebar with server-side menu translation
- Add server-side translation for menu labels using user's preferred_language - Replace hardcoded sidebar template with dynamic rendering from menu API - Remove hardcoded section/page mappings in favor of menu discovery - Fix locale file structure (move menu keys to top level to avoid double-nesting) - Sidebar now fully driven by /admin/menu-config/render/admin API endpoint Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,7 @@ from app.api.deps import (
|
||||
from app.modules.core.services.menu_service import MenuItemConfig, menu_service
|
||||
from app.modules.tenancy.services.platform_service import platform_service
|
||||
from app.modules.enums import FrontendType # noqa: API-007 - Enum for type safety
|
||||
from app.utils.i18n import translate, DEFAULT_LANGUAGE
|
||||
from models.schema.auth import UserContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -111,15 +112,39 @@ class MenuActionResponse(BaseModel):
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _build_menu_item_response(item: MenuItemConfig) -> MenuItemResponse:
|
||||
"""Convert MenuItemConfig to API response."""
|
||||
def _translate_label(label_key: str | None, language: str) -> str | None:
|
||||
"""
|
||||
Translate a label key to the user's language.
|
||||
|
||||
Falls back to a readable version of the key if translation not found.
|
||||
"""
|
||||
if not label_key:
|
||||
return None
|
||||
|
||||
translated = translate(label_key, language=language)
|
||||
|
||||
# If translation returned the key itself, it wasn't found
|
||||
# Create a readable fallback from the last part of the key
|
||||
if translated == label_key:
|
||||
parts = label_key.split(".")
|
||||
last_part = parts[-1]
|
||||
# Convert snake_case to Title Case
|
||||
return last_part.replace("_", " ").title()
|
||||
|
||||
return translated
|
||||
|
||||
|
||||
def _build_menu_item_response(
|
||||
item: MenuItemConfig, language: str = DEFAULT_LANGUAGE
|
||||
) -> MenuItemResponse:
|
||||
"""Convert MenuItemConfig to API response with translated labels."""
|
||||
return MenuItemResponse(
|
||||
id=item.id,
|
||||
label=item.label,
|
||||
label=_translate_label(item.label, language),
|
||||
icon=item.icon,
|
||||
url=item.url,
|
||||
section_id=item.section_id,
|
||||
section_label=item.section_label,
|
||||
section_label=_translate_label(item.section_label, language),
|
||||
is_visible=item.is_visible,
|
||||
is_mandatory=item.is_mandatory,
|
||||
is_super_admin_only=item.is_super_admin_only,
|
||||
@@ -129,11 +154,12 @@ def _build_menu_item_response(item: MenuItemConfig) -> MenuItemResponse:
|
||||
def _build_menu_config_response(
|
||||
items: list[MenuItemConfig],
|
||||
frontend_type: FrontendType,
|
||||
language: str = DEFAULT_LANGUAGE,
|
||||
platform_id: int | None = None,
|
||||
user_id: int | None = None,
|
||||
) -> MenuConfigResponse:
|
||||
"""Build menu configuration response."""
|
||||
item_responses = [_build_menu_item_response(item) for item in items]
|
||||
"""Build menu configuration response with translated labels."""
|
||||
item_responses = [_build_menu_item_response(item, language) for item in items]
|
||||
visible_count = sum(1 for item in items if item.is_visible)
|
||||
|
||||
return MenuConfigResponse(
|
||||
@@ -177,7 +203,12 @@ async def get_platform_menu_config(
|
||||
f"for platform {platform.code} ({frontend_type.value})"
|
||||
)
|
||||
|
||||
return _build_menu_config_response(items, frontend_type, platform_id=platform_id)
|
||||
# Use user's preferred language, falling back to default
|
||||
language = current_user.preferred_language or DEFAULT_LANGUAGE
|
||||
|
||||
return _build_menu_config_response(
|
||||
items, frontend_type, language=language, platform_id=platform_id
|
||||
)
|
||||
|
||||
|
||||
@router.put("/platforms/{platform_id}")
|
||||
@@ -300,8 +331,11 @@ async def get_user_menu_config(
|
||||
f"[MENU_CONFIG] Super admin {current_user.email} fetched their personal menu config"
|
||||
)
|
||||
|
||||
# Use user's preferred language, falling back to default
|
||||
language = current_user.preferred_language or DEFAULT_LANGUAGE
|
||||
|
||||
return _build_menu_config_response(
|
||||
items, FrontendType.ADMIN, user_id=current_user.id
|
||||
items, FrontendType.ADMIN, language=language, user_id=current_user.id
|
||||
)
|
||||
|
||||
|
||||
@@ -448,14 +482,26 @@ async def get_rendered_admin_menu(
|
||||
is_super_admin=False,
|
||||
)
|
||||
|
||||
sections = [
|
||||
MenuSectionResponse(
|
||||
id=section["id"],
|
||||
label=section.get("label"),
|
||||
items=section["items"],
|
||||
# Use user's preferred language, falling back to default
|
||||
language = current_user.preferred_language or DEFAULT_LANGUAGE
|
||||
|
||||
# Translate section and item labels
|
||||
sections = []
|
||||
for section in menu.get("sections", []):
|
||||
# Translate item labels
|
||||
translated_items = []
|
||||
for item in section.get("items", []):
|
||||
translated_item = item.copy()
|
||||
translated_item["label"] = _translate_label(item.get("label"), language)
|
||||
translated_items.append(translated_item)
|
||||
|
||||
sections.append(
|
||||
MenuSectionResponse(
|
||||
id=section["id"],
|
||||
label=_translate_label(section.get("label"), language),
|
||||
items=translated_items,
|
||||
)
|
||||
)
|
||||
for section in menu.get("sections", [])
|
||||
]
|
||||
|
||||
return RenderedMenuResponse(
|
||||
frontend_type=FrontendType.ADMIN.value,
|
||||
|
||||
@@ -22,33 +22,21 @@ function data() {
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Sidebar sections persistence
|
||||
// Dynamic - section IDs come from menu discovery API
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
const SIDEBAR_STORAGE_KEY = 'admin_sidebar_sections';
|
||||
|
||||
// Default state: Platform Administration open, others closed
|
||||
const defaultSections = {
|
||||
superAdmin: true, // Super admin section (only visible to super admins)
|
||||
platformAdmin: true,
|
||||
vendorOps: false,
|
||||
marketplace: false,
|
||||
billing: false,
|
||||
contentMgmt: false,
|
||||
devTools: false,
|
||||
platformHealth: false,
|
||||
monitoring: false,
|
||||
settingsSection: false
|
||||
};
|
||||
|
||||
function getSidebarSectionsFromStorage() {
|
||||
try {
|
||||
const stored = window.localStorage.getItem(SIDEBAR_STORAGE_KEY);
|
||||
if (stored) {
|
||||
return { ...defaultSections, ...JSON.parse(stored) };
|
||||
return JSON.parse(stored);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse sidebar sections from localStorage:', e);
|
||||
}
|
||||
return { ...defaultSections };
|
||||
// Default: first section open (will be set dynamically)
|
||||
return {};
|
||||
}
|
||||
|
||||
function saveSidebarSectionsToStorage(sections) {
|
||||
@@ -92,43 +80,18 @@ function data() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Map pages to their parent sections
|
||||
const pageSectionMap = {
|
||||
// Super Admin section
|
||||
'admin-users': 'superAdmin',
|
||||
// Platform Administration
|
||||
companies: 'platformAdmin',
|
||||
vendors: 'platformAdmin',
|
||||
messages: 'platformAdmin',
|
||||
// Vendor Operations (Products, Customers, Inventory, Orders, Shipping)
|
||||
'marketplace-products': 'vendorOps',
|
||||
'vendor-products': 'vendorOps',
|
||||
customers: 'vendorOps',
|
||||
inventory: 'vendorOps',
|
||||
orders: 'vendorOps',
|
||||
// Future: shipping will map to 'vendorOps'
|
||||
// Marketplace
|
||||
'marketplace-letzshop': 'marketplace',
|
||||
// Content Management
|
||||
'platform-homepage': 'contentMgmt',
|
||||
'content-pages': 'contentMgmt',
|
||||
'vendor-theme': 'contentMgmt',
|
||||
// Developer Tools
|
||||
components: 'devTools',
|
||||
icons: 'devTools',
|
||||
// Platform Health
|
||||
testing: 'platformHealth',
|
||||
'code-quality': 'platformHealth',
|
||||
// Platform Monitoring
|
||||
imports: 'monitoring',
|
||||
'background-tasks': 'monitoring',
|
||||
logs: 'monitoring',
|
||||
'notifications-settings': 'monitoring',
|
||||
// Platform Settings
|
||||
settings: 'settingsSection',
|
||||
profile: 'settingsSection',
|
||||
'api-keys': 'settingsSection'
|
||||
};
|
||||
// Helper to find section ID for a page from menu data
|
||||
function findSectionForPage(menuData, pageId) {
|
||||
if (!menuData?.sections) return null;
|
||||
for (const section of menuData.sections) {
|
||||
for (const item of section.items || []) {
|
||||
if (item.id === pageId) {
|
||||
return section.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
@@ -191,9 +154,10 @@ function data() {
|
||||
saveSidebarSectionsToStorage(this.openSections);
|
||||
},
|
||||
|
||||
// Auto-expand section containing current page
|
||||
// Auto-expand section containing current page (uses menu API data)
|
||||
expandSectionForCurrentPage() {
|
||||
const section = pageSectionMap[this.currentPage];
|
||||
if (!this.menuData) return;
|
||||
const section = findSectionForPage(this.menuData, this.currentPage);
|
||||
if (section && !this.openSections[section]) {
|
||||
this.openSections[section] = true;
|
||||
saveSidebarSectionsToStorage(this.openSections);
|
||||
@@ -242,12 +206,21 @@ function data() {
|
||||
this.menuData = await apiClient.get('/admin/menu-config/render/admin');
|
||||
// Build a set of visible menu item IDs for quick lookup
|
||||
this.visibleMenuItems = new Set();
|
||||
for (const section of (this.menuData?.sections || [])) {
|
||||
const sections = this.menuData?.sections || [];
|
||||
for (const section of sections) {
|
||||
for (const item of (section.items || [])) {
|
||||
this.visibleMenuItems.add(item.id);
|
||||
}
|
||||
// Initialize openSections for new sections (default: first section open)
|
||||
if (this.openSections[section.id] === undefined) {
|
||||
// Default: first section open, rest closed
|
||||
this.openSections[section.id] = (sections.indexOf(section) === 0);
|
||||
}
|
||||
}
|
||||
console.debug('Menu config loaded:', this.visibleMenuItems.size, 'items');
|
||||
|
||||
// Auto-expand section containing current page
|
||||
this.expandSectionForCurrentPage();
|
||||
} catch (e) {
|
||||
// Silently fail - menu will show all items as fallback
|
||||
console.debug('Menu config not loaded, using defaults:', e?.message || e);
|
||||
|
||||
@@ -57,9 +57,6 @@ function adminMyMenuConfig() {
|
||||
},
|
||||
|
||||
async init() {
|
||||
// Load i18n translations
|
||||
await I18n.loadModule('core');
|
||||
|
||||
// Guard against multiple initialization
|
||||
if (window._adminMyMenuConfigInitialized) {
|
||||
myMenuConfigLog.warn('Already initialized, skipping');
|
||||
@@ -70,6 +67,8 @@ function adminMyMenuConfig() {
|
||||
myMenuConfigLog.info('=== MY MENU CONFIG PAGE INITIALIZING ===');
|
||||
|
||||
try {
|
||||
// Load core translations for confirmations
|
||||
await I18n.loadModule('core');
|
||||
await this.loadMenuConfig();
|
||||
myMenuConfigLog.info('=== MY MENU CONFIG PAGE INITIALIZED ===');
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
{
|
||||
"menu": {
|
||||
"marketplace": "Marktplatz",
|
||||
"letzshop": "Letzshop",
|
||||
"products_inventory": "Produkte & Inventar",
|
||||
"marketplace_import": "Marktplatz Import",
|
||||
"sales_orders": "Verkäufe & Bestellungen",
|
||||
"letzshop_orders": "Letzshop Bestellungen"
|
||||
},
|
||||
"marketplace": {
|
||||
"title": "Marktplatz",
|
||||
"import": "Importieren",
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
{
|
||||
"menu": {
|
||||
"marketplace": "Marketplace",
|
||||
"letzshop": "Letzshop",
|
||||
"products_inventory": "Products & Inventory",
|
||||
"marketplace_import": "Marketplace Import",
|
||||
"sales_orders": "Sales & Orders",
|
||||
"letzshop_orders": "Letzshop Orders"
|
||||
},
|
||||
"marketplace": {
|
||||
"title": "Marketplace",
|
||||
"import": "Import",
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
{
|
||||
"menu": {
|
||||
"marketplace": "Marketplace",
|
||||
"letzshop": "Letzshop",
|
||||
"products_inventory": "Produits et Inventaire",
|
||||
"marketplace_import": "Import Marketplace",
|
||||
"sales_orders": "Ventes et Commandes",
|
||||
"letzshop_orders": "Commandes Letzshop"
|
||||
},
|
||||
"marketplace": {
|
||||
"title": "Marketplace",
|
||||
"import": "Importer",
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
{
|
||||
"menu": {
|
||||
"marketplace": "Marchéplaz",
|
||||
"letzshop": "Letzshop",
|
||||
"products_inventory": "Produkter & Inventar",
|
||||
"marketplace_import": "Marchéplaz Import",
|
||||
"sales_orders": "Verkaf & Bestellungen",
|
||||
"letzshop_orders": "Letzshop Bestellungen"
|
||||
},
|
||||
"marketplace": {
|
||||
"title": "Marchéplaz",
|
||||
"import": "Import",
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"menu": {
|
||||
"payments": "Zahlungen"
|
||||
},
|
||||
"payments": {
|
||||
"title": "Zahlungen",
|
||||
"menu": {
|
||||
"payments": "Zahlungen"
|
||||
},
|
||||
"messages": {
|
||||
"payment_successful": "Zahlung erfolgreich verarbeitet",
|
||||
"payment_failed": "Zahlungsverarbeitung fehlgeschlagen"
|
||||
}
|
||||
"title": "Zahlungen"
|
||||
},
|
||||
"messages": {
|
||||
"payment_successful": "Zahlung erfolgreich verarbeitet",
|
||||
"payment_failed": "Zahlungsverarbeitung fehlgeschlagen"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"menu": {
|
||||
"payments": "Payments"
|
||||
},
|
||||
"payments": {
|
||||
"title": "Payments",
|
||||
"menu": {
|
||||
"payments": "Payments"
|
||||
},
|
||||
"messages": {
|
||||
"payment_successful": "Payment processed successfully",
|
||||
"payment_failed": "Payment processing failed"
|
||||
}
|
||||
"title": "Payments"
|
||||
},
|
||||
"messages": {
|
||||
"payment_successful": "Payment processed successfully",
|
||||
"payment_failed": "Payment processing failed"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"menu": {
|
||||
"payments": "Paiements"
|
||||
},
|
||||
"payments": {
|
||||
"title": "Paiements",
|
||||
"menu": {
|
||||
"payments": "Paiements"
|
||||
},
|
||||
"messages": {
|
||||
"payment_successful": "Paiement traité avec succès",
|
||||
"payment_failed": "Échec du traitement du paiement"
|
||||
}
|
||||
"title": "Paiements"
|
||||
},
|
||||
"messages": {
|
||||
"payment_successful": "Paiement traité avec succès",
|
||||
"payment_failed": "Échec du traitement du paiement"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"menu": {
|
||||
"payments": "Bezuelungen"
|
||||
},
|
||||
"payments": {
|
||||
"title": "Bezuelungen",
|
||||
"menu": {
|
||||
"payments": "Bezuelungen"
|
||||
},
|
||||
"messages": {
|
||||
"payment_successful": "Bezuelung erfollegräich veraarbecht",
|
||||
"payment_failed": "Bezuelungsveraarbechtung ass feelgeschloen"
|
||||
}
|
||||
"title": "Bezuelungen"
|
||||
},
|
||||
"messages": {
|
||||
"payment_successful": "Bezuelung erfollegräich veraarbecht",
|
||||
"payment_failed": "Bezuelungsveraarbechtung ass feelgeschloen"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,9 +52,6 @@ function adminPlatformMenuConfig(platformCode) {
|
||||
},
|
||||
|
||||
async init() {
|
||||
// Load i18n translations
|
||||
await I18n.loadModule('tenancy');
|
||||
|
||||
// Guard against duplicate initialization
|
||||
if (window._platformMenuConfigInitialized) {
|
||||
menuConfigLog.warn('Already initialized, skipping');
|
||||
@@ -66,6 +63,8 @@ function adminPlatformMenuConfig(platformCode) {
|
||||
menuConfigLog.info('Platform code:', this.platformCode);
|
||||
|
||||
try {
|
||||
// Load tenancy translations for confirmations
|
||||
await I18n.loadModule('tenancy');
|
||||
await this.loadPlatform();
|
||||
await this.loadPlatformMenuConfig();
|
||||
menuConfigLog.info('=== PLATFORM MENU CONFIG PAGE INITIALIZED ===');
|
||||
|
||||
@@ -1,55 +1,5 @@
|
||||
{# app/templates/admin/partials/sidebar.html #}
|
||||
{# Collapsible sidebar sections with localStorage persistence #}
|
||||
|
||||
{# ============================================================================
|
||||
REUSABLE MACROS FOR SIDEBAR ITEMS
|
||||
============================================================================ #}
|
||||
|
||||
{# Macro for collapsible section header #}
|
||||
{% macro section_header(title, section_key) %}
|
||||
<div class="px-6 my-4">
|
||||
<hr class="border-gray-200 dark:border-gray-700" />
|
||||
</div>
|
||||
<button
|
||||
@click="toggleSection('{{ section_key }}')"
|
||||
class="flex items-center justify-between w-full px-6 py-2 text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
<span>{{ title }}</span>
|
||||
<span
|
||||
x-html="$icon('chevron-down', 'w-4 h-4 transition-transform duration-200')"
|
||||
:class="{ 'rotate-180': openSections.{{ section_key }} }"
|
||||
></span>
|
||||
</button>
|
||||
{% endmacro %}
|
||||
|
||||
{# Macro for collapsible section content wrapper #}
|
||||
{% macro section_content(section_key) %}
|
||||
<ul
|
||||
x-show="openSections.{{ section_key }}"
|
||||
x-transition:enter="transition-all duration-200 ease-out"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 translate-y-0"
|
||||
x-transition:leave="transition-all duration-150 ease-in"
|
||||
x-transition:leave-start="opacity-100 translate-y-0"
|
||||
x-transition:leave-end="opacity-0 -translate-y-2"
|
||||
class="mt-1 overflow-hidden"
|
||||
>
|
||||
{{ caller() }}
|
||||
</ul>
|
||||
{% endmacro %}
|
||||
|
||||
{# Macro for menu item with visibility check #}
|
||||
{% macro menu_item(page_id, url, icon, label) %}
|
||||
<li x-show="isMenuItemVisible('{{ page_id }}')" class="relative px-6 py-3">
|
||||
<span x-show="currentPage === '{{ page_id }}'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
|
||||
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
|
||||
:class="currentPage === '{{ page_id }}' ? 'text-gray-800 dark:text-gray-100' : ''"
|
||||
href="{{ url }}">
|
||||
<span x-html="$icon('{{ icon }}')"></span>
|
||||
<span class="ml-4">{{ label }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endmacro %}
|
||||
{# Dynamic sidebar that renders from menu discovery API #}
|
||||
|
||||
{# ============================================================================
|
||||
SIDEBAR CONTENT (shared between desktop and mobile)
|
||||
@@ -61,125 +11,84 @@
|
||||
Admin Portal
|
||||
</a>
|
||||
|
||||
<!-- Dashboard (always visible) -->
|
||||
<ul class="mt-6">
|
||||
{{ menu_item('dashboard', '/admin/dashboard', 'home', 'Dashboard') }}
|
||||
</ul>
|
||||
|
||||
<!-- Super Admin Section (only visible to super admins) -->
|
||||
<template x-if="isSuperAdmin">
|
||||
<div>
|
||||
{{ section_header('Super Admin', 'superAdmin') }}
|
||||
{% call section_content('superAdmin') %}
|
||||
{{ menu_item('admin-users', '/admin/admin-users', 'shield', 'Admin Users') }}
|
||||
{% endcall %}
|
||||
<!-- Loading State -->
|
||||
<div x-show="menuLoading" class="mt-6 px-6">
|
||||
<div class="animate-pulse space-y-3">
|
||||
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
|
||||
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/2"></div>
|
||||
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-2/3"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Platform Administration Section -->
|
||||
<div x-show="isSectionVisible('platformAdmin')">
|
||||
{{ section_header('Platform Administration', 'platformAdmin') }}
|
||||
{% call section_content('platformAdmin') %}
|
||||
{{ menu_item('companies', '/admin/companies', 'office-building', 'Companies') }}
|
||||
{{ menu_item('vendors', '/admin/vendors', 'shopping-bag', 'Vendors') }}
|
||||
{{ menu_item('messages', '/admin/messages', 'chat-bubble-left-right', 'Messages') }}
|
||||
{% endcall %}
|
||||
</div>
|
||||
|
||||
<!-- Vendor Operations Section -->
|
||||
<div x-show="isSectionVisible('vendorOps')">
|
||||
{{ section_header('Vendor Operations', 'vendorOps') }}
|
||||
{% call section_content('vendorOps') %}
|
||||
{{ menu_item('vendor-products', '/admin/vendor-products', 'cube', 'Products') }}
|
||||
{{ menu_item('customers', '/admin/customers', 'user-group', 'Customers') }}
|
||||
{{ menu_item('inventory', '/admin/inventory', 'archive', 'Inventory') }}
|
||||
{{ menu_item('orders', '/admin/orders', 'clipboard-list', 'Orders') }}
|
||||
{# Future items - uncomment when implemented:
|
||||
{{ menu_item('shipping', '/admin/shipping', 'truck', 'Shipping') }}
|
||||
#}
|
||||
{% endcall %}
|
||||
<!-- Menu Content (loaded from API) -->
|
||||
<div x-show="!menuLoading && menuData">
|
||||
<!-- Dynamic Sections from API -->
|
||||
<template x-for="section in (menuData?.sections || [])" :key="section.id">
|
||||
<div>
|
||||
<!-- Section Divider -->
|
||||
<div class="px-6 my-4">
|
||||
<hr class="border-gray-200 dark:border-gray-700" />
|
||||
</div>
|
||||
|
||||
<!-- Section Header (collapsible) -->
|
||||
<button
|
||||
@click="toggleSection(section.id)"
|
||||
class="flex items-center justify-between w-full px-6 py-2 text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
<span x-text="section.label"></span>
|
||||
<span
|
||||
x-html="$icon('chevron-down', 'w-4 h-4 transition-transform duration-200')"
|
||||
:class="{ 'rotate-180': openSections[section.id] }"
|
||||
></span>
|
||||
</button>
|
||||
|
||||
<!-- Section Items -->
|
||||
<ul
|
||||
x-show="openSections[section.id]"
|
||||
x-transition:enter="transition-all duration-200 ease-out"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 translate-y-0"
|
||||
x-transition:leave="transition-all duration-150 ease-in"
|
||||
x-transition:leave-start="opacity-100 translate-y-0"
|
||||
x-transition:leave-end="opacity-0 -translate-y-2"
|
||||
class="mt-1 overflow-hidden"
|
||||
>
|
||||
<template x-for="item in section.items" :key="item.id">
|
||||
<li class="relative px-6 py-3">
|
||||
<span
|
||||
x-show="currentPage === item.id"
|
||||
class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<a
|
||||
class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
|
||||
:class="currentPage === item.id ? 'text-gray-800 dark:text-gray-100' : ''"
|
||||
:href="item.url"
|
||||
>
|
||||
<span x-html="$icon(item.icon)"></span>
|
||||
<span class="ml-4" x-text="item.label"></span>
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Marketplace Section -->
|
||||
<div x-show="isSectionVisible('marketplace')">
|
||||
{{ section_header('Marketplace', 'marketplace') }}
|
||||
{% call section_content('marketplace') %}
|
||||
{{ menu_item('marketplace-letzshop', '/admin/marketplace/letzshop', 'shopping-cart', 'Letzshop') }}
|
||||
{% endcall %}
|
||||
</div>
|
||||
|
||||
<!-- Billing & Subscriptions Section -->
|
||||
<div x-show="isSectionVisible('billing')">
|
||||
{{ section_header('Billing & Subscriptions', 'billing') }}
|
||||
{% call section_content('billing') %}
|
||||
{{ menu_item('subscription-tiers', '/admin/subscription-tiers', 'tag', 'Subscription Tiers') }}
|
||||
{{ menu_item('subscriptions', '/admin/subscriptions', 'credit-card', 'Vendor Subscriptions') }}
|
||||
{{ menu_item('billing-history', '/admin/billing-history', 'document-text', 'Billing History') }}
|
||||
{% endcall %}
|
||||
</div>
|
||||
|
||||
<!-- Content Management Section -->
|
||||
<div x-show="isSectionVisible('contentMgmt')">
|
||||
{{ section_header('Content Management', 'contentMgmt') }}
|
||||
{% call section_content('contentMgmt') %}
|
||||
{{ menu_item('platforms', '/admin/platforms', 'globe-alt', 'Platforms') }}
|
||||
{{ menu_item('content-pages', '/admin/content-pages', 'document-text', 'Content Pages') }}
|
||||
{{ menu_item('vendor-themes', '/admin/vendor-themes', 'color-swatch', 'Vendor Themes') }}
|
||||
{% endcall %}
|
||||
</div>
|
||||
|
||||
<!-- Developer Tools Section -->
|
||||
<div x-show="isSectionVisible('devTools')">
|
||||
{{ section_header('Developer Tools', 'devTools') }}
|
||||
{% call section_content('devTools') %}
|
||||
{{ menu_item('components', '/admin/components', 'view-grid', 'Components') }}
|
||||
{{ menu_item('icons', '/admin/icons', 'photograph', 'Icons') }}
|
||||
{% endcall %}
|
||||
</div>
|
||||
|
||||
<!-- Platform Health Section -->
|
||||
<div x-show="isSectionVisible('platformHealth')">
|
||||
{{ section_header('Platform Health', 'platformHealth') }}
|
||||
{% call section_content('platformHealth') %}
|
||||
{{ menu_item('platform-health', '/admin/platform-health', 'chart-bar', 'Capacity Monitor') }}
|
||||
{{ menu_item('testing', '/admin/testing', 'beaker', 'Testing Hub') }}
|
||||
{{ menu_item('code-quality', '/admin/code-quality', 'shield-check', 'Code Quality') }}
|
||||
{% endcall %}
|
||||
</div>
|
||||
|
||||
<!-- Platform Monitoring Section -->
|
||||
<div x-show="isSectionVisible('monitoring')">
|
||||
{{ section_header('Platform Monitoring', 'monitoring') }}
|
||||
{% call section_content('monitoring') %}
|
||||
{{ menu_item('imports', '/admin/imports', 'cube', 'Import Jobs') }}
|
||||
{{ menu_item('background-tasks', '/admin/background-tasks', 'collection', 'Background Tasks') }}
|
||||
{{ menu_item('logs', '/admin/logs', 'document-text', 'Application Logs') }}
|
||||
{{ menu_item('notifications', '/admin/notifications', 'bell', 'Notifications') }}
|
||||
{% endcall %}
|
||||
</div>
|
||||
|
||||
<!-- Platform Settings Section -->
|
||||
<div x-show="isSectionVisible('settings')">
|
||||
{{ section_header('Platform Settings', 'settings') }}
|
||||
{% call section_content('settings') %}
|
||||
{{ menu_item('settings', '/admin/settings', 'cog', 'General') }}
|
||||
{{ menu_item('email-templates', '/admin/email-templates', 'mail', 'Email Templates') }}
|
||||
<!-- My Menu - super admin only (customize personal sidebar) -->
|
||||
<template x-if="isSuperAdmin">
|
||||
<li x-show="isMenuItemVisible('my-menu')" class="relative px-6 py-3">
|
||||
<span x-show="currentPage === 'my-menu'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
|
||||
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
|
||||
:class="currentPage === 'my-menu' ? 'text-gray-800 dark:text-gray-100' : ''"
|
||||
href="/admin/my-menu">
|
||||
<span x-html="$icon('view-grid')"></span>
|
||||
<span class="ml-4">My Menu</span>
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
{# TODO: Implement profile and API keys pages #}
|
||||
{# {{ menu_item('profile', '/admin/profile', 'user-circle', 'Profile') }} #}
|
||||
{# {{ menu_item('api-keys', '/admin/api-keys', 'key', 'API Keys') }} #}
|
||||
{% endcall %}
|
||||
<!-- Fallback: Static menu when API not loaded -->
|
||||
<div x-show="!menuLoading && !menuData" class="mt-6">
|
||||
<ul>
|
||||
<li class="relative px-6 py-3">
|
||||
<span x-show="currentPage === 'dashboard'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
|
||||
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
|
||||
:class="currentPage === 'dashboard' ? 'text-gray-800 dark:text-gray-100' : ''"
|
||||
href="/admin/dashboard">
|
||||
<span x-html="$icon('home')"></span>
|
||||
<span class="ml-4">Dashboard</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="px-6 py-3 text-xs text-gray-400">Loading menu...</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
Reference in New Issue
Block a user