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:
2026-02-04 22:18:27 +01:00
parent 6b7f385a1d
commit 334b28e5b5
13 changed files with 232 additions and 274 deletions

View File

@@ -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 %}