Some checks failed
Deploy Application / deploy (push) Has been cancelled
304 lines
12 KiB
PHP
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');
|
|
}
|
|
}
|