feat(Production): Complete production deployment infrastructure

- Add comprehensive health check system with multiple endpoints
- Add Prometheus metrics endpoint
- Add production logging configurations (5 strategies)
- Add complete deployment documentation suite:
  * QUICKSTART.md - 30-minute deployment guide
  * DEPLOYMENT_CHECKLIST.md - Printable verification checklist
  * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle
  * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference
  * production-logging.md - Logging configuration guide
  * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation
  * README.md - Navigation hub
  * DEPLOYMENT_SUMMARY.md - Executive summary
- Add deployment scripts and automation
- Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment
- Update README with production-ready features

All production infrastructure is now complete and ready for deployment.
This commit is contained in:
2025-10-25 19:18:37 +02:00
parent caa85db796
commit fc3d7e6357
83016 changed files with 378904 additions and 20919 deletions

View File

@@ -16,9 +16,9 @@ use App\Framework\Auth\Auth;
use App\Framework\Http\Method;
use App\Framework\Http\Status;
use App\Framework\Meta\MetaData;
use App\Framework\Router\AdminRoutes;
use App\Framework\Router\Result\JsonResult;
use App\Framework\Router\Result\ViewResult;
use App\Framework\Router\AdminRoutes;
final class AnalyticsController
{

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Application\Admin;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Framework\Attributes\Route;
use App\Framework\Auth\Auth;
use App\Framework\Core\VersionInfo;
@@ -12,8 +11,9 @@ use App\Framework\DateTime\Clock;
use App\Framework\Http\Method;
use App\Framework\Meta\MetaData;
use App\Framework\Performance\MemoryMonitor;
use App\Framework\Router\Result\ViewResult;
use App\Framework\Process\Services\SystemInfoService;
use App\Framework\Router\AdminRoutes;
use App\Framework\Router\Result\ViewResult;
final readonly class Dashboard
{
@@ -21,7 +21,7 @@ final readonly class Dashboard
private VersionInfo $versionInfo,
private MemoryMonitor $memoryMonitor,
private Clock $clock,
private AdminLayoutProcessor $layoutProcessor,
private SystemInfoService $systemInfo,
) {
}
@@ -29,61 +29,87 @@ final readonly class Dashboard
#[Route(path: '/admin', method: Method::GET, name: AdminRoutes::DASHBOARD)]
public function show(): ViewResult
{
$data = [
'title' => 'Admin Dashboard',
'framework_version' => $this->versionInfo->getVersion(),
'uptime_formatted' => $this->getServerUptime(),
'memory_usage_formatted' => $this->memoryMonitor->getCurrentMemory()->toHumanReadable(),
'peak_memory_formatted' => $this->memoryMonitor->getPeakMemory()->toHumanReadable(),
'load_average' => $this->getLoadAverage(),
'db_pool_size' => 10,
'db_active_connections' => 3,
'cache_hit_rate' => 85,
'cache_total_operations' => number_format(12547),
'requests_today' => number_format(1247),
'errors_today' => 3,
'last_deployment' => $this->clock->now()->format('Y-m-d H:i'),
'clear_cache_url' => '/admin/infrastructure/cache/reset',
'logs_url' => '/admin/infrastructure/logs',
'migrations_url' => '/admin/infrastructure/migrations',
];
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
// DEBUG: Log template data to see what's being passed
error_log("🎯 Dashboard::show() - Final template data keys: " . implode(', ', array_keys($finalData)));
error_log("🎯 Dashboard::show() - Navigation menu count: " . count($finalData['navigation_menu'] ?? []));
error_log("🎯 Dashboard::show() - Framework version: " . ($finalData['framework_version'] ?? 'MISSING'));
$sysInfo = ($this->systemInfo)();
return new ViewResult(
template: 'dashboard',
metaData: new MetaData('Admin Dashboard', 'Administrative control panel'),
data: $finalData
data: [
'title' => 'Admin Dashboard',
'page_title' => 'Admin Dashboard',
'current_path' => '/admin',
'framework_version' => $this->versionInfo->getVersion(),
'uptime_formatted' => $this->formatUptime($sysInfo->uptime),
'boot_time' => $sysInfo->uptime->bootTime->format('Y-m-d H:i:s'),
'memory_usage_formatted' => $this->memoryMonitor->getCurrentMemory()->toHumanReadable(),
'peak_memory_formatted' => $this->memoryMonitor->getPeakMemory()->toHumanReadable(),
'load_average' => $this->formatLoadAverage($sysInfo->load),
'system_memory_total' => $this->formatBytes($sysInfo->memory->totalBytes),
'system_memory_used' => $this->formatBytes($sysInfo->memory->usedBytes),
'system_memory_free' => $this->formatBytes($sysInfo->memory->freeBytes),
'system_memory_percent' => round(($sysInfo->memory->usedBytes / $sysInfo->memory->totalBytes) * 100, 1),
'cpu_cores' => $sysInfo->cpu->cores,
'cpu_model' => $sysInfo->cpu->model,
'disk_total' => $this->formatBytes($sysInfo->disk->totalBytes),
'disk_used' => $this->formatBytes($sysInfo->disk->usedBytes),
'disk_free' => $this->formatBytes($sysInfo->disk->availableBytes),
'disk_percent' => round(($sysInfo->disk->usedBytes / $sysInfo->disk->totalBytes) * 100, 1),
'processes_total' => $sysInfo->processes->total,
'processes_running' => $sysInfo->processes->running,
'processes_sleeping' => $sysInfo->processes->sleeping,
'db_pool_size' => 10,
'db_active_connections' => 3,
'cache_hit_rate' => '85%',
'cache_total_operations' => number_format(12547),
'requests_today' => number_format(1247),
'errors_today' => 3,
'last_deployment' => $this->clock->now()->format('Y-m-d H:i'),
'clear_cache_url' => '/admin/infrastructure/cache/reset',
'logs_url' => '/admin/infrastructure/logs',
'migrations_url' => '/admin/infrastructure/migrations',
]
);
}
private function getLoadAverage(): string
private function formatLoadAverage(\App\Framework\Process\ValueObjects\SystemInfo\LoadAverage $load): string
{
if (function_exists('sys_getloadavg')) {
$load = sys_getloadavg();
return sprintf('%.2f, %.2f, %.2f', $load[0], $load[1], $load[2]);
}
return 'N/A';
return sprintf('%.2f, %.2f, %.2f', $load->oneMinute, $load->fiveMinutes, $load->fifteenMinutes);
}
private function getServerUptime(): string
private function formatUptime(\App\Framework\Process\ValueObjects\SystemInfo\SystemUptime $uptime): string
{
// Für Linux-Systeme
if (function_exists('shell_exec') && stripos(PHP_OS, 'Linux') !== false) {
$uptime = shell_exec('uptime -p');
if ($uptime) {
return $uptime;
}
$duration = $uptime->uptime;
$totalHours = (int) floor($duration->toHours());
$totalMinutes = (int) floor($duration->toMinutes());
$days = (int) floor($totalHours / 24);
$hours = $totalHours % 24;
$minutes = $totalMinutes % 60;
$parts = [];
if ($days > 0) {
$parts[] = $days . ' ' . ($days === 1 ? 'Tag' : 'Tage');
}
if ($hours > 0) {
$parts[] = $hours . ' ' . ($hours === 1 ? 'Stunde' : 'Stunden');
}
if ($minutes > 0 && $days === 0) {
$parts[] = $minutes . ' ' . ($minutes === 1 ? 'Minute' : 'Minuten');
}
// Fallback
return 'Nicht verfügbar';
return !empty($parts) ? implode(', ', $parts) : 'Weniger als eine Minute';
}
private function formatBytes(int $bytes): string
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= (1024 ** $pow);
return round($bytes, 2) . ' ' . $units[$pow];
}
}

View File

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace App\Application\Admin;
use App\Framework\Attributes\Route;
use App\Framework\Deployment\Pipeline\Services\PipelineHistoryService;
use App\Framework\Deployment\Pipeline\ValueObjects\DeploymentEnvironment;
use App\Framework\Deployment\Pipeline\ValueObjects\PipelineStatus;
use App\Framework\Http\Method;
use App\Framework\Meta\MetaData;
use App\Framework\Router\Result\ViewResult;
use App\Framework\Core\ValueObjects\Duration;
/**
* Deployment Pipeline Dashboard Controller
*
* Provides comprehensive deployment pipeline monitoring:
* - Pipeline execution history
* - Deployment statistics and success rates
* - Environment-specific metrics
* - Recent deployments overview
*/
final readonly class DeploymentDashboardController
{
public function __construct(
private PipelineHistoryService $historyService
) {}
#[Route(path: '/admin/deployment/dashboard', method: Method::GET)]
public function dashboard(): ViewResult
{
// Get overall statistics
$stats = $this->historyService->getStatistics();
// Get recent deployments (last 20)
$recentDeployments = $this->historyService->getRecent(limit: 20);
// Get failed deployments (last 10)
$failedDeployments = $this->historyService->getByStatus(
status: PipelineStatus::FAILED,
limit: 10
);
// Get environment-specific stats
$productionStats = $this->historyService->getStatistics(
environment: DeploymentEnvironment::PRODUCTION
);
$stagingStats = $this->historyService->getStatistics(
environment: DeploymentEnvironment::STAGING
);
return new ViewResult(
template: 'deployment-dashboard',
metaData: MetaData::create('Deployment Pipeline Dashboard'),
data: [
'pageTitle' => 'Deployment Pipeline Dashboard',
'pageDescription' => 'Monitor deployment pipeline executions and statistics',
// Overall statistics
'totalDeployments' => $stats['total_deployments'],
'successfulDeployments' => $stats['successful'],
'failedDeployments' => $stats['failed'],
'rolledBackDeployments' => $stats['rolled_back'],
'successRate' => $stats['success_rate'],
'averageDurationFormatted' => Duration::fromMilliseconds($stats['average_duration_ms'])->toHumanReadable(),
// Environment-specific statistics
'productionStats' => $productionStats,
'stagingStats' => $stagingStats,
// Recent deployments list
'recentDeployments' => array_map(
fn($entry) => $this->formatDeploymentEntry($entry),
$recentDeployments
),
// Failed deployments list
'failedDeployments' => array_map(
fn($entry) => $this->formatDeploymentEntry($entry),
$failedDeployments
),
]
);
}
/**
* Format deployment entry for display
*/
private function formatDeploymentEntry($entry): array
{
return [
'pipelineId' => $entry->pipelineId->value,
'environment' => $entry->environment->value,
'status' => $entry->status->value,
'statusBadgeClass' => $this->getStatusBadgeClass($entry->status),
'durationFormatted' => $entry->totalDuration->toHumanReadable(),
'stageCount' => $entry->getStageCount(),
'successRate' => round($entry->getSuccessRate(), 1),
'startedAt' => $entry->startedAt?->format('Y-m-d H:i:s'),
'completedAt' => $entry->completedAt?->format('Y-m-d H:i:s'),
'error' => $entry->error,
'wasRolledBack' => $entry->wasRolledBack,
'failedStage' => $entry->failedStage?->value,
];
}
/**
* Get CSS class for status badge
*/
private function getStatusBadgeClass(PipelineStatus $status): string
{
return match ($status) {
PipelineStatus::SUCCESS => 'admin-badge--success',
PipelineStatus::FAILED => 'admin-badge--danger',
PipelineStatus::ROLLED_BACK => 'admin-badge--warning',
PipelineStatus::RUNNING => 'admin-badge--info',
};
}
}

View File

@@ -10,7 +10,7 @@ use App\Framework\DateTime\Clock;
use App\Framework\Design\Component\ComponentCategory;
use App\Framework\Design\ComponentScanner;
use App\Framework\Design\Service\DesignSystemAnalyzer;
use App\Framework\Filesystem\FilePath;
use App\Framework\Filesystem\ValueObjects\FilePath;
use App\Framework\Filesystem\FileScanner;
use App\Framework\Filesystem\ValueObjects\FilePattern;
use App\Framework\Http\HttpRequest;

View File

@@ -11,9 +11,9 @@ use App\Framework\Cache\Metrics\CacheMetricsInterface;
use App\Framework\DateTime\Clock;
use App\Framework\Http\Method;
use App\Framework\Meta\MetaData;
use App\Framework\Router\AdminRoutes;
use App\Framework\Router\Result\JsonResult;
use App\Framework\Router\Result\ViewResult;
use App\Framework\Router\AdminRoutes;
final readonly class CacheMetricsController
{

View File

@@ -9,6 +9,7 @@ use App\Framework\Attributes\Route;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\DateTime\Clock;
use App\Framework\DateTime\Timer;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\Method;
use App\Framework\Http\SseStream;
use App\Framework\Http\Status;
@@ -68,18 +69,18 @@ final readonly class LogViewerController
/**
* @return JsonResult<array{status: string, data?: array<string, mixed>, message?: string, available_logs?: array<int, string>}>
*/
public function readLog(string $logName): JsonResult
public function readLog(string $logName, HttpRequest $request): JsonResult
{
$limit = (int) ($_GET['limit'] ?? 100);
$level = $_GET['level'] ?? null;
$search = $_GET['search'] ?? null;
$limit = $request->query->getInt('limit', 100);
$level = $request->query->get('level');
$search = $request->query->get('search');
try {
$logData = $this->logViewer->readLog($logName, $limit, $level, $search);
return new JsonResult([
'status' => 'success',
'data' => $logData,
'data' => $logData->toArray(),
]);
} catch (\InvalidArgumentException $e) {
@@ -95,16 +96,16 @@ final readonly class LogViewerController
/**
* @return JsonResult<array{status: string, data?: array<string, mixed>, message?: string}>
*/
public function tailLog(string $logName): JsonResult
public function tailLog(string $logName, HttpRequest $request): JsonResult
{
$lines = (int) ($_GET['lines'] ?? 50);
$lines = $request->query->getInt('lines', 50);
try {
$logData = $this->logViewer->tailLog($logName, $lines);
return new JsonResult([
'status' => 'success',
'data' => $logData,
'data' => $logData->toArray(),
]);
} catch (\InvalidArgumentException $e) {
@@ -119,11 +120,12 @@ final readonly class LogViewerController
/**
* @return JsonResult<array{status: string, data?: array<string, mixed>, message?: string}>
*/
public function searchLogs(): JsonResult
public function searchLogs(HttpRequest $request): JsonResult
{
$query = $_GET['query'] ?? '';
$level = $_GET['level'] ?? null;
$logs = isset($_GET['logs']) ? explode(',', $_GET['logs']) : null;
$query = $request->query->get('query', '');
$level = $request->query->get('level');
$logsParam = $request->query->get('logs');
$logs = $logsParam ? explode(',', $logsParam) : null;
if (empty($query)) {
return new JsonResult([
@@ -136,7 +138,7 @@ final readonly class LogViewerController
return new JsonResult([
'status' => 'success',
'data' => $results,
'data' => $results->toArray(),
]);
}
@@ -161,11 +163,11 @@ final readonly class LogViewerController
}
#[Route('/admin/infrastructure/logs/api/stream/{logName}', Method::GET)]
public function streamLog(string $logName): SseResult
public function streamLog(string $logName, HttpRequest $request): SseResult
{
$level = $_GET['level'] ?? null;
$search = $_GET['search'] ?? null;
$batchSize = (int) ($_GET['batch_size'] ?? 10);
$level = $request->query->get('level');
$search = $request->query->get('search');
$batchSize = $request->query->getInt('batch_size', 10);
try {
static $executed = false;
@@ -184,9 +186,15 @@ final readonly class LogViewerController
break;
}
// Convert LogEntry objects to arrays for JSON
$batchData = array_map(
fn($entry) => $entry->toArray(),
$batch['batch']
);
$stream->sendJson([
'status' => 'success',
'batch' => $batch['batch'],
'batch' => $batchData,
'batch_number' => $batch['batch_number'],
'entries_in_batch' => $batch['entries_in_batch'],
'total_processed' => $batch['total_processed'],

View File

@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Application\Admin;
use App\Application\LiveComponents\Dashboard\FailedJobsListComponent;
use App\Application\LiveComponents\Dashboard\FailedJobsState;
use App\Application\LiveComponents\Dashboard\QueueStatsComponent;
use App\Application\LiveComponents\Dashboard\QueueStatsState;
use App\Application\LiveComponents\Dashboard\SchedulerState;
use App\Application\LiveComponents\Dashboard\SchedulerTimelineComponent;
use App\Application\LiveComponents\Dashboard\WorkerHealthComponent;
use App\Application\LiveComponents\Dashboard\WorkerHealthState;
use App\Framework\Attributes\Route;
use App\Framework\Http\Method;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\Meta\MetaData;
use App\Framework\Queue\Queue;
use App\Framework\Queue\Services\DeadLetterManager;
use App\Framework\Queue\Services\JobMetricsManagerInterface;
use App\Framework\Queue\Services\WorkerRegistry;
use App\Framework\Router\Result\ViewResult;
use App\Framework\Scheduler\Services\SchedulerService;
/**
* Job Dashboard Controller
*
* Provides comprehensive real-time monitoring dashboard for:
* - Queue statistics and metrics
* - Worker health monitoring
* - Failed jobs management
* - Scheduler timeline
*
* Uses composable LiveComponents for modular, reusable UI.
*/
final readonly class JobDashboardController
{
public function __construct(
private Queue $queue,
private JobMetricsManagerInterface $metricsManager,
private WorkerRegistry $workerRegistry,
private DeadLetterManager $deadLetterManager,
private SchedulerService $scheduler
) {}
#[Route(path: '/admin/jobs/dashboard', method: Method::GET)]
public function dashboard(): ViewResult
{
// Create composable LiveComponents
$queueStats = new QueueStatsComponent(
id: ComponentId::create('queue-stats', 'main'),
state: QueueStatsState::empty(),
queue: $this->queue,
metricsManager: $this->metricsManager
);
$workerHealth = new WorkerHealthComponent(
id: ComponentId::create('worker-health', 'main'),
state: WorkerHealthState::empty(),
workerRegistry: $this->workerRegistry
);
$failedJobs = new FailedJobsListComponent(
id: ComponentId::create('failed-jobs-list', 'main'),
state: FailedJobsState::empty(),
deadLetterManager: $this->deadLetterManager
);
$schedulerTimeline = new SchedulerTimelineComponent(
id: ComponentId::create('scheduler-timeline', 'main'),
state: SchedulerState::empty(),
scheduler: $this->scheduler
);
return new ViewResult(
template: 'job-dashboard',
metaData: MetaData::create('Background Jobs Dashboard'),
data: [
'pageTitle' => 'Background Jobs Dashboard',
// 'queueStats' => $queueStats,
// 'workerHealth' => $workerHealth,
// 'failedJobs' => $failedJobs,
// 'schedulerTimeline' => $schedulerTimeline,
]
);
}
}

View File

@@ -11,8 +11,8 @@ use App\Framework\Database\Migration\MigrationLoader;
use App\Framework\Database\Migration\MigrationRunner;
use App\Framework\Http\Method;
use App\Framework\Meta\MetaData;
use App\Framework\Router\Result\ViewResult;
use App\Framework\Router\AdminRoutes;
use App\Framework\Router\Result\ViewResult;
use App\Framework\View\Table\Table;
use App\Framework\View\Table\TableBuilder;
use App\Framework\View\Table\ValueObjects\ColumnDefinition;

View File

@@ -4,8 +4,11 @@ declare(strict_types=1);
namespace App\Application\Admin;
use App\Domain\PreSave\PreSaveCampaign;
use App\Domain\PreSave\PreSaveCampaignRepository;
use App\Domain\PreSave\Services\PreSaveCampaignService;
use App\Domain\PreSave\ValueObjects\CampaignStatus;
use App\Domain\PreSave\ValueObjects\TrackUrl;
use App\Framework\Admin\AdminPageRenderer;
use App\Framework\Admin\Attributes\AdminResource;
use App\Framework\Admin\Factories\AdminFormFactory;
@@ -13,18 +16,14 @@ use App\Framework\Admin\Factories\AdminTableFactory;
use App\Framework\Admin\ValueObjects\AdminFormConfig;
use App\Framework\Admin\ValueObjects\AdminTableConfig;
use App\Framework\Attributes\Route;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\Method;
use App\Framework\Router\Result\JsonResult;
use App\Framework\Router\Result\Redirect;
use App\Framework\Router\Result\ViewResult;
use App\Domain\PreSave\PreSaveCampaign;
use App\Domain\PreSave\ValueObjects\CampaignStatus;
use App\Domain\PreSave\ValueObjects\StreamingPlatform;
use App\Domain\PreSave\ValueObjects\TrackUrl;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Exception\FrameworkException;
use App\Framework\Exception\ErrorCode;
/**
* Pre-Save Campaign Admin Controller
@@ -47,7 +46,8 @@ final readonly class PreSaveCampaignAdminController
private AdminFormFactory $formFactory,
private PreSaveCampaignRepository $repository,
private PreSaveCampaignService $service,
) {}
) {
}
/**
* List all campaigns
@@ -94,7 +94,7 @@ final readonly class PreSaveCampaignAdminController
$campaigns = $this->repository->findAll();
$campaignData = array_map(
fn($campaign) => [
fn ($campaign) => [
...$campaign->toArray(),
'release_date' => $campaign->releaseDate->format('Y-m-d H:i'),
'created_at' => $campaign->createdAt->format('Y-m-d H:i'),
@@ -163,7 +163,7 @@ final readonly class PreSaveCampaignAdminController
$campaigns = $this->repository->findAll();
$items = array_map(
fn($campaign) => [
fn ($campaign) => [
...$campaign->toArray(),
'release_date' => $campaign->releaseDate->format('Y-m-d H:i'),
'created_at' => $campaign->createdAt->format('Y-m-d H:i'),
@@ -293,13 +293,13 @@ final readonly class PreSaveCampaignAdminController
// Parse track URLs
$trackUrls = [];
if (!empty($data['track_url_spotify'])) {
if (! empty($data['track_url_spotify'])) {
$trackUrls[] = TrackUrl::fromUrl($data['track_url_spotify']);
}
if (!empty($data['track_url_tidal'])) {
if (! empty($data['track_url_tidal'])) {
$trackUrls[] = TrackUrl::fromUrl($data['track_url_tidal']);
}
if (!empty($data['track_url_apple_music'])) {
if (! empty($data['track_url_apple_music'])) {
$trackUrls[] = TrackUrl::fromUrl($data['track_url_apple_music']);
}
@@ -319,8 +319,8 @@ final readonly class PreSaveCampaignAdminController
coverImageUrl: $data['cover_image_url'],
releaseDate: Timestamp::fromDateTime(new \DateTimeImmutable($data['release_date'])),
trackUrls: $trackUrls,
description: !empty($data['description']) ? $data['description'] : null,
startDate: !empty($data['start_date']) ? Timestamp::fromDateTime(new \DateTimeImmutable($data['start_date'])) : null
description: ! empty($data['description']) ? $data['description'] : null,
startDate: ! empty($data['start_date']) ? Timestamp::fromDateTime(new \DateTimeImmutable($data['start_date'])) : null
);
// Save campaign
@@ -461,13 +461,13 @@ final readonly class PreSaveCampaignAdminController
// Parse track URLs
$trackUrls = [];
if (!empty($data['track_url_spotify'])) {
if (! empty($data['track_url_spotify'])) {
$trackUrls[] = TrackUrl::fromUrl($data['track_url_spotify']);
}
if (!empty($data['track_url_tidal'])) {
if (! empty($data['track_url_tidal'])) {
$trackUrls[] = TrackUrl::fromUrl($data['track_url_tidal']);
}
if (!empty($data['track_url_apple_music'])) {
if (! empty($data['track_url_apple_music'])) {
$trackUrls[] = TrackUrl::fromUrl($data['track_url_apple_music']);
}
@@ -490,8 +490,8 @@ final readonly class PreSaveCampaignAdminController
status: CampaignStatus::from($data['status']),
createdAt: $campaign->createdAt,
updatedAt: Timestamp::now(),
description: !empty($data['description']) ? $data['description'] : null,
startDate: !empty($data['start_date']) ? Timestamp::fromDateTime(new \DateTimeImmutable($data['start_date'])) : null
description: ! empty($data['description']) ? $data['description'] : null,
startDate: ! empty($data['start_date']) ? Timestamp::fromDateTime(new \DateTimeImmutable($data['start_date'])) : null
);
$this->repository->save($updatedCampaign);
@@ -540,7 +540,7 @@ final readonly class PreSaveCampaignAdminController
'Only draft campaigns can be published'
)->withData([
'campaign_id' => $id,
'current_status' => $campaign->status->value
'current_status' => $campaign->status->value,
]);
}
@@ -574,13 +574,13 @@ final readonly class PreSaveCampaignAdminController
)->withData(['campaign_id' => $id]);
}
if (!in_array($campaign->status, [CampaignStatus::DRAFT, CampaignStatus::SCHEDULED, CampaignStatus::ACTIVE], true)) {
if (! in_array($campaign->status, [CampaignStatus::DRAFT, CampaignStatus::SCHEDULED, CampaignStatus::ACTIVE], true)) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
'Campaign cannot be cancelled in current status'
)->withData([
'campaign_id' => $id,
'current_status' => $campaign->status->value
'current_status' => $campaign->status->value,
]);
}

View File

@@ -4,19 +4,19 @@ declare(strict_types=1);
namespace App\Application\Admin;
use App\Framework\Attributes\Route;
use App\Framework\Attributes\Auth;
use App\Framework\Core\Method;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\ViewResult;
use App\Framework\Http\JsonResult;
use App\Framework\Http\Redirect;
use App\Domain\PreSave\PreSaveCampaign;
use App\Domain\PreSave\PreSaveCampaignRepository;
use App\Domain\PreSave\PreSaveRegistrationRepository;
use App\Domain\PreSave\PreSaveCampaign;
use App\Domain\PreSave\ValueObjects\CampaignStatus;
use App\Framework\Exception\FrameworkException;
use App\Framework\Attributes\Auth;
use App\Framework\Attributes\Route;
use App\Framework\Core\Method;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\JsonResult;
use App\Framework\Http\Redirect;
use App\Framework\Http\ViewResult;
/**
* Admin Controller for Pre-Save Campaign Management
@@ -27,7 +27,8 @@ final readonly class PreSaveCampaignController
public function __construct(
private PreSaveCampaignRepository $campaignRepository,
private PreSaveRegistrationRepository $registrationRepository
) {}
) {
}
/**
* Show all campaigns overview
@@ -39,7 +40,7 @@ final readonly class PreSaveCampaignController
return new ViewResult('admin/presave/campaigns/index', [
'campaigns' => $campaigns,
'stats' => $this->getGlobalStats()
'stats' => $this->getGlobalStats(),
]);
}
@@ -65,13 +66,13 @@ final readonly class PreSaveCampaignController
// Parse track URLs from form
$trackUrls = [];
if (!empty($data['spotify_url'])) {
if (! empty($data['spotify_url'])) {
$trackUrls['spotify'] = $data['spotify_url'];
}
if (!empty($data['apple_music_url'])) {
if (! empty($data['apple_music_url'])) {
$trackUrls['apple_music'] = $data['apple_music_url'];
}
if (!empty($data['tidal_url'])) {
if (! empty($data['tidal_url'])) {
$trackUrls['tidal'] = $data['tidal_url'];
}
@@ -83,7 +84,7 @@ final readonly class PreSaveCampaignController
description: $data['description'] ?? null,
releaseDate: strtotime($data['release_date']),
trackUrls: $trackUrls,
startDate: !empty($data['start_date']) ? strtotime($data['start_date']) : null
startDate: ! empty($data['start_date']) ? strtotime($data['start_date']) : null
);
$this->campaignRepository->save($campaign);
@@ -114,7 +115,7 @@ final readonly class PreSaveCampaignController
return new ViewResult('admin/presave/campaigns/show', [
'campaign' => $campaign,
'registrations' => $registrations,
'stats' => $stats
'stats' => $stats,
]);
}
@@ -135,7 +136,7 @@ final readonly class PreSaveCampaignController
}
return new ViewResult('admin/presave/campaigns/edit', [
'campaign' => $campaign
'campaign' => $campaign,
]);
}
@@ -160,13 +161,13 @@ final readonly class PreSaveCampaignController
// Parse track URLs
$trackUrls = [];
if (!empty($data['spotify_url'])) {
if (! empty($data['spotify_url'])) {
$trackUrls['spotify'] = $data['spotify_url'];
}
if (!empty($data['apple_music_url'])) {
if (! empty($data['apple_music_url'])) {
$trackUrls['apple_music'] = $data['apple_music_url'];
}
if (!empty($data['tidal_url'])) {
if (! empty($data['tidal_url'])) {
$trackUrls['tidal'] = $data['tidal_url'];
}
@@ -198,7 +199,7 @@ final readonly class PreSaveCampaignController
if ($campaign === null) {
return new JsonResult([
'success' => false,
'message' => 'Campaign not found'
'message' => 'Campaign not found',
], 404);
}
@@ -206,7 +207,7 @@ final readonly class PreSaveCampaignController
return new JsonResult([
'success' => true,
'message' => 'Campaign deleted successfully'
'message' => 'Campaign deleted successfully',
]);
}
@@ -317,7 +318,7 @@ final readonly class PreSaveCampaignController
'active' => 0,
'paused' => 0,
'completed' => 0,
'total_registrations' => 0
'total_registrations' => 0,
];
foreach ($allCampaigns as $campaign) {
@@ -344,8 +345,8 @@ final readonly class PreSaveCampaignController
'by_platform' => [
'spotify' => 0,
'apple_music' => 0,
'tidal' => 0
]
'tidal' => 0,
],
];
foreach ($registrations as $registration) {

View File

@@ -5,10 +5,13 @@ declare(strict_types=1);
namespace App\Application\Admin;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Application\LiveComponents\ImageGallery\ImageGalleryComponent;
use App\Domain\Media\ImageRepository;
use App\Framework\Attributes\Route;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\Method;
use App\Framework\LiveComponents\DataProviderResolver;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\Meta\MetaData;
use App\Framework\Router\Result\ViewResult;
@@ -16,37 +19,37 @@ final readonly class ShowImageManager
{
public function __construct(
private AdminLayoutProcessor $layoutProcessor,
private ImageRepository $imageRepository
private ImageRepository $imageRepository,
private DataProviderResolver $dataProviderResolver
) {
}
#[Route(path: '/admin/images', method: Method::GET)]
public function __invoke(HttpRequest $request): ViewResult
{
// Load real images from database
$images = $this->imageRepository->findAll(limit: 50);
// Resolve database provider
$databaseProvider = $this->dataProviderResolver->resolve(
\App\Application\LiveComponents\Services\ImageGalleryDataProvider::class,
'database'
);
// Convert images to array format for the template
$imageData = array_map(function ($image) {
return [
'ulid' => (string) $image->ulid,
'filename' => $image->filename,
'original_filename' => $image->originalFilename,
'url' => '/images/' . $image->filename,
'thumbnail_url' => '/images/' . $image->filename, // TODO: Use proper thumbnail logic
'width' => $image->width,
'height' => $image->height,
'mime_type' => $image->mimeType->value,
'file_size' => $image->fileSize->toBytes(),
'alt_text' => $image->altText ?? '',
];
}, $images);
// Create LiveComponent for image gallery
$imageGallery = new ImageGalleryComponent(
id: ComponentId::fromString('image-gallery:admin'),
dataProviderResolver: $this->dataProviderResolver,
dataProvider: $databaseProvider,
images: [], // Will be loaded by provider in getRenderData()
sortBy: 'created_at',
sortDirection: 'desc',
itemsPerPage: 20,
dataSource: 'database'
);
$viewData = [
'title' => 'Image Management',
'subtitle' => 'Upload, manage and organize your images',
'slots' => [], // TODO: Load actual slots
'images' => $imageData,
'image_gallery' => $imageGallery, // Add LiveComponent to view
'slots' => [], // TODO: Load actual slots for legacy support
];
$finalData = $this->layoutProcessor->processLayoutFromArray($viewData);
@@ -58,4 +61,4 @@ final readonly class ShowImageManager
return new ViewResult('image-manager', $metaData, $finalData);
}
}
}

View File

@@ -9,7 +9,6 @@ use App\Domain\Media\Image;
use App\Domain\Media\ImageRepository;
use App\Domain\Media\ImageResizer;
use App\Domain\Media\ImageVariantRepository;
use App\Domain\Media\SaveImageFile;
use App\Framework\Attributes\Route;
use App\Framework\Auth\Auth;
use App\Framework\Core\PathProvider;
@@ -17,9 +16,8 @@ use App\Framework\Http\Method;
use App\Framework\Http\Request;
use App\Framework\Http\Session\FormIdGenerator;
use App\Framework\Http\UploadedFile;
use App\Framework\Router\Result\ViewResult;
use App\Framework\Router\Result\JsonResult;
use App\Framework\Meta\MetaData;
use App\Framework\Router\Result\ViewResult;
use App\Framework\Ulid\StringConverter;
use App\Framework\Ulid\Ulid;
use App\Framework\View\FormBuilder;
@@ -50,7 +48,7 @@ final readonly class ShowImageUpload
$data = [
'title' => 'Bild-Upload',
'description' => 'Laden Sie neue Bilder in das System hoch.',
'formHtml' => RawHtml::from($formHtml)
'formHtml' => RawHtml::from($formHtml),
];
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
@@ -71,7 +69,7 @@ final readonly class ShowImageUpload
/** @var UploadedFile $file */
$file = $request->files->get('image');
if (!$file || $file->error !== UPLOAD_ERR_OK) {
if (! $file || $file->error !== UPLOAD_ERR_OK) {
return $this->renderUploadError('Keine gültige Datei hochgeladen.');
}
@@ -145,7 +143,7 @@ final readonly class ShowImageUpload
'title' => 'Upload Fehler',
'description' => $message,
'error' => true,
'formHtml' => $this->buildUploadForm()
'formHtml' => $this->buildUploadForm(),
];
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
@@ -163,7 +161,7 @@ final readonly class ShowImageUpload
'title' => $title,
'description' => $message,
'success' => true,
'formHtml' => $this->buildUploadForm()
'formHtml' => $this->buildUploadForm(),
];
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
@@ -183,6 +181,7 @@ final readonly class ShowImageUpload
->addSubmitButton('Upload');
$formHtml = str_replace('<form', '<form enctype="multipart/form-data"', (string) $form);
return RawHtml::from($formHtml);
}
}

View File

@@ -7,7 +7,6 @@ namespace App\Application\Admin;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Framework\Attributes\Route;
use App\Framework\Auth\Auth;
use App\Framework\Http\Method;
use App\Framework\Meta\MetaData;
use App\Framework\Router\Result\ViewResult;
@@ -36,4 +35,4 @@ final readonly class ShowUploadTest
return new ViewResult('upload-test', $metaData, $finalData);
}
}
}

View File

@@ -44,7 +44,8 @@ final readonly class UserAdminController
private AdminFormFactory $formFactory,
private AdminApiHandler $apiHandler,
private UserRepository $repository,
) {}
) {
}
/**
* List all users with table, search, and pagination
@@ -88,7 +89,7 @@ final readonly class UserAdminController
// Fetch data from repository
$users = $this->repository->findAll();
$userData = array_map(
fn($user) => $user->toArray(),
fn ($user) => $user->toArray(),
$users
);

View File

@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace App\Application\Admin\ValueObjects;
use App\Framework\Design\Theme;
use App\Framework\Design\ValueObjects\ThemeMode;
/**
* Admin Theme Value Object
*
* Represents the current theme setting for the admin interface.
* Supports light, dark, and automatic (system preference) modes.
*
* Implements the framework Theme interface for consistent theme management.
*/
final readonly class AdminTheme implements Theme
{
private function __construct(
public ThemeMode $mode
) {
}
public static function light(): self
{
return new self(ThemeMode::LIGHT);
}
public static function dark(): self
{
return new self(ThemeMode::DARK);
}
public static function auto(): self
{
return new self(ThemeMode::AUTO);
}
public function getMode(): ThemeMode
{
return $this->mode;
}
public static function fromString(string $mode): self
{
$normalizedMode = strtolower(trim($mode));
return match ($normalizedMode) {
'light' => self::light(),
'dark' => self::dark(),
'auto', 'system' => self::auto(),
default => throw new \InvalidArgumentException(
"Invalid theme mode: {$mode}. Valid options: light, dark, auto"
),
};
}
/**
* Get data-theme attribute value for HTML
*/
public function toDataAttribute(): string
{
return $this->mode->toDataAttribute();
}
/**
* Check if theme requires JavaScript preference detection
*/
public function requiresPreferenceDetection(): bool
{
return $this->mode->isAuto();
}
/**
* Get CSS class for theme
*/
public function toCssClass(): string
{
return $this->mode->toCssClass('admin-theme');
}
/**
* Get storage key for LocalStorage
*/
public function getStorageKey(): string
{
return 'admin-theme-preference';
}
/**
* Serialize for JSON storage
*/
public function toJson(): string
{
return json_encode([
'mode' => $this->mode->value,
'timestamp' => time(),
], JSON_THROW_ON_ERROR);
}
/**
* Deserialize from JSON
*/
public static function fromJson(string $json): self
{
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
if (!isset($data['mode'])) {
throw new \InvalidArgumentException('Invalid theme JSON: missing mode');
}
return self::fromString($data['mode']);
}
public function equals(self $other): bool
{
return $this->mode === $other->mode;
}
public function toString(): string
{
return $this->mode->value;
}
}

View File

@@ -0,0 +1,348 @@
<?php
declare(strict_types=1);
namespace App\Application\Admin\ValueObjects;
use App\Framework\Design\ValueObjects\CssColor;
use App\Framework\Design\ValueObjects\DesignToken;
use App\Framework\Design\ValueObjects\ColorFormat;
/**
* Admin Token Registry
*
* Central registry for all admin design tokens.
* Uses framework's DesignToken system for compatibility.
*/
final readonly class AdminTokenRegistry
{
/**
* @var array<string, DesignToken>
*/
private array $tokens;
public function __construct()
{
$this->tokens = $this->initializeTokens();
}
/**
* Get token by name
*/
public function get(string $name): ?DesignToken
{
return $this->tokens[$name] ?? null;
}
/**
* Get all tokens
* @return array<string, DesignToken>
*/
public function all(): array
{
return $this->tokens;
}
/**
* Get tokens by category
* @return array<string, DesignToken>
*/
public function byCategory(string $category): array
{
return array_filter(
$this->tokens,
fn(DesignToken $token) => str_starts_with($token->name, $category . '-')
);
}
/**
* Get all color tokens
* @return array<string, DesignToken>
*/
public function colors(): array
{
return array_filter(
$this->tokens,
fn(DesignToken $token) => $token->value instanceof CssColor
);
}
/**
* Get all spacing tokens
* @return array<string, DesignToken>
*/
public function spacing(): array
{
return $this->byCategory('spacing');
}
/**
* Generate CSS custom properties
*/
public function toCss(): string
{
$css = ":root {\n";
foreach ($this->tokens as $token) {
$css .= " " . $token->toCssCustomProperty() . "\n";
}
$css .= "}\n";
return $css;
}
/**
* Generate dark mode CSS overrides
*/
public function toDarkModeCss(): string
{
$css = "@media (prefers-color-scheme: dark) {\n";
$css .= " :root {\n";
foreach ($this->colors() as $token) {
$darkColor = $this->getDarkModeColor($token);
if ($darkColor) {
$css .= " --{$token->name}: {$darkColor->toString()};\n";
}
}
$css .= " }\n";
$css .= "}\n";
return $css;
}
/**
* Initialize all admin design tokens
* @return array<string, DesignToken>
*/
private function initializeTokens(): array
{
return [
// Background Colors
'admin-bg-primary' => DesignToken::color(
'admin-bg-primary',
new CssColor('oklch(98% 0.01 280)', ColorFormat::OKLCH),
'Primary background color'
),
'admin-bg-secondary' => DesignToken::color(
'admin-bg-secondary',
new CssColor('oklch(95% 0.01 280)', ColorFormat::OKLCH),
'Secondary background color'
),
'admin-bg-tertiary' => DesignToken::color(
'admin-bg-tertiary',
new CssColor('oklch(92% 0.01 280)', ColorFormat::OKLCH),
'Tertiary background color'
),
// Sidebar Colors
'admin-sidebar-bg' => DesignToken::color(
'admin-sidebar-bg',
new CssColor('oklch(25% 0.02 280)', ColorFormat::OKLCH),
'Sidebar background color'
),
'admin-sidebar-text' => DesignToken::color(
'admin-sidebar-text',
new CssColor('oklch(90% 0.01 280)', ColorFormat::OKLCH),
'Sidebar text color'
),
'admin-sidebar-text-hover' => DesignToken::color(
'admin-sidebar-text-hover',
new CssColor('oklch(100% 0 0)', ColorFormat::OKLCH),
'Sidebar text hover color'
),
'admin-sidebar-active' => DesignToken::color(
'admin-sidebar-active',
new CssColor('oklch(45% 0.15 280)', ColorFormat::OKLCH),
'Sidebar active item color'
),
'admin-sidebar-border' => DesignToken::color(
'admin-sidebar-border',
new CssColor('oklch(30% 0.02 280)', ColorFormat::OKLCH),
'Sidebar border color'
),
// Header Colors
'admin-header-bg' => DesignToken::color(
'admin-header-bg',
new CssColor('oklch(100% 0 0)', ColorFormat::OKLCH),
'Header background color'
),
'admin-header-border' => DesignToken::color(
'admin-header-border',
new CssColor('oklch(85% 0.01 280)', ColorFormat::OKLCH),
'Header border color'
),
'admin-header-text' => DesignToken::color(
'admin-header-text',
new CssColor('oklch(20% 0.02 280)', ColorFormat::OKLCH),
'Header text color'
),
// Content Colors
'admin-content-bg' => DesignToken::color(
'admin-content-bg',
new CssColor('oklch(100% 0 0)', ColorFormat::OKLCH),
'Content background color'
),
'admin-content-text' => DesignToken::color(
'admin-content-text',
new CssColor('oklch(20% 0.02 280)', ColorFormat::OKLCH),
'Content text color'
),
// Interactive Colors
'admin-link-color' => DesignToken::color(
'admin-link-color',
new CssColor('oklch(55% 0.2 260)', ColorFormat::OKLCH),
'Link color'
),
'admin-link-hover' => DesignToken::color(
'admin-link-hover',
new CssColor('oklch(45% 0.25 260)', ColorFormat::OKLCH),
'Link hover color'
),
'admin-link-active' => DesignToken::color(
'admin-link-active',
new CssColor('oklch(35% 0.3 260)', ColorFormat::OKLCH),
'Link active color'
),
// Accent Colors
'admin-accent-primary' => DesignToken::color(
'admin-accent-primary',
new CssColor('oklch(60% 0.2 280)', ColorFormat::OKLCH),
'Primary accent color'
),
'admin-accent-success' => DesignToken::color(
'admin-accent-success',
new CssColor('oklch(65% 0.2 145)', ColorFormat::OKLCH),
'Success accent color'
),
'admin-accent-warning' => DesignToken::color(
'admin-accent-warning',
new CssColor('oklch(70% 0.2 85)', ColorFormat::OKLCH),
'Warning accent color'
),
'admin-accent-error' => DesignToken::color(
'admin-accent-error',
new CssColor('oklch(60% 0.25 25)', ColorFormat::OKLCH),
'Error accent color'
),
'admin-accent-info' => DesignToken::color(
'admin-accent-info',
new CssColor('oklch(65% 0.2 240)', ColorFormat::OKLCH),
'Info accent color'
),
// Border Colors
'admin-border-light' => DesignToken::color(
'admin-border-light',
new CssColor('oklch(90% 0.01 280)', ColorFormat::OKLCH),
'Light border color'
),
'admin-border-medium' => DesignToken::color(
'admin-border-medium',
new CssColor('oklch(80% 0.02 280)', ColorFormat::OKLCH),
'Medium border color'
),
'admin-border-dark' => DesignToken::color(
'admin-border-dark',
new CssColor('oklch(70% 0.02 280)', ColorFormat::OKLCH),
'Dark border color'
),
// Focus/Hover States
'admin-focus-ring' => DesignToken::color(
'admin-focus-ring',
new CssColor('oklch(55% 0.2 260)', ColorFormat::OKLCH),
'Focus ring color'
),
'admin-hover-overlay' => DesignToken::color(
'admin-hover-overlay',
new CssColor('oklch(0% 0 0 / 0.05)', ColorFormat::OKLCH),
'Hover overlay color'
),
// Spacing Tokens
'spacing-xs' => DesignToken::spacing('spacing-xs', '0.25rem', 'Extra small spacing'),
'spacing-sm' => DesignToken::spacing('spacing-sm', '0.5rem', 'Small spacing'),
'spacing-md' => DesignToken::spacing('spacing-md', '1rem', 'Medium spacing'),
'spacing-lg' => DesignToken::spacing('spacing-lg', '1.5rem', 'Large spacing'),
'spacing-xl' => DesignToken::spacing('spacing-xl', '2rem', 'Extra large spacing'),
'spacing-2xl' => DesignToken::spacing('spacing-2xl', '3rem', 'Double extra large spacing'),
// Layout Spacing
'spacing-sidebar' => DesignToken::spacing('spacing-sidebar', '250px', 'Sidebar width'),
'spacing-sidebar-wide' => DesignToken::spacing('spacing-sidebar-wide', '280px', 'Sidebar width on wide screens'),
'spacing-header' => DesignToken::spacing('spacing-header', '4rem', 'Header height'),
'spacing-content-padding' => DesignToken::spacing('spacing-content-padding', '2rem', 'Content padding'),
'spacing-content-max-width' => DesignToken::spacing('spacing-content-max-width', '1400px', 'Content maximum width'),
// Typography Tokens
'font-size-xs' => DesignToken::typography('font-size-xs', '0.75rem', 'Extra small font size'),
'font-size-sm' => DesignToken::typography('font-size-sm', '0.875rem', 'Small font size'),
'font-size-base' => DesignToken::typography('font-size-base', '1rem', 'Base font size'),
'font-size-lg' => DesignToken::typography('font-size-lg', '1.125rem', 'Large font size'),
'font-size-xl' => DesignToken::typography('font-size-xl', '1.25rem', 'Extra large font size'),
'font-size-2xl' => DesignToken::typography('font-size-2xl', '1.5rem', 'Double extra large font size'),
'font-size-3xl' => DesignToken::typography('font-size-3xl', '1.875rem', 'Triple extra large font size'),
];
}
/**
* Get dark mode variant of a color
*/
private function getDarkModeColor(DesignToken $token): ?CssColor
{
if (!($token->value instanceof CssColor)) {
return null;
}
// Dark mode color mappings
$darkModeMap = [
// Backgrounds
'admin-bg-primary' => 'oklch(20% 0.02 280)',
'admin-bg-secondary' => 'oklch(23% 0.02 280)',
'admin-bg-tertiary' => 'oklch(26% 0.02 280)',
// Sidebar
'admin-sidebar-bg' => 'oklch(15% 0.02 280)',
'admin-sidebar-text' => 'oklch(75% 0.02 280)',
'admin-sidebar-text-hover' => 'oklch(95% 0.01 280)',
'admin-sidebar-active' => 'oklch(35% 0.2 280)',
'admin-sidebar-border' => 'oklch(25% 0.02 280)',
// Header
'admin-header-bg' => 'oklch(18% 0.02 280)',
'admin-header-border' => 'oklch(30% 0.02 280)',
'admin-header-text' => 'oklch(90% 0.01 280)',
// Content
'admin-content-bg' => 'oklch(20% 0.02 280)',
'admin-content-text' => 'oklch(90% 0.01 280)',
// Interactive
'admin-link-color' => 'oklch(70% 0.2 260)',
'admin-link-hover' => 'oklch(80% 0.22 260)',
'admin-link-active' => 'oklch(85% 0.25 260)',
// Borders
'admin-border-light' => 'oklch(30% 0.02 280)',
'admin-border-medium' => 'oklch(35% 0.02 280)',
'admin-border-dark' => 'oklch(40% 0.02 280)',
// Focus
'admin-focus-ring' => 'oklch(70% 0.2 260)',
'admin-hover-overlay' => 'oklch(100% 0 0 / 0.05)',
];
if (isset($darkModeMap[$token->name])) {
return new CssColor($darkModeMap[$token->name], ColorFormat::OKLCH);
}
return null;
}
}

View File

@@ -1,4 +1,4 @@
<layout name="layouts/admin" />
<layout name="admin" />
<div class="admin-page">
<div class="page-header">

View File

@@ -1,4 +1,4 @@
<layout name="layouts/admin" />
<layout name="admin" />
<div class="admin-page">
<div class="page-header">

View File

@@ -0,0 +1,152 @@
<!doctype html>
<html lang="de" data-theme="{theme ?? 'auto'}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>{title} | Admin Panel</title>
<meta name="description" content="{description}">
<meta property="og:type" content="website">
<!-- Theme Meta -->
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#1a1a1a" media="(prefers-color-scheme: dark)">
<!-- Admin CSS (ITCSS Architecture) - Vite Built -->
<link rel="stylesheet" href="/assets/css/admin-U1y6JHpV.css">
<!-- Main Application Assets -->
<link rel="stylesheet" href="/assets/css/main-DN7LWXEn.css">
<script src="/assets/js/main-DReViZUb.js" type="module"></script>
</head>
<body>
<!-- Skip to main content (Accessibility) -->
<a href="#main-content" class="admin-skip-link">Skip to main content</a>
<!-- Admin Layout Grid -->
<div class="admin-layout">
<!-- Sidebar Navigation -->
<x-admin-sidebar currentPath="{current_path}" />
<!-- Header with Search & User Menu -->
<x-admin-header pageTitle="{page_title}" />
<!-- Main Content Area -->
<main class="admin-content" id="main-content" role="main">
{content}
</main>
</div>
<!-- Admin JavaScript (Mobile Menu, Theme Toggle, Dropdowns) -->
<script>
// Mobile Menu Toggle
document.addEventListener('DOMContentLoaded', () => {
const mobileToggle = document.querySelector('[data-mobile-menu-toggle]');
const sidebar = document.querySelector('.admin-sidebar');
const overlay = document.querySelector('[data-mobile-menu-overlay]');
if (mobileToggle && sidebar && overlay) {
const toggleMenu = () => {
const isOpen = sidebar.dataset.mobileMenuOpen === 'true';
sidebar.dataset.mobileMenuOpen = !isOpen;
overlay.dataset.mobileMenuOpen = !isOpen;
mobileToggle.setAttribute('aria-expanded', !isOpen);
};
mobileToggle.addEventListener('click', toggleMenu);
overlay.addEventListener('click', toggleMenu);
}
// Theme Toggle with Dark Mode Detection
const themeToggle = document.querySelector('[data-theme-toggle]');
const htmlElement = document.documentElement;
const storageKey = 'admin-theme-preference';
if (themeToggle) {
// Detect system preference
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)');
// Initialize theme
const initTheme = () => {
const savedTheme = localStorage.getItem(storageKey) || 'auto';
if (savedTheme === 'auto') {
// Use system preference when auto
htmlElement.dataset.theme = prefersDark.matches ? 'dark' : 'light';
} else {
// Use saved preference
htmlElement.dataset.theme = savedTheme;
}
// Update icon visibility
updateThemeIcon(savedTheme === 'auto' ? (prefersDark.matches ? 'dark' : 'light') : savedTheme);
};
// Update theme icon
const updateThemeIcon = (theme) => {
document.querySelectorAll('[data-theme-icon]').forEach(icon => {
icon.style.display = icon.dataset.themeIcon === theme ? 'block' : 'none';
});
};
// Listen for system theme changes
prefersDark.addEventListener('change', (e) => {
const savedTheme = localStorage.getItem(storageKey);
if (!savedTheme || savedTheme === 'auto') {
htmlElement.dataset.theme = e.matches ? 'dark' : 'light';
updateThemeIcon(e.matches ? 'dark' : 'light');
}
});
// Theme toggle click handler
themeToggle.addEventListener('click', () => {
const currentTheme = localStorage.getItem(storageKey) || 'auto';
const nextTheme = currentTheme === 'light' ? 'dark' :
currentTheme === 'dark' ? 'auto' : 'light';
localStorage.setItem(storageKey, nextTheme);
if (nextTheme === 'auto') {
htmlElement.dataset.theme = prefersDark.matches ? 'dark' : 'light';
updateThemeIcon(prefersDark.matches ? 'dark' : 'light');
} else {
htmlElement.dataset.theme = nextTheme;
updateThemeIcon(nextTheme);
}
});
// Initialize on load
initTheme();
}
// Dropdown Menus
document.querySelectorAll('[data-dropdown-trigger]').forEach(trigger => {
const dropdownId = trigger.dataset.dropdownTrigger;
const dropdown = trigger.closest('[data-dropdown]');
trigger.addEventListener('click', (e) => {
e.stopPropagation();
const isOpen = dropdown.dataset.open === 'true';
// Close all other dropdowns
document.querySelectorAll('[data-dropdown]').forEach(dd => {
if (dd !== dropdown) {
dd.dataset.open = 'false';
}
});
dropdown.dataset.open = !isOpen;
});
});
// Close dropdowns on outside click
document.addEventListener('click', () => {
document.querySelectorAll('[data-dropdown]').forEach(dropdown => {
dropdown.dataset.open = 'false';
});
});
});
</script>
</body>
</html>

View File

@@ -1,4 +1,4 @@
<layout name="layouts/admin" />
<layout name="admin" />
<div class="section">
<h2>Analytics Dashboard</h2>

View File

@@ -1,4 +1,4 @@
<layout name="layouts/admin" />
<layout name="admin" />
<div class="section">
<h2>Cache Metrics</h2>

View File

@@ -0,0 +1,42 @@
<!-- Admin Breadcrumbs Component -->
<!-- Uses Framework LinkCollection Value Object -->
<nav class="admin-breadcrumbs" aria-label="Breadcrumb">
<ol class="admin-breadcrumbs__list" role="list">
<!-- Home Icon Link -->
<li class="admin-breadcrumbs__item">
<a href="/admin" class="admin-breadcrumbs__link" aria-label="Home">
<svg class="admin-breadcrumbs__home-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<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"/>
</svg>
<span class="admin-breadcrumbs__sr-only">Home</span>
</a>
</li>
<!-- LinkCollection Items -->
<if condition="breadcrumbs && breadcrumbs.count() > 0">
<for items="breadcrumbs" as="link" index="index">
<!-- Separator -->
<li class="admin-breadcrumbs__separator" aria-hidden="true">
<svg class="admin-breadcrumbs__separator-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</li>
<!-- Breadcrumb Item -->
<li class="admin-breadcrumbs__item">
<!-- Check if current page (AccessibleLink with aria-current) -->
<if condition="link.hasAttribute('aria-current')">
<span class="admin-breadcrumbs__current" aria-current="page">
{link.text}
</span>
<else>
<a href="{link.href}" class="admin-breadcrumbs__link">
{link.text}
</a>
</else>
</if>
</li>
</for>
</if>
</ol>
</nav>

View File

@@ -0,0 +1,118 @@
<!-- Admin Header Component -->
<header class="admin-header" role="banner">
<!-- Page Title (hidden on mobile, breadcrumbs shown instead) -->
<h1 class="admin-header__title">{page_title}</h1>
<!-- Breadcrumbs (visible on mobile) -->
<if condition="breadcrumbs">
<include template="components/breadcrumbs" data="{breadcrumbs: breadcrumbs}" />
</if>
<!-- Search Bar -->
<div class="admin-header__search">
<form class="admin-search" role="search" action="/admin/search" method="get">
<svg class="admin-search__icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<input
type="search"
name="q"
class="admin-search__input"
placeholder="Search..."
aria-label="Search"
autocomplete="off"
/>
</form>
</div>
<!-- Header Actions -->
<div class="admin-header__actions">
<!-- Notifications -->
<button
type="button"
class="admin-action-btn"
aria-label="Notifications"
data-dropdown-trigger="notifications"
>
<svg class="admin-action-btn__icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<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"/>
</svg>
<if condition="notification_count > 0">
<span class="admin-action-btn__badge admin-action-btn__badge--count" aria-label="{notification_count} unread notifications">
{notification_count}
</span>
</if>
</button>
<!-- Theme Toggle -->
<button
type="button"
class="admin-theme-toggle"
aria-label="Toggle theme"
data-theme-toggle
>
<svg class="admin-theme-toggle__icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true" data-theme-icon="light">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/>
</svg>
<svg class="admin-theme-toggle__icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true" data-theme-icon="dark" style="display: none;">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
</svg>
<span class="admin-theme-toggle__label">Theme</span>
</button>
<!-- User Menu -->
<div class="admin-user-menu" data-dropdown="user-menu">
<button
type="button"
class="admin-user-menu__trigger"
aria-label="User menu"
aria-expanded="false"
aria-haspopup="true"
data-dropdown-trigger="user-menu"
>
<img
src="{user.avatar ?? '/assets/default-avatar.png'}"
alt="{user.name}"
class="admin-user-menu__avatar"
width="32"
height="32"
/>
<span class="admin-user-menu__name">{user.name}</span>
<svg class="admin-user-menu__chevron" 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>
</button>
<ul class="admin-user-menu__dropdown" role="menu" aria-labelledby="user-menu" data-dropdown-content="user-menu">
<li class="admin-user-menu__item" role="none">
<a href="/admin/profile" class="admin-user-menu__link" role="menuitem">
<svg class="admin-user-menu__icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
<span>Profile</span>
</a>
</li>
<li class="admin-user-menu__item" role="none">
<a href="/admin/settings" class="admin-user-menu__link" role="menuitem">
<svg class="admin-user-menu__icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
<span>Settings</span>
</a>
</li>
<li role="none">
<hr class="admin-user-menu__divider" />
</li>
<li class="admin-user-menu__item" role="none">
<a href="/logout" class="admin-user-menu__link" role="menuitem">
<svg class="admin-user-menu__icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
</svg>
<span>Logout</span>
</a>
</li>
</ul>
</div>
</div>
</header>

View File

@@ -0,0 +1,108 @@
<!-- Admin Sidebar Component -->
<aside class="admin-sidebar" role="navigation" aria-label="Main navigation">
<!-- Sidebar Header -->
<div class="admin-sidebar__header">
<svg class="admin-sidebar__logo" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<rect width="40" height="40" rx="8" fill="currentColor" opacity="0.1"/>
<path d="M20 10L28 16V24L20 30L12 24V16L20 10Z" fill="currentColor" opacity="0.8"/>
</svg>
<h1 class="admin-sidebar__title">Admin</h1>
</div>
<!-- Main Navigation -->
<nav class="admin-nav" aria-label="Primary">
<!-- Dashboard Section -->
<div class="admin-nav__section">
<ul class="admin-nav__list" role="list">
<li class="admin-nav__item">
<a href="/admin" class="admin-nav__link" aria-current="{current_path === '/admin' ? 'page' : null}">
<svg class="admin-nav__icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<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"/>
</svg>
<span>Dashboard</span>
</a>
</li>
</ul>
</div>
<!-- Content Section -->
<div class="admin-nav__section">
<h2 class="admin-nav__section-title">Content</h2>
<ul class="admin-nav__list" role="list">
<li class="admin-nav__item">
<a href="/admin/pages" class="admin-nav__link" aria-current="{current_path === '/admin/pages' ? 'page' : null}">
<svg class="admin-nav__icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<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"/>
</svg>
<span>Pages</span>
</a>
</li>
<li class="admin-nav__item">
<a href="/admin/media" class="admin-nav__link" aria-current="{current_path === '/admin/media' ? 'page' : null}">
<svg class="admin-nav__icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<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"/>
</svg>
<span>Media</span>
</a>
</li>
</ul>
</div>
<!-- System Section -->
<div class="admin-nav__section">
<h2 class="admin-nav__section-title">System</h2>
<ul class="admin-nav__list" role="list">
<li class="admin-nav__item">
<a href="/admin/users" class="admin-nav__link" aria-current="{current_path === '/admin/users' ? 'page' : null}">
<svg class="admin-nav__icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
</svg>
<span>Users</span>
</a>
</li>
<li class="admin-nav__item">
<a href="/admin/settings" class="admin-nav__link" aria-current="{current_path === '/admin/settings' ? 'page' : null}">
<svg class="admin-nav__icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
<span>Settings</span>
</a>
</li>
</ul>
</div>
</nav>
<!-- Sidebar Footer -->
<div class="admin-sidebar__footer">
<a href="/admin/profile" class="admin-sidebar__user">
<img
src="{user.avatar ?? '/assets/default-avatar.png'}"
alt="{user.name}"
class="admin-sidebar__avatar"
width="32"
height="32"
/>
<div class="admin-sidebar__user-info">
<span class="admin-sidebar__user-name">{user.name}</span>
<span class="admin-sidebar__user-role">{user.role}</span>
</div>
</a>
</div>
</aside>
<!-- Mobile Menu Toggle -->
<button
type="button"
class="admin-sidebar__mobile-toggle"
aria-label="Toggle navigation menu"
aria-expanded="false"
data-mobile-menu-toggle
>
<svg class="admin-sidebar__toggle-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
</button>
<!-- Mobile Overlay -->
<div class="admin-mobile-overlay" data-mobile-menu-overlay aria-hidden="true"></div>

View File

@@ -1,51 +1,140 @@
<layout name="layouts/admin" />
<layout name="admin" />
<div class="section">
<h2>Admin Dashboard</h2>
<div class="stats-grid">
<div class="stat-card">
<h3>System Status</h3>
<p><strong>Status:</strong> <span style="color: var(--success);">Online</span></p>
<p><strong>Uptime:</strong> {{ uptime_formatted }}</p>
<p><strong>Version:</strong> {{ framework_version }}</p>
<!-- Breadcrumbs -->
<x-breadcrumbs items='[{"url": "/admin", "text": "Dashboard"}]' />
<!-- Dashboard Content -->
<div class="admin-content__header admin-content__header--with-actions">
<div class="admin-content__title-group">
<h1 class="admin-content__title">Admin Dashboard</h1>
<p class="admin-content__description">System overview and quick actions</p>
</div>
</div>
<!-- System Stats Grid -->
<div class="admin-grid admin-grid--3-col">
<div class="admin-card">
<div class="admin-card__header">
<h3 class="admin-card__title">System Status</h3>
</div>
<div class="stat-card">
<h3>Performance</h3>
<p><strong>Memory Usage:</strong> {{ memory_usage_formatted }}</p>
<p><strong>Peak Memory:</strong> {{ peak_memory_formatted }}</p>
<p><strong>Load Average:</strong> {{ load_average }}</p>
</div>
<div class="stat-card">
<h3>Database</h3>
<p><strong>Connection:</strong> <span style="color: var(--success);">Connected</span></p>
<p><strong>Pool Size:</strong> {{ db_pool_size }}</p>
<p><strong>Active Connections:</strong> {{ db_active_connections }}</p>
<div class="admin-card__content">
<div class="admin-stat-list">
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Status</span>
<span class="admin-badge admin-badge--success">Online</span>
</div>
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Uptime</span>
<span class="admin-stat-list__value">{{ uptime_formatted }}</span>
</div>
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Version</span>
<span class="admin-stat-list__value">{{ $framework_version }}</span>
</div>
</div>
</div>
</div>
<div class="stats-grid">
<div class="stat-card">
<h3>Cache Performance</h3>
<p><strong>Hit Rate:</strong> {{ cache_hit_rate }}</p>
<p><strong>Total Operations:</strong> {{ cache_total_operations }}</p>
<p><strong>Status:</strong> <span style="color: var(--success);">Running</span></p>
<div class="admin-card">
<div class="admin-card__header">
<h3 class="admin-card__title">Performance</h3>
</div>
<div class="stat-card">
<h3>Recent Activity</h3>
<p><strong>Requests Today:</strong> {{ requests_today }}</p>
<p><strong>Errors:</strong> {{ errors_today }}</p>
<p><strong>Last Deployment:</strong> {{ last_deployment }}</p>
<div class="admin-card__content">
<div class="admin-stat-list">
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Memory Usage</span>
<span class="admin-stat-list__value">{{ memory_usage_formatted }}</span>
</div>
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Peak Memory</span>
<span class="admin-stat-list__value">{{ peak_memory_formatted }}</span>
</div>
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Load Average</span>
<span class="admin-stat-list__value">{{ load_average }}</span>
</div>
</div>
</div>
</div>
<div class="stat-card">
<h3>Quick Actions</h3>
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
<a href="{{ clear_cache_url }}" style="background: var(--primary); color: white; padding: 8px 16px; border-radius: 4px; text-decoration: none; font-size: 14px;">Clear Cache</a>
<a href="{{ logs_url }}" style="background: var(--secondary); color: white; padding: 8px 16px; border-radius: 4px; text-decoration: none; font-size: 14px;">View Logs</a>
<a href="{{ migrations_url }}" style="background: var(--accent); color: white; padding: 8px 16px; border-radius: 4px; text-decoration: none; font-size: 14px;">Migrations</a>
<div class="admin-card">
<div class="admin-card__header">
<h3 class="admin-card__title">Database</h3>
</div>
<div class="admin-card__content">
<div class="admin-stat-list">
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Connection</span>
<span class="admin-badge admin-badge--success">Connected</span>
</div>
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Pool Size</span>
<span class="admin-stat-list__value">{{ db_pool_size }}</span>
</div>
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Active Connections</span>
<span class="admin-stat-list__value">{{ db_active_connections }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Additional Stats Grid -->
<div class="admin-grid admin-grid--3-col" style="margin-top: var(--admin-spacing-xl);">
<div class="admin-card">
<div class="admin-card__header">
<h3 class="admin-card__title">Cache Performance</h3>
</div>
<div class="admin-card__content">
<div class="admin-stat-list">
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Hit Rate</span>
<span class="admin-stat-list__value">{{ cache_hit_rate }}</span>
</div>
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Total Operations</span>
<span class="admin-stat-list__value">{{ cache_total_operations }}</span>
</div>
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Status</span>
<span class="admin-badge admin-badge--success">Running</span>
</div>
</div>
</div>
</div>
<div class="admin-card">
<div class="admin-card__header">
<h3 class="admin-card__title">Recent Activity</h3>
</div>
<div class="admin-card__content">
<div class="admin-stat-list">
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Requests Today</span>
<span class="admin-stat-list__value">{{ requests_today }}</span>
</div>
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Errors</span>
<span class="admin-stat-list__value">{{ errors_today }}</span>
</div>
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Last Deployment</span>
<span class="admin-stat-list__value">{{ last_deployment }}</span>
</div>
</div>
</div>
</div>
<div class="admin-card">
<div class="admin-card__header">
<h3 class="admin-card__title">Quick Actions</h3>
</div>
<div class="admin-card__content">
<div class="admin-cluster">
<x-a href="{{ clear_cache_url }}" class="admin-btn admin-btn--primary">Clear Cache</x-a>
<x-a href="{{ logs_url }}" class="admin-btn admin-btn--secondary">View Logs</x-a>
<x-a href="{{ migrations_url }}" class="admin-btn admin-btn--accent">Migrations</x-a>
</div>
</div>
</div>

View File

@@ -0,0 +1,218 @@
<layout name="admin" />
<!-- Breadcrumbs -->
<x-breadcrumbs items='[{"url": "/admin", "text": "Dashboard"}, {"url": "/admin/deployment/dashboard", "text": "Deployments"}]' />
<!-- Dashboard Header -->
<div class="admin-content__header admin-content__header--with-actions">
<div class="admin-content__title-group">
<h1 class="admin-content__title">{{ $pageTitle }}</h1>
<p class="admin-content__description">{{ $pageDescription }}</p>
</div>
</div>
<!-- Overall Statistics Grid -->
<div class="admin-grid admin-grid--4-col">
<div class="admin-card">
<div class="admin-card__header">
<h3 class="admin-card__title">Total Deployments</h3>
</div>
<div class="admin-card__content">
<div class="admin-stat-big">
<span class="admin-stat-big__value">{{ $totalDeployments }}</span>
<span class="admin-stat-big__label">All Time</span>
</div>
</div>
</div>
<div class="admin-card">
<div class="admin-card__header">
<h3 class="admin-card__title">Success Rate</h3>
</div>
<div class="admin-card__content">
<div class="admin-stat-big">
<span class="admin-stat-big__value">{{ $successRate }}%</span>
<span class="admin-stat-big__label">Success</span>
</div>
</div>
</div>
<div class="admin-card">
<div class="admin-card__header">
<h3 class="admin-card__title">Failed Deployments</h3>
</div>
<div class="admin-card__content">
<div class="admin-stat-big">
<span class="admin-stat-big__value">{{ $failedDeployments }}</span>
<span class="admin-stat-big__label">Failures</span>
</div>
</div>
</div>
<div class="admin-card">
<div class="admin-card__header">
<h3 class="admin-card__title">Average Duration</h3>
</div>
<div class="admin-card__content">
<div class="admin-stat-big">
<span class="admin-stat-big__value">{{ $averageDurationFormatted }}</span>
<span class="admin-stat-big__label">Avg Time</span>
</div>
</div>
</div>
</div>
<!-- Environment-Specific Statistics -->
<div class="admin-grid admin-grid--2-col" style="margin-top: var(--admin-spacing-xl);">
<div class="admin-card">
<div class="admin-card__header">
<h3 class="admin-card__title">Production Environment</h3>
</div>
<div class="admin-card__content">
<div class="admin-stat-list">
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Total Deployments</span>
<span class="admin-stat-list__value">{{ $productionStats['total_deployments'] }}</span>
</div>
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Success Rate</span>
<span class="admin-stat-list__value">{{ $productionStats['success_rate'] }}%</span>
</div>
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Failed</span>
<span class="admin-stat-list__value">{{ $productionStats['failed'] }}</span>
</div>
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Rolled Back</span>
<span class="admin-stat-list__value">{{ $productionStats['rolled_back'] }}</span>
</div>
</div>
</div>
</div>
<div class="admin-card">
<div class="admin-card__header">
<h3 class="admin-card__title">Staging Environment</h3>
</div>
<div class="admin-card__content">
<div class="admin-stat-list">
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Total Deployments</span>
<span class="admin-stat-list__value">{{ $stagingStats['total_deployments'] }}</span>
</div>
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Success Rate</span>
<span class="admin-stat-list__value">{{ $stagingStats['success_rate'] }}%</span>
</div>
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Failed</span>
<span class="admin-stat-list__value">{{ $stagingStats['failed'] }}</span>
</div>
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Rolled Back</span>
<span class="admin-stat-list__value">{{ $stagingStats['rolled_back'] }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Recent Deployments Table -->
<div class="admin-card" style="margin-top: var(--admin-spacing-xl);">
<div class="admin-card__header">
<h3 class="admin-card__title">Recent Deployments</h3>
</div>
<div class="admin-card__content">
<div if="count($recentDeployments) > 0">
<table class="admin-table">
<thead>
<tr>
<th>Pipeline ID</th>
<th>Environment</th>
<th>Status</th>
<th>Duration</th>
<th>Stages</th>
<th>Success Rate</th>
<th>Started At</th>
<th>Completed At</th>
</tr>
</thead>
<tbody>
<for items="recentDeployments" as="deployment">
<tr>
<td><code>{{ $deployment['pipelineId'] }}</code></td>
<td>
<span class="admin-badge admin-badge--neutral">
{{ $deployment['environment'] }}
</span>
</td>
<td>
<span class="admin-badge {{ $deployment['statusBadgeClass'] }}">
{{ $deployment['status'] }}
</span>
<span if="$deployment['wasRolledBack']" class="admin-badge admin-badge--warning">Rolled Back</span>
</td>
<td>{{ $deployment['durationFormatted'] }}</td>
<td>{{ $deployment['stageCount'] }}</td>
<td>{{ $deployment['successRate'] }}%</td>
<td>{{ $deployment['startedAt'] }}</td>
<td>{{ $deployment['completedAt'] }}</td>
</tr>
</for>
</tbody>
</table>
</div>
<div if="count($recentDeployments) === 0">
<div class="admin-empty-state">
<p class="admin-empty-state__text">No deployments found</p>
</div>
</div>
</div>
</div>
<!-- Failed Deployments Section -->
<div if="count($failedDeployments) > 0" class="admin-card" style="margin-top: var(--admin-spacing-xl);">
<div class="admin-card__header">
<h3 class="admin-card__title">Recent Failed Deployments</h3>
</div>
<div class="admin-card__content">
<table class="admin-table">
<thead>
<tr>
<th>Pipeline ID</th>
<th>Environment</th>
<th>Failed Stage</th>
<th>Error</th>
<th>Duration</th>
<th>Started At</th>
</tr>
</thead>
<tbody>
<for items="failedDeployments" as="deployment">
<tr>
<td><code>{{ $deployment['pipelineId'] }}</code></td>
<td>
<span class="admin-badge admin-badge--neutral">
{{ $deployment['environment'] }}
</span>
</td>
<td>
<span if="$deployment['failedStage'] !== null" class="admin-badge admin-badge--danger">
{{ $deployment['failedStage'] }}
</span>
<span if="$deployment['failedStage'] === null" class="admin-badge admin-badge--neutral">N/A</span>
</td>
<td>
<span if="$deployment['error'] !== null" class="admin-text-truncate" title="{{ $deployment['error'] }}">
{{ $deployment['error'] }}
</span>
<span if="$deployment['error'] === null" class="admin-text-muted">No error message</span>
</td>
<td>{{ $deployment['durationFormatted'] }}</td>
<td>{{ $deployment['startedAt'] }}</td>
</tr>
</for>
</tbody>
</table>
</div>
</div>

View File

@@ -1,4 +1,4 @@
<layout name="layouts/admin" />
<layout name="admin" />
<div class="section">
<h2>Umgebungsvariablen</h2>

View File

@@ -1,4 +1,4 @@
<layout name="layouts/admin" />
<layout name="admin" />
<!-- Cache invalidation: 2025-01-20 16:11 -->
<div class="section">
<h2>{{ title }}</h2>

View File

@@ -1,4 +1,4 @@
<layout name="layouts/admin" />
<layout name="admin" />
<div class="section">
<div class="page-header-actions">
@@ -49,29 +49,15 @@
</div>
</div>
<!-- Image Gallery Section -->
<!-- Image Gallery Section - Now using LiveComponent -->
<div class="stat-card full-width">
<h3>Image Gallery</h3>
<h3>Image Gallery (LiveComponent)</h3>
<p style="color: var(--gray-600); margin-bottom: 20px;">
Real-time image gallery with server-side filtering, sorting, and actions
</p>
<div data-module="image-manager"
data-image-gallery
data-list-endpoint="/api/images"
data-upload-endpoint="/api/images"
data-page-size="20"
data-columns="4"
data-pagination
data-search
data-sort
data-allow-delete
data-allow-edit
data-selectable>
<!-- Gallery will be rendered here by JavaScript -->
<div class="gallery-loading">
<div class="loading-spinner"></div>
<p>Loading images...</p>
</div>
</div>
<!-- LiveComponent replaces JavaScript-based gallery -->
{{image_gallery}}
</div>
<!-- Legacy Image Slots Section (for backward compatibility) -->

View File

@@ -1,4 +1,4 @@
<layout name="layouts/admin" />
<layout name="admin" />
<div class="section">
<h2>{{ title }}</h2>

View File

@@ -0,0 +1,68 @@
<layout name="admin" />
<!-- Breadcrumbs -->
<x-breadcrumbs items='[{"url": "/admin", "text": "Admin"}, {"url": "/admin/jobs/dashboard", "text": "Job Dashboard"}]' />
<!-- Dashboard Header -->
<div class="admin-content__header admin-content__header--with-actions">
<div class="admin-content__title-group">
<h1 class="admin-content__title">Background Jobs Dashboard</h1>
<p class="admin-content__description">Real-time monitoring of queue, workers, and scheduler</p>
</div>
</div>
<!-- Dashboard Grid - Top Row: Queue Stats & Worker Health -->
<div class="admin-grid admin-grid--2-col">
<!-- Queue Statistics Card -->
<div class="admin-card">
<div class="admin-card__header">
<h3 class="admin-card__title">Queue Statistics</h3>
<span class="admin-badge admin-badge--info">Live</span>
</div>
<div class="admin-card__content">
<x-queue-stats />
</div>
</div>
<!-- Worker Health Card -->
<div class="admin-card">
<div class="admin-card__header">
<h3 class="admin-card__title">Worker Health</h3>
<span class="admin-badge admin-badge--info">Live</span>
</div>
<div class="admin-card__content">
<x-worker-health />
</div>
</div>
</div>
<!-- Dashboard Grid - Middle Row: Scheduler Timeline -->
<div class="admin-grid admin-grid--1-col">
<div class="admin-card">
<div class="admin-card__header">
<h3 class="admin-card__title">Scheduled Tasks Timeline</h3>
<span class="admin-badge admin-badge--info">Live</span>
</div>
<div class="admin-card__content">
<x-scheduler-timeline />
</div>
</div>
</div>
<!-- Dashboard Grid - Bottom Row: Failed Jobs -->
<div class="admin-grid admin-grid--1-col">
<div class="admin-card">
<div class="admin-card__header">
<h3 class="admin-card__title">Failed Jobs</h3>
<span class="admin-badge admin-badge--warning">Needs Attention</span>
</div>
<div class="admin-card__content">
<x-failed-jobs-list />
</div>
</div>
</div>
<!-- Dashboard Info Footer -->
<div class="admin-info-box admin-info-box--info">
<strong>📊 Live Dashboard</strong> - All components auto-update in real-time. Queue Stats and Worker Health refresh every 5 seconds, Failed Jobs every 10 seconds, and Scheduler Timeline every 30 seconds.
</div>

View File

@@ -1,142 +0,0 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>{title} | Admin Panel</title>
<meta name="description" content="{description}">
<meta property="og:type" content="website">
<link rel='stylesheet' href='/css/admin.css'>
<link rel="stylesheet" href="/assets/css/main-DLVw97vA.css">
<script src="/assets/js/main-CyVTPjIx.js" type="module"></script>
<style>
.admin-layout {
display: grid;
grid-template-columns: 250px 1fr;
grid-template-rows: auto 1fr;
min-height: 100vh;
grid-template-areas:
"sidebar header"
"sidebar content";
}
.admin-header {
grid-area: header;
background: #f8f9fa;
border-bottom: 1px solid #dee2e6;
padding: 1rem 2rem;
}
.admin-sidebar {
grid-area: sidebar;
background: #343a40;
color: white;
padding: 1rem;
}
.admin-content {
grid-area: content;
padding: 2rem;
overflow-y: auto;
}
.breadcrumbs {
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 1rem;
font-size: 0.875rem;
color: #6c757d;
}
.breadcrumbs a {
color: #007bff;
text-decoration: none;
}
.breadcrumbs a:hover {
text-decoration: underline;
}
.nav-section {
margin-bottom: 2rem;
}
.nav-section h3 {
color: #adb5bd;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
font-weight: 600;
}
.nav-items {
list-style: none;
padding: 0;
margin: 0;
}
.nav-items li {
margin-bottom: 0.25rem;
}
.nav-items a {
display: block;
color: #dee2e6;
text-decoration: none;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
transition: background-color 0.15s;
}
.nav-items a:hover {
background-color: #495057;
color: white;
}
.nav-items a.active {
background-color: #007bff;
color: white;
}
.logo {
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid #495057;
}
.logo h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
</style>
</head>
<body>
<div class="admin-layout">
<aside class="admin-sidebar">
<div class="logo">
<h2>Admin Panel</h2>
</div>
{navigation}
</aside>
<header class="admin-header">
<nav class="breadcrumbs">
{breadcrumbs}
</nav>
<h1>{page_title}</h1>
</header>
<main class="admin-content">
{content}
</main>
</div>
</body>
</html>

View File

@@ -1,4 +1,4 @@
<layout name="layouts/admin" />
<layout name="admin" />
<div class="section">
<h2>📄 {{ title }}</h2>
@@ -324,17 +324,21 @@ class LogViewer {
Object.values(logs).forEach(log => {
const li = document.createElement('li');
li.className = 'log-item';
// Extract log name - handle both string and object (VO)
const logName = typeof log.name === 'object' ? log.name.value : log.name;
li.innerHTML = `
<div class="log-name">${log.name}</div>
<div class="log-name">${logName}</div>
<div class="log-info">
${log.size_human} • ${log.modified_human}
${log.size} • ${log.modified}
${!log.readable ? ' • ⚠️ Not readable' : ''}
</div>
`;
if (log.readable) {
li.addEventListener('click', () => {
this.selectLog(log.name, li);
this.selectLog(logName, li);
});
} else {
li.style.opacity = '0.5';

View File

@@ -1,4 +1,4 @@
<layout name="layouts/admin" />
<layout name="admin" />
<div class="section">
<h2>{{ title }}</h2>

View File

@@ -1,4 +1,4 @@
<layout name="layouts/admin" />
<layout name="admin" />
<div class="section">
<h2>Performance Übersicht</h2>

View File

@@ -1,4 +1,4 @@
<layout name="layouts/admin" />
<layout name="admin" />
<div class="section">
<h2>{{ title }}</h2>

View File

@@ -1,4 +1,4 @@
<layout name="layouts/admin" />
<layout name="admin" />
<div class="section">
<h2>{{ title }}</h2>

View File

@@ -1,4 +1,4 @@
<layout name="layouts/admin" />
<layout name="admin" />
<div class="section">
<h2>System Routes</h2>

View File

@@ -1,4 +1,4 @@
<layout name="layouts/admin" />
<layout name="admin" />
<div class="section">
<h2>Registrierte Dienste</h2>

View File

@@ -1,4 +1,4 @@
<layout name="layouts/admin" />
<layout name="admin" />
<div class="section">
<h2>{{ title }}</h2>

View File

@@ -1,4 +1,4 @@
<layout name="layouts/admin" />
<layout name="admin" />
<div class="section">
<h2>{{ title }}</h2>