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

@@ -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,

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 ===');