Files
michaelschiemer/src/Framework/View/Components/AdminSidebar.php
2025-11-24 21:28:25 +01:00

304 lines
12 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Framework\View\Components;
use App\Application\Admin\ValueObjects\NavigationMenu;
use App\Application\Admin\ValueObjects\NavigationSection;
use App\Application\Admin\ValueObjects\NavigationItem;
use App\Framework\View\Attributes\ComponentName;
use App\Framework\View\Contracts\StaticComponent;
use App\Framework\View\Dom\ElementNode;
use App\Framework\View\Dom\Node;
use App\Framework\View\Dom\TextNode;
use App\Framework\View\ValueObjects\UIDataAttribute;
#[ComponentName('admin-sidebar')]
final readonly class AdminSidebar implements StaticComponent
{
private string $title;
private string $logoUrl;
private string $currentPath;
private ?NavigationMenu $navigationMenu;
public function __construct(
string $content = '',
array $attributes = []
) {
// Extract attributes with defaults
$this->title = $attributes['title'] ?? 'Admin Panel';
$this->logoUrl = $attributes['logo-url'] ?? '/admin';
$this->currentPath = $attributes['current-path'] ?? $attributes['currentPath'] ?? '/admin';
// Parse navigation menu from attributes
$this->navigationMenu = $this->parseNavigationMenu($attributes);
}
public function getRootNode(): Node
{
$nav = new ElementNode('nav');
$nav->setAttribute('class', 'admin-sidebar');
$nav->setAttribute('role', 'navigation');
$nav->setAttribute('aria-label', 'Admin navigation');
// Use TextNode for HTML content (will be parsed by HtmlRenderer)
$content = new TextNode($this->buildSidebarContent());
$nav->appendChild($content);
return $nav;
}
private function buildSidebarContent(): string
{
$header = $this->buildHeader();
$navigation = $this->buildNavigation();
$footer = $this->buildFooter();
$resizeHandle = '<div class="admin-sidebar__resize-handle" aria-label="Resize sidebar"></div>';
return $header . $navigation . $footer . $resizeHandle;
}
private function buildHeader(): string
{
return <<<HTML
<div class="admin-sidebar__header">
<a href="{$this->logoUrl}" class="admin-sidebar__logo-link">
<span class="admin-sidebar__title">{$this->title}</span>
</a>
</div>
HTML;
}
private function buildNavigation(): string
{
if ($this->navigationMenu !== null) {
return $this->buildDynamicNavigation();
}
return $this->buildFallbackNavigation();
}
private function buildDynamicNavigation(): string
{
$html = '<nav class="admin-nav" aria-label="Primary">';
foreach ($this->navigationMenu->sections as $section) {
$html .= $this->renderSection($section);
}
$html .= '</nav>';
return $html;
}
private function renderSection(NavigationSection $section): string
{
$sectionId = $this->generateSectionId($section->name);
// Use name attribute for exclusive accordion behavior
$html = '<details class="admin-nav__section" name="admin-nav-sections" ' . UIDataAttribute::SECTION_ID->value() . '="' . $this->escape($sectionId) . '">';
if ($section->name !== '') {
$chevronIcon = '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>';
$html .= '<summary class="admin-nav__section-toggle">';
$html .= '<h2>' . htmlspecialchars($section->name) . '</h2>';
$html .= $chevronIcon;
$html .= '</summary>';
}
$html .= '<ul role="list">';
foreach ($section->items as $item) {
$html .= $this->renderItem($item);
}
$html .= '</ul></details>';
return $html;
}
/**
* Generate a section ID from section name
*/
private function generateSectionId(string $name): string
{
return strtolower(preg_replace('/[^a-zA-Z0-9]+/', '-', trim($name)));
}
private function renderItem(NavigationItem $item): string
{
$activeState = $item->isActive($this->currentPath)
? 'aria-current="page"'
: '';
$iconHtml = $this->renderIcon($item->icon);
return <<<HTML
<li>
<a href="{$this->escape($item->url)}" {$activeState}>
{$iconHtml}
<span>{$this->escape($item->name)}</span>
</a>
</li>
HTML;
}
private function renderIcon(?string $icon): string
{
if ($icon === null || $icon === '') {
return '';
}
// Try to match icon name to SVG or use emoji fallback
$svgIcon = $this->getSvgIcon($icon);
if ($svgIcon !== null) {
return '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">' . $svgIcon . '</svg>';
}
// Fallback to emoji or text
$emojiIcon = $this->getEmojiIcon($icon);
return '<span class="admin-nav__icon">' . $emojiIcon . '</span>';
}
private function getSvgIcon(string $iconName): ?string
{
// Common icon mappings
$icons = [
'dashboard' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>',
'server' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"/>',
'database' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"/>',
'photo' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>',
'chart-bar' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>',
'code' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/>',
'bell' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/>',
'brain' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>',
'file' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>',
];
return $icons[strtolower($iconName)] ?? null;
}
private function getEmojiIcon(string $iconName): string
{
$emoji = [
'dashboard' => '📊',
'server' => '🖥️',
'database' => '💾',
'photo' => '🖼️',
'chart-bar' => '📈',
'code' => '💻',
'bell' => '🔔',
'brain' => '🧠',
'file' => '📄',
];
return $emoji[strtolower($iconName)] ?? '📄';
}
private function buildFallbackNavigation(): string
{
// Fallback to old hardcoded navigation for backward compatibility
$sectionId = $this->generateSectionId('Dashboard');
$chevronIcon = '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>';
return <<<HTML
<nav class="admin-nav">
<details class="admin-nav__section" name="admin-nav-sections" data-section-id="{$sectionId}">
<summary class="admin-nav__section-toggle">
<h2>Dashboard</h2>
{$chevronIcon}
</summary>
<ul role="list">
<li>
<a href="/admin" {$this->getActiveState('/admin')}>
<span class="admin-nav__icon">📊</span>
<span>Overview</span>
</a>
</li>
</ul>
</details>
</nav>
HTML;
}
private function buildFooter(): string
{
return <<<HTML
<div class="admin-sidebar__footer">
<div class="admin-sidebar__user">
<div class="admin-sidebar__avatar" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: white; font-weight: 600; font-size: 0.875rem;">A</div>
<div class="admin-sidebar__user-info">
<span class="admin-sidebar__user-name">Admin User</span>
<span class="admin-sidebar__user-role">Administrator</span>
</div>
</div>
</div>
HTML;
}
private function getActiveState(string $path): string
{
return $path === $this->currentPath ? 'aria-current="page"' : '';
}
/**
* Parse navigation menu from attributes
*/
private function parseNavigationMenu(array $attributes): ?NavigationMenu
{
// Try different attribute names
$menuData = $attributes['navigation-menu']
?? $attributes['navigation_menu']
?? $attributes['navigationMenu']
?? null;
if ($menuData === null) {
return null;
}
// Handle array directly
if (is_array($menuData)) {
try {
return NavigationMenu::fromArray($menuData);
} catch (\Exception $e) {
error_log("AdminSidebar: Failed to parse navigation menu from array: " . $e->getMessage());
return null;
}
}
// Handle JSON string
if (is_string($menuData)) {
// Decode HTML entities first (template system escapes values in HTML attributes)
$decodedJson = html_entity_decode($menuData, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$decoded = json_decode($decodedJson, true);
if (json_last_error() !== JSON_ERROR_NONE) {
error_log("AdminSidebar: JSON decode error: " . json_last_error_msg() . " - Data: " . substr($decodedJson, 0, 200));
return null;
}
if (!is_array($decoded)) {
error_log("AdminSidebar: Decoded data is not an array: " . gettype($decoded));
return null;
}
try {
return NavigationMenu::fromArray($decoded);
} catch (\Exception $e) {
error_log("AdminSidebar: Failed to parse navigation menu from decoded JSON: " . $e->getMessage() . " - Data: " . json_encode($decoded));
return null;
}
}
return null;
}
/**
* Escape HTML special characters
*/
private function escape(string $string): string
{
return htmlspecialchars($string, ENT_QUOTES, 'UTF-8');
}
}