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>

View File

@@ -37,8 +37,8 @@ final readonly class CsrfController
'honeypot' => $honeypotData,
'headers' => [
'X-CSRF-Form-ID' => $formId,
'X-CSRF-Token' => $token->toString()
]
'X-CSRF-Token' => $token->toString(),
],
]);
}
@@ -61,8 +61,8 @@ final readonly class CsrfController
'honeypot' => $honeypotData,
'headers' => [
'X-CSRF-Form-ID' => $formId,
'X-CSRF-Token' => $token->toString()
]
'X-CSRF-Token' => $token->toString(),
],
]);
}
@@ -84,8 +84,8 @@ final readonly class CsrfController
'fields' => [
'_honeypot_name' => $honeypotName,
$honeypotName => '',
'_form_start_time' => (string) time() // Current timestamp for time validation
]
'_form_start_time' => (string) time(), // Current timestamp for time validation
],
];
}
}
}

View File

@@ -12,8 +12,8 @@ use App\Framework\OpenApi\Attributes\ApiParameter;
use App\Framework\OpenApi\Attributes\ApiRequestBody;
use App\Framework\OpenApi\Attributes\ApiResponse;
use App\Framework\OpenApi\Attributes\ApiSecurity;
use App\Framework\Router\Result\JsonResult;
use App\Framework\Router\ApiRoutes;
use App\Framework\Router\Result\JsonResult;
/**
* Example API controller demonstrating OpenAPI documentation

View File

@@ -7,8 +7,7 @@ namespace App\Application\Api\Images;
use App\Domain\Media\ImageRepository;
use App\Framework\Attributes\Route;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
use App\Framework\Filesystem\FilePath;
use App\Framework\Filesystem\ValueObjects\FilePath;
use App\Framework\Http\Exception\NotFound;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\Method;
@@ -29,7 +28,7 @@ final readonly class DeleteImageController
// Find image by ULID
$image = $this->imageRepository->findByUlid($ulid);
if (!$image) {
if (! $image) {
throw NotFound::create(
ErrorCode::ENTITY_NOT_FOUND,
"Image with ULID {$ulid} not found"
@@ -96,4 +95,4 @@ final readonly class DeleteImageController
}
}
}
}
}

View File

@@ -7,7 +7,6 @@ namespace App\Application\Api\Images;
use App\Domain\Media\ImageRepository;
use App\Framework\Attributes\Route;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
use App\Framework\Http\Exception\NotFound;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\Method;
@@ -27,7 +26,7 @@ final readonly class GetImageController
// Find image by ULID
$image = $this->imageRepository->findByUlid($ulid);
if (!$image) {
if (! $image) {
throw NotFound::create(
ErrorCode::ENTITY_NOT_FOUND,
"Image with ULID {$ulid} not found"
@@ -64,4 +63,4 @@ final readonly class GetImageController
], $image->variants ?? []),
]);
}
}
}

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Application\Api\Images;
use App\Application\Security\Services\FileUploadSecurityService;
use App\Domain\Media\Image;
use App\Domain\Media\ImageProcessor;
use App\Domain\Media\ImageRepository;
use App\Domain\Media\ImageVariantRepository;
@@ -18,8 +17,6 @@ use App\Framework\Http\Exception\NotFound;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\Method;
use App\Framework\Http\Responses\JsonResponse;
use App\Framework\Http\Status;
use App\Framework\Http\UploadedFile;
use App\Framework\Router\Result\FileResult;
use App\Framework\Ulid\UlidGenerator;
@@ -191,7 +188,7 @@ final readonly class ImageApiController
// The path already contains the full file path
$file = $image->path->toString();
if (!file_exists($file)) {
if (! file_exists($file)) {
throw FrameworkException::create(
ErrorCode::ENTITY_NOT_FOUND,
"Image file not found on filesystem: {$file}"

View File

@@ -164,4 +164,4 @@ final readonly class ListImagesController
], $image->variants ?? []),
];
}
}
}

View File

@@ -28,7 +28,7 @@ final readonly class UpdateImageController
// Find image by ULID
$image = $this->imageRepository->findByUlid($ulid);
if (!$image) {
if (! $image) {
throw NotFound::create(
ErrorCode::ENTITY_NOT_FOUND,
"Image with ULID {$ulid} not found"
@@ -125,7 +125,7 @@ final readonly class UpdateImageController
)->withData([
'field' => 'filename',
'value' => $filename,
'invalid_chars' => ['/', '\\', ':', '*', '?', '"', '<', '>', '|']
'invalid_chars' => ['/', '\\', ':', '*', '?', '"', '<', '>', '|'],
]);
}
@@ -137,8 +137,8 @@ final readonly class UpdateImageController
)->withData([
'field' => 'filename',
'length' => strlen($filename),
'max_length' => 255
'max_length' => 255,
]);
}
}
}
}

View File

@@ -11,14 +11,13 @@ use App\Domain\Media\ImageRepository;
use App\Framework\Attributes\Route;
use App\Framework\Core\ValueObjects\FileSize;
use App\Framework\Core\ValueObjects\Hash;
use App\Framework\Core\ValueObjects\HashAlgorithm;
use App\Framework\DateTime\Clock;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
use App\Framework\Filesystem\FilePath;
use App\Framework\Http\Request;
use App\Framework\Filesystem\ValueObjects\FilePath;
use App\Framework\Http\Method;
use App\Framework\Http\MimeType;
use App\Framework\Http\Request;
use App\Framework\Http\Responses\JsonResponse;
use App\Framework\Http\Status;
use App\Framework\Http\UploadedFile;
@@ -40,19 +39,19 @@ final readonly class UploadImageController
// Validate uploaded file
$uploadedFiles = $request->files;
if ($uploadedFiles->isEmpty() || !$uploadedFiles->has('image')) {
if ($uploadedFiles->isEmpty() || ! $uploadedFiles->has('image')) {
throw FrameworkException::create(
ErrorCode::VAL_REQUIRED_FIELD_MISSING,
'No image file uploaded'
)->withData([
'field' => 'image',
'files_empty' => $uploadedFiles->isEmpty(),
'available_fields' => $uploadedFiles->keys()
'available_fields' => $uploadedFiles->keys(),
]);
}
$uploadedFile = $uploadedFiles->get('image');
if (!($uploadedFile instanceof UploadedFile)) {
if (! ($uploadedFile instanceof UploadedFile)) {
throw FrameworkException::create(
ErrorCode::VAL_INVALID_FORMAT,
'Invalid uploaded file'
@@ -62,7 +61,7 @@ final readonly class UploadImageController
// Security validation
try {
$validationResult = $this->uploadSecurityService->validateUpload($uploadedFile);
if (!$validationResult) {
if (! $validationResult) {
throw FrameworkException::create(
ErrorCode::SEC_FILE_UPLOAD_REJECTED,
'File upload security validation failed'
@@ -74,7 +73,7 @@ final readonly class UploadImageController
// Validate MIME type
$detectedMimeType = MimeType::fromFilePath($uploadedFile->name);
if (!$detectedMimeType || !$detectedMimeType->isImage()) {
if (! $detectedMimeType || ! $detectedMimeType->isImage()) {
throw FrameworkException::create(
ErrorCode::VAL_INVALID_FORMAT,
'Uploaded file is not a valid image'
@@ -85,7 +84,7 @@ final readonly class UploadImageController
$ulid = new Ulid($this->clock);
// Calculate file hash first (needed for filename)
if (!is_file($uploadedFile->tmpName)) {
if (! is_file($uploadedFile->tmpName)) {
throw FrameworkException::create(
ErrorCode::VAL_INVALID_FORMAT,
'Temporary file is not a valid file'
@@ -93,7 +92,7 @@ final readonly class UploadImageController
'tmp_name' => $uploadedFile->tmpName,
'is_file' => is_file($uploadedFile->tmpName),
'is_dir' => is_dir($uploadedFile->tmpName),
'exists' => file_exists($uploadedFile->tmpName)
'exists' => file_exists($uploadedFile->tmpName),
]);
}
@@ -202,5 +201,4 @@ final readonly class UploadImageController
'created_at' => $image->ulid->getDateTime()->format('c'),
], Status::CREATED);
}
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Application\Api;
use App\Application\LiveComponents\UserRegistration\UserRegistrationFormDefinition;
use App\Framework\Attributes\Route;
use App\Framework\Http\Method;
use App\Framework\LiveComponents\FormBuilder\MultiStepFormComponent;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\Meta\MetaData;
use App\Framework\Router\Result\ViewResult;
/**
* Demo Controller für LiveComponent FormBuilder
*/
final readonly class LiveComponentDemoController
{
#[Route('/demo/livecomponent-form', method: Method::GET)]
public function showMultiStepForm(): ViewResult
{
// Form Definition erstellen
$formDefinition = UserRegistrationFormDefinition::create();
// Component erstellen
$component = new MultiStepFormComponent(
id: ComponentId::generate('user-registration'),
formDefinition: $formDefinition
);
return new ViewResult(
template: 'pages/livecomponent-demo',
metaData: MetaData::create(
title: 'LiveComponent Multi-Step Form Demo',
description: 'Beispiel für die Integration von LiveComponent + FormBuilder'
),
data: [
'page_title' => 'LiveComponent Multi-Step Form Demo',
'registration_form' => $component,
]
);
}
}

View File

@@ -4,17 +4,17 @@ declare(strict_types=1);
namespace App\Application\Api;
use App\Domain\PreSave\PreSaveCampaignRepository;
use App\Domain\PreSave\PreSaveRegistration;
use App\Domain\PreSave\PreSaveRegistrationRepository;
use App\Domain\PreSave\ValueObjects\StreamingPlatform;
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\Domain\PreSave\PreSaveCampaignRepository;
use App\Domain\PreSave\PreSaveRegistrationRepository;
use App\Domain\PreSave\PreSaveRegistration;
use App\Domain\PreSave\ValueObjects\StreamingPlatform;
use App\Framework\OAuth\Storage\OAuthTokenRepository;
use App\Framework\Exception\FrameworkException;
use App\Framework\Exception\ErrorCode;
/**
* Pre-Save Campaign API Controller
@@ -25,7 +25,8 @@ final readonly class PreSaveApiController
private PreSaveCampaignRepository $campaignRepository,
private PreSaveRegistrationRepository $registrationRepository,
private OAuthTokenRepository $tokenRepository
) {}
) {
}
/**
* Get active campaigns
@@ -38,7 +39,7 @@ final readonly class PreSaveApiController
return new JsonResult([
'success' => true,
'data' => array_map(fn($campaign) => [
'data' => array_map(fn ($campaign) => [
'id' => $campaign->id,
'title' => $campaign->title,
'artist_name' => $campaign->artistName,
@@ -46,8 +47,8 @@ final readonly class PreSaveApiController
'description' => $campaign->description,
'release_date' => $campaign->releaseDate->toTimestamp(),
'status' => $campaign->status->value,
'platforms' => array_map(fn($url) => $url->platform->value, $campaign->trackUrls)
], $campaigns)
'platforms' => array_map(fn ($url) => $url->platform->value, $campaign->trackUrls),
], $campaigns),
]);
} catch (\Throwable $e) {
throw FrameworkException::create(
@@ -68,7 +69,7 @@ final readonly class PreSaveApiController
$campaignId = $request->routeParams->get('id');
// Validate ID is numeric
if (!is_numeric($campaignId)) {
if (! is_numeric($campaignId)) {
throw FrameworkException::create(
ErrorCode::VAL_INVALID_VALUE,
'Campaign ID must be numeric'
@@ -81,7 +82,7 @@ final readonly class PreSaveApiController
if ($campaign === null) {
return new JsonResult([
'success' => false,
'message' => 'Campaign not found'
'message' => 'Campaign not found',
], 404);
}
@@ -95,9 +96,9 @@ final readonly class PreSaveApiController
'description' => $campaign->description,
'release_date' => $campaign->releaseDate->toTimestamp(),
'status' => $campaign->status->value,
'platforms' => array_map(fn($url) => $url->platform->value, $campaign->trackUrls),
'track_urls' => array_map(fn($url) => $url->toArray(), $campaign->trackUrls)
]
'platforms' => array_map(fn ($url) => $url->platform->value, $campaign->trackUrls),
'track_urls' => array_map(fn ($url) => $url->toArray(), $campaign->trackUrls),
],
]);
} catch (\Throwable $e) {
throw FrameworkException::create(
@@ -116,7 +117,7 @@ final readonly class PreSaveApiController
$campaignId = $request->routeParams->get('id');
// Validate ID is numeric
if (!is_numeric($campaignId)) {
if (! is_numeric($campaignId)) {
throw FrameworkException::create(
ErrorCode::VAL_INVALID_VALUE,
'Campaign ID must be numeric'
@@ -130,7 +131,7 @@ final readonly class PreSaveApiController
if ($campaign === null) {
return new JsonResult([
'success' => false,
'message' => 'Campaign not found'
'message' => 'Campaign not found',
], 404);
}
@@ -157,7 +158,7 @@ final readonly class PreSaveApiController
"Invalid platform: {$data['platform']}"
)->withData([
'provided' => $data['platform'],
'allowed' => ['spotify', 'apple_music', 'tidal']
'allowed' => ['spotify', 'apple_music', 'tidal'],
]);
}
@@ -175,7 +176,7 @@ final readonly class PreSaveApiController
'success' => false,
'message' => "User not connected to {$platformName}",
'oauth_required' => true,
'oauth_url' => "/oauth/{$platformName}/authorize?user_id={$data['user_id']}&redirect_url=/presave/{$campaignId}"
'oauth_url' => "/oauth/{$platformName}/authorize?user_id={$data['user_id']}&redirect_url=/presave/{$campaignId}",
], 401);
}
@@ -194,8 +195,8 @@ final readonly class PreSaveApiController
'id' => $existingRegistration->id,
'status' => $existingRegistration->status->value,
'platform' => $existingRegistration->platform->value,
'registered_at' => $existingRegistration->registeredAt->toTimestamp()
]
'registered_at' => $existingRegistration->registeredAt->toTimestamp(),
],
], 409);
}
@@ -216,8 +217,8 @@ final readonly class PreSaveApiController
'campaign_id' => $campaignId,
'platform' => $platform->value,
'status' => $savedRegistration->status->value,
'registered_at' => $savedRegistration->registeredAt->toTimestamp()
]
'registered_at' => $savedRegistration->registeredAt->toTimestamp(),
],
], 201);
}
@@ -241,28 +242,28 @@ final readonly class PreSaveApiController
$registrations = $this->registrationRepository->findByCampaign($campaignId);
$userRegistrations = array_filter(
$registrations,
fn($reg) => $reg->userId === $userId
fn ($reg) => $reg->userId === $userId
);
if (empty($userRegistrations)) {
return new JsonResult([
'success' => true,
'registered' => false,
'registrations' => []
'registrations' => [],
]);
}
return new JsonResult([
'success' => true,
'registered' => true,
'registrations' => array_map(fn($reg) => [
'registrations' => array_map(fn ($reg) => [
'id' => $reg->id,
'platform' => $reg->platform->value,
'status' => $reg->status->value,
'registered_at' => $reg->registeredAt->toTimestamp(),
'processed_at' => $reg->processedAt?->toTimestamp(),
'error_message' => $reg->errorMessage
], array_values($userRegistrations))
'error_message' => $reg->errorMessage,
], array_values($userRegistrations)),
]);
}
@@ -289,14 +290,14 @@ final readonly class PreSaveApiController
$connections[$platform] = [
'connected' => $token !== null,
'expires_at' => $token?->expiresAt,
'needs_refresh' => $token !== null && $token->expiresAt < time()
'needs_refresh' => $token !== null && $token->expiresAt < time(),
];
}
return new JsonResult([
'success' => true,
'user_id' => $userId,
'connections' => $connections
'connections' => $connections,
]);
}
@@ -312,7 +313,7 @@ final readonly class PreSaveApiController
if ($campaign === null) {
return new JsonResult([
'success' => false,
'message' => 'Campaign not found'
'message' => 'Campaign not found',
], 404);
}
@@ -324,13 +325,13 @@ final readonly class PreSaveApiController
'pending' => 0,
'processing' => 0,
'completed' => 0,
'failed' => 0
'failed' => 0,
],
'by_platform' => [
'spotify' => 0,
'apple_music' => 0,
'tidal' => 0
]
'tidal' => 0,
],
];
foreach ($registrations as $registration) {
@@ -341,7 +342,7 @@ final readonly class PreSaveApiController
return new JsonResult([
'success' => true,
'campaign_id' => $campaignId,
'stats' => $stats
'stats' => $stats,
]);
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Application\Api;
use App\Framework\Attributes\Route;
use App\Framework\Http\Method;
use App\Framework\Router\Result\JsonResult;
final readonly class SimpleTestController
{
#[Route('/test/simple', method: Method::GET)]
public function test(): JsonResult
{
return new JsonResult(['message' => 'Test works!']);
}
}

View File

@@ -18,7 +18,8 @@ final readonly class CampaignService
{
public function __construct(
private CampaignRepository $repository
) {}
) {
}
public function findBySlug(string $slug): ?Campaign
{

View File

@@ -60,10 +60,10 @@ final readonly class InMemoryCampaignRepository implements CampaignRepository
'preview_url' => null,
'spotify_id' => 'spotify_track_3',
'apple_music_id' => 'apple_track_3',
]
],
],
'status' => 'active',
])
]),
];
}

View File

@@ -4,10 +4,10 @@ declare(strict_types=1);
namespace App\Application\Campaign;
use App\Application\Campaign\Services\SpotifyCampaignService;
use App\Framework\Attributes\Route;
use App\Framework\Http\Method;
use App\Framework\Http\Session\Session;
use App\Application\Campaign\Services\SpotifyCampaignService;
use App\Framework\Router\Result\Redirect;
/**
@@ -20,7 +20,8 @@ final readonly class PreSaveCampaign
public function __construct(
private CampaignService $campaignService,
private SpotifyCampaignService $spotifyCampaignService
) {}
) {
}
#[Route(path: '/campaign/{slug}/presave/{platform}', method: Method::GET)]
public function __invoke(string $slug, string $platform, Session $session): Redirect
@@ -28,7 +29,7 @@ final readonly class PreSaveCampaign
// Fetch campaign
$campaign = $this->campaignService->findBySlug($slug);
if (!$campaign) {
if (! $campaign) {
return Redirect::to('/404')
->withFlash('error', 'Campaign not found');
}
@@ -58,7 +59,7 @@ final readonly class PreSaveCampaign
*/
private function handleSpotifyPreSave(Session $session, object $campaign, string $slug): Redirect
{
if (!$campaign->spotify_enabled || !$campaign->spotify_uri) {
if (! $campaign->spotify_enabled || ! $campaign->spotify_uri) {
return Redirect::to("/campaign/{$slug}")
->withFlash('error', 'Spotify is not enabled for this campaign');
}
@@ -82,7 +83,7 @@ final readonly class PreSaveCampaign
*/
private function handleAppleMusicPreSave(object $campaign, string $slug): Redirect
{
if (!$campaign->apple_music_enabled || !$campaign->apple_music_id) {
if (! $campaign->apple_music_enabled || ! $campaign->apple_music_id) {
return Redirect::to("/campaign/{$slug}")
->withFlash('error', 'Apple Music is not enabled for this campaign');
}
@@ -94,7 +95,7 @@ final readonly class PreSaveCampaign
->withFlash('success', 'Redirecting to Apple Music...')
->withSession([
'presave_platform' => 'Apple Music',
'presave_url' => $appleUrl
'presave_url' => $appleUrl,
]);
}
}

View File

@@ -4,10 +4,10 @@ declare(strict_types=1);
namespace App\Application\Campaign\Services;
use App\Framework\OAuth\Providers\SpotifyProvider;
use App\Framework\OAuth\ValueObjects\OAuthToken;
use App\Application\Campaign\ValueObjects\Campaign;
use App\Framework\Attributes\Singleton;
use App\Framework\OAuth\Providers\SpotifyProvider;
use App\Framework\OAuth\ValueObjects\OAuthToken;
/**
* Spotify Campaign Integration Service
@@ -19,7 +19,8 @@ final readonly class SpotifyCampaignService
{
public function __construct(
private SpotifyProvider $spotifyProvider
) {}
) {
}
/**
* Get Spotify authorization URL for campaign pre-save
@@ -50,14 +51,14 @@ final readonly class SpotifyCampaignService
*/
public function addCampaignToLibrary(OAuthToken $token, Campaign $campaign): bool
{
if (!$campaign->spotify_enabled || empty($campaign->tracks)) {
if (! $campaign->spotify_enabled || empty($campaign->tracks)) {
return false;
}
// Extract Spotify track IDs from campaign tracks
$trackIds = array_filter(
array_map(
fn($track) => $this->extractSpotifyId($track['spotify_uri'] ?? null),
fn ($track) => $this->extractSpotifyId($track['spotify_uri'] ?? null),
$campaign->tracks
)
);
@@ -78,13 +79,13 @@ final readonly class SpotifyCampaignService
*/
public function checkCampaignInLibrary(OAuthToken $token, Campaign $campaign): array
{
if (!$campaign->spotify_enabled || empty($campaign->tracks)) {
if (! $campaign->spotify_enabled || empty($campaign->tracks)) {
return [];
}
$trackIds = array_filter(
array_map(
fn($track) => $this->extractSpotifyId($track['spotify_uri'] ?? null),
fn ($track) => $this->extractSpotifyId($track['spotify_uri'] ?? null),
$campaign->tracks
)
);
@@ -124,12 +125,14 @@ final readonly class SpotifyCampaignService
// Handle spotify:track:xxxx format
if (str_starts_with($uri, 'spotify:')) {
$parts = explode(':', $uri);
return $parts[2] ?? null;
}
// Handle URL format: https://open.spotify.com/track/xxxx
if (str_contains($uri, 'open.spotify.com')) {
$parts = explode('/', $uri);
return end($parts);
}

View File

@@ -6,9 +6,9 @@ namespace App\Application\Campaign;
use App\Framework\Attributes\Route;
use App\Framework\Http\Method;
use App\Framework\Router\Result\ViewResult;
use App\Framework\Http\Session\Session;
use App\Framework\Meta\MetaData;
use App\Framework\Router\Result\ViewResult;
/**
* Show Campaign Landing Page
@@ -19,7 +19,8 @@ final readonly class ShowCampaign
{
public function __construct(
private CampaignService $campaignService
) {}
) {
}
#[Route(path: '/campaign/{slug}', method: Method::GET)]
public function __invoke(string $slug, Session $session): ViewResult
@@ -27,7 +28,7 @@ final readonly class ShowCampaign
// Fetch campaign data
$campaign = $this->campaignService->findBySlug($slug);
if (!$campaign) {
if (! $campaign) {
throw new \RuntimeException("Campaign not found: {$slug}");
}
@@ -50,7 +51,7 @@ final readonly class ShowCampaign
'spotify_enabled' => (bool) $campaign->spotify_enabled,
'apple_music_enabled' => (bool) $campaign->apple_music_enabled,
'tracks' => $campaign->tracks ? array_map(
fn($track) => [
fn ($track) => [
'position' => $track->position,
'title' => $track->title,
'duration' => $track->duration ? $this->formatDuration($track->duration) : null,

View File

@@ -18,7 +18,8 @@ final readonly class ShowSuccess
{
public function __construct(
private CampaignService $campaignService
) {}
) {
}
#[Route(path: '/campaign/{slug}/success', method: Method::GET)]
public function __invoke(string $slug, Request $request): ViewResult
@@ -27,7 +28,7 @@ final readonly class ShowSuccess
// Fetch campaign
$campaign = $this->campaignService->findBySlug($slug);
if (!$campaign) {
if (! $campaign) {
throw new \RuntimeException("Campaign not found: {$slug}");
}

View File

@@ -4,13 +4,12 @@ declare(strict_types=1);
namespace App\Application\Campaign;
use App\Application\Campaign\Services\SpotifyCampaignService;
use App\Framework\Attributes\Route;
use App\Framework\Http\Method;
use App\Framework\Http\Request;
use App\Framework\Http\Responses\Redirect;
use App\Framework\OAuth\Providers\SpotifyProvider;
use App\Application\Campaign\Services\SpotifyCampaignService;
use App\Application\Campaign\CampaignService;
/**
* Spotify OAuth Callback Handler
@@ -23,7 +22,8 @@ final readonly class SpotifyCallbackHandler
private SpotifyProvider $spotifyProvider,
private SpotifyCampaignService $spotifyCampaignService,
private CampaignService $campaignService
) {}
) {
}
#[Route(path: '/campaign/spotify/callback', method: Method::GET)]
public function __invoke(Request $request): Redirect
@@ -41,14 +41,14 @@ final readonly class SpotifyCallbackHandler
// Validate state (CSRF protection)
$expectedState = $request->session->get('spotify_oauth_state');
if (!$state || $state !== $expectedState) {
if (! $state || $state !== $expectedState) {
return Redirect::to('/')
->withFlash('error', 'Invalid OAuth state. Please try again.');
}
// Get campaign slug from session
$campaignSlug = $request->session->get('spotify_campaign_slug');
if (!$campaignSlug) {
if (! $campaignSlug) {
return Redirect::to('/')
->withFlash('error', 'Campaign session expired. Please try again.');
}
@@ -59,7 +59,7 @@ final readonly class SpotifyCallbackHandler
// Get campaign data
$campaign = $this->campaignService->findBySlug($campaignSlug);
if (!$campaign) {
if (! $campaign) {
return Redirect::to('/')
->withFlash('error', 'Campaign not found');
}
@@ -67,7 +67,7 @@ final readonly class SpotifyCallbackHandler
// Add campaign tracks to user's Spotify library
$success = $this->spotifyCampaignService->addCampaignToLibrary($token, $campaign);
if (!$success) {
if (! $success) {
return Redirect::to("/campaign/{$campaignSlug}")
->withFlash('error', 'Failed to add tracks to your library');
}
@@ -113,6 +113,7 @@ final readonly class SpotifyCallbackHandler
if (str_starts_with($uri, 'spotify:')) {
$parts = explode(':', $uri);
return $parts[2] ?? null;
}

View File

@@ -18,7 +18,8 @@ final readonly class SubscribeCampaign
{
public function __construct(
private CampaignService $campaignService
) {}
) {
}
#[Route(path: '/campaign/{slug}/subscribe', method: Method::POST)]
public function __invoke(string $slug, Request $request): Redirect
@@ -27,7 +28,7 @@ final readonly class SubscribeCampaign
// Fetch campaign
$campaign = $this->campaignService->findBySlug($slug);
if (!$campaign) {
if (! $campaign) {
return Redirect::to('/404')
->withFlash('error', 'Campaign not found');
}
@@ -37,7 +38,7 @@ final readonly class SubscribeCampaign
$name = $request->parsedBody->get('name');
$newsletter = $request->parsedBody->get('newsletter') === '1';
if (!$email || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
if (! $email || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
return Redirect::back()
->withFlash('error', 'Please provide a valid email address')
->withInput($request->parsedBody->toArray());

View File

@@ -32,7 +32,8 @@ final readonly class Campaign
public string $status = 'active',
public ?\DateTimeImmutable $created_at = null,
public ?\DateTimeImmutable $updated_at = null,
) {}
) {
}
public static function fromArray(array $data): self
{
@@ -53,7 +54,7 @@ final readonly class Campaign
spotify_uri: $data['spotify_uri'] ?? null,
apple_music_id: $data['apple_music_id'] ?? null,
tracks: isset($data['tracks']) ? array_map(
fn($track) => CampaignTrack::fromArray($track),
fn ($track) => CampaignTrack::fromArray($track),
$data['tracks']
) : null,
status: $data['status'] ?? 'active',
@@ -95,7 +96,7 @@ final readonly class Campaign
public function hasReleased(): bool
{
if (!$this->release_date) {
if (! $this->release_date) {
return false;
}

View File

@@ -19,7 +19,8 @@ final readonly class CampaignTrack
public ?string $preview_url,
public ?string $spotify_id = null,
public ?string $apple_music_id = null,
) {}
) {
}
public static function fromArray(array $data): self
{

View File

@@ -38,6 +38,10 @@
</section>
<x-counter count="5"/>
<x-search/>
<style>
div.box {
background: red;

View File

@@ -70,4 +70,15 @@ final readonly class DemoController
],
]);
}
#[Route(path: '/demo/x-components', method: Method::GET)]
public function xComponentDemo(HttpRequest $request): ViewResult
{
$metaData = new MetaData(
title: 'X-Component Syntax Demo',
description: 'Demo der unified x-component syntax für LiveComponents und HTML Components'
);
return new ViewResult('x-component-demo', $metaData);
}
}

View File

@@ -0,0 +1,648 @@
<?php
declare(strict_types=1);
namespace App\Application\Controllers;
use App\Application\Components\CounterComponent;
use App\Application\LiveComponents\ActivityFeed\ActivityFeedComponent;
use App\Application\LiveComponents\Autocomplete\AutocompleteComponent;
use App\Application\LiveComponents\Chart\ChartComponent;
use App\Application\LiveComponents\CommentThread\CommentThreadComponent;
use App\Application\LiveComponents\DataTable\DataTableComponent;
use App\Application\LiveComponents\DynamicForm\DynamicFormComponent;
use App\Application\LiveComponents\ImageUploader\ImageUploaderComponent;
use App\Application\LiveComponents\InfiniteScroll\InfiniteScrollComponent;
use App\Application\LiveComponents\InfiniteScroll\InfiniteScrollState;
use App\Application\LiveComponents\LivePresence\LivePresenceComponent;
use App\Application\LiveComponents\MetricsDashboard\MetricsDashboardComponent;
use App\Application\LiveComponents\Modal\ModalComponent;
use App\Application\LiveComponents\NotificationCenter\NotificationCenterComponent;
use App\Application\LiveComponents\ProductFilter\ProductFilterComponent;
use App\Application\LiveComponents\Search\SearchComponent;
use App\Application\LiveComponents\ShoppingCart\ShoppingCartComponent;
use App\Application\LiveComponents\Stats\StatsComponent;
use App\Application\LiveComponents\Tabs\TabsComponent;
use App\Application\LiveComponents\Timer\TimerComponent;
use App\Framework\Attributes\Route;
use App\Framework\Core\PathProvider;
use App\Framework\Http\Method;
use App\Framework\LiveComponents\ComponentRegistry;
use App\Framework\LiveComponents\DataProviderResolver;
use App\Framework\LiveComponents\ValueObjects\ComponentData;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\Meta\MetaData;
use App\Framework\Router\Result\ViewResult;
final readonly class LiveComponentDemoController
{
public function __construct(
private ComponentRegistry $componentRegistry,
private PathProvider $pathProvider,
private DataProviderResolver $dataProviderResolver
) {
}
#[Route('/livecomponent-events', method: Method::GET)]
public function eventsDemo(): ViewResult
{
// Create two counter components
$counter1 = new CounterComponent(
id: ComponentId::fromString('counter:demo1'),
count: 0
);
$counter2 = new CounterComponent(
id: ComponentId::fromString('counter:demo2'),
count: 5
);
return new ViewResult(
template: 'livecomponent-events-demo',
metaData: MetaData::create('LiveComponent Events Demo'),
data: [
'counter1' => $counter1,
'counter2' => $counter2,
]
);
}
#[Route('/livecomponent-upload', method: Method::GET)]
public function uploadDemo(): ViewResult
{
// Create image uploader component
$uploader = new ImageUploaderComponent(
id: ComponentId::fromString('image-uploader:demo'),
pathProvider: $this->pathProvider,
initialData: ComponentData::fromArray([])
);
return new ViewResult(
template: 'livecomponent-upload-demo',
metaData: MetaData::create('LiveComponent Upload Demo'),
data: [
'uploader' => $uploader,
]
);
}
#[Route('/livecomponent-cache', method: Method::GET)]
public function cacheDemo(): ViewResult
{
// Create two stats components - one cached, one not
$stats1 = new StatsComponent(
id: ComponentId::fromString('stats:cached'),
cacheEnabled: true
);
$stats2 = new StatsComponent(
id: ComponentId::fromString('stats:uncached'),
cacheEnabled: false
);
return new ViewResult(
template: 'livecomponent-cache-demo',
metaData: MetaData::create('LiveComponent Cache Demo'),
data: [
'stats1' => $stats1,
'stats2' => $stats2,
]
);
}
#[Route('/livecomponent-search', method: Method::GET)]
public function searchDemo(): ViewResult
{
// Create search component
$search = new SearchComponent(
id: ComponentId::fromString('search:demo'),
query: '',
target: 'all'
);
return new ViewResult(
template: 'livecomponent-search-demo',
metaData: MetaData::create('LiveComponent Search Demo'),
data: [
'search' => $search,
]
);
}
#[Route('/livecomponent-form', method: Method::GET)]
public function formDemo(): ViewResult
{
// Create dynamic form component
$form = new DynamicFormComponent(
id: ComponentId::fromString('dynamic-form:demo'),
currentStep: 1,
totalSteps: 4,
formData: [],
errors: [],
submitted: false
);
return new ViewResult(
template: 'livecomponent-dynamic-form-demo',
metaData: MetaData::create('LiveComponent Dynamic Form Demo'),
data: [
'form' => $form,
]
);
}
#[Route('/livecomponent-autocomplete', method: Method::GET)]
public function autocompleteDemo(): ViewResult
{
// Create autocomplete component - provider resolved automatically
$autocomplete = new AutocompleteComponent(
id: ComponentId::fromString('autocomplete:demo'),
dataProviderResolver: $this->dataProviderResolver,
query: '',
suggestions: [],
context: 'general',
showDropdown: false,
recentSearches: [],
dataSource: 'demo'
);
return new ViewResult(
template: 'livecomponent-autocomplete-demo',
metaData: MetaData::create('LiveComponent Autocomplete Demo'),
data: [
'autocomplete' => $autocomplete,
]
);
}
#[Route('/livecomponent-datatable', method: Method::GET)]
public function datatableDemo(): ViewResult
{
// Create datatable component
// Component has its own demo data in getTableData()
// Initial render will populate rows via getRenderData()
$datatable = new DataTableComponent(
id: ComponentId::fromString('datatable:demo'),
rows: [], // Will be populated on first render
page: 1,
pageSize: 10
);
return new ViewResult(
template: 'livecomponent-datatable-demo',
metaData: MetaData::create('LiveComponent DataTable Demo'),
data: [
'datatable' => $datatable,
]
);
}
#[Route('/livecomponent-infinite-scroll', method: Method::GET)]
public function infiniteScrollDemo(): ViewResult
{
// Create infinite scroll component - provider resolved automatically
$infiniteScroll = new InfiniteScrollComponent(
id: ComponentId::fromString('infinite-scroll:demo'),
dataProviderResolver: $this->dataProviderResolver,
items: [], // Will be populated by first loadMore
currentPage: 0,
pageSize: 20,
dataSource: 'demo'
);
// Load first batch
$firstBatch = $infiniteScroll->loadMore();
// Get state after first load
$loadedState = InfiniteScrollState::fromComponentData($firstBatch);
return new ViewResult(
template: 'livecomponent-infinite-scroll-demo',
metaData: MetaData::create('LiveComponent Infinite Scroll Demo'),
data: [
'infinite_scroll' => new InfiniteScrollComponent(
id: ComponentId::fromString('infinite-scroll:demo'),
dataProviderResolver: $this->dataProviderResolver,
initialData: $firstBatch,
dataSource: 'demo'
),
]
);
}
#[Route('/livecomponent-chart', method: Method::GET)]
public function chartDemo(): ViewResult
{
// Create chart component - provider resolved automatically
$chart = new ChartComponent(
id: ComponentId::fromString('chart:demo'),
dataProviderResolver: $this->dataProviderResolver,
chartType: 'line',
chartData: [],
dataRange: '24h',
lastUpdate: 0,
autoRefresh: true,
refreshInterval: 5000,
executionTimeMs: 0.0,
dataPointsCount: 0,
dataSource: 'demo'
);
return new ViewResult(
template: 'livecomponent-chart-demo',
metaData: MetaData::create('LiveComponent Chart Demo'),
data: [
'chart' => $chart,
]
);
}
#[Route('/livecomponent-notification-center', method: Method::GET)]
public function notificationCenterDemo(): ViewResult
{
// Create notification center with some demo notifications
$notificationCenter = new NotificationCenterComponent(
id: ComponentId::fromString('notification-center:demo'),
notifications: [
[
'id' => 'notif_' . uniqid(),
'type' => 'success',
'title' => 'Erfolgreich gespeichert',
'message' => 'Ihre Änderungen wurden erfolgreich gespeichert.',
'read' => false,
'timestamp' => time() - 300,
],
[
'id' => 'notif_' . uniqid(),
'type' => 'info',
'title' => 'Neue Version verfügbar',
'message' => 'Eine neue Version ist verfügbar. Bitte aktualisieren Sie.',
'read' => false,
'timestamp' => time() - 600,
],
[
'id' => 'notif_' . uniqid(),
'type' => 'warning',
'title' => 'Warnung: Speicherplatz',
'message' => 'Ihr Speicherplatz ist fast voll (85% verwendet).',
'read' => true,
'timestamp' => time() - 1800,
],
[
'id' => 'notif_' . uniqid(),
'type' => 'error',
'title' => 'Verbindungsfehler',
'message' => 'Verbindung zum Server konnte nicht hergestellt werden.',
'read' => true,
'timestamp' => time() - 3600,
],
],
unreadCount: 2,
filter: 'all',
showPanel: false
);
return new ViewResult(
template: 'livecomponent-notification-center-demo',
metaData: MetaData::create('LiveComponent Notification Center Demo'),
data: [
'notification_center' => $notificationCenter,
]
);
}
#[Route('/livecomponent-modal', method: Method::GET)]
public function modalDemo(): ViewResult
{
// Create modal component with closed initial state
$modal = new ModalComponent(
id: ComponentId::fromString('modal:demo'),
initialData: ComponentData::fromArray([
'is_open' => false,
'title' => '',
'content' => '',
'size' => 'medium',
'buttons' => [],
'show_close_button' => true,
'close_on_backdrop' => true,
'close_on_escape' => true,
'animation' => 'fade',
'z_index' => 1050,
])
);
return new ViewResult(
template: 'livecomponent-modal-demo',
metaData: MetaData::create('LiveComponent Modal Demo'),
data: [
'modal' => $modal,
]
);
}
#[Route('/livecomponent-tabs', method: Method::GET)]
public function tabsDemo(): ViewResult
{
// Create tabs component with initial tabs
$tabs = new TabsComponent(
id: ComponentId::fromString('tabs:demo'),
initialData: ComponentData::fromArray([
'tabs' => [
[
'id' => 'tab_1',
'title' => 'Übersicht',
'content' => '<h3>Willkommen</h3><p>Dies ist der Übersichts-Tab mit wichtigen Informationen.</p><ul><li>Feature 1</li><li>Feature 2</li><li>Feature 3</li></ul>',
'closable' => false,
'created_at' => time(),
],
[
'id' => 'tab_2',
'title' => 'Details',
'content' => '<h3>Detaillierte Informationen</h3><p>Hier finden Sie weitere Details und spezifische Daten.</p><p>Dieser Tab kann geschlossen werden.</p>',
'closable' => true,
'created_at' => time(),
],
[
'id' => 'tab_3',
'title' => 'Einstellungen',
'content' => '<h3>Konfiguration</h3><p>Verwalten Sie hier Ihre Einstellungen und Präferenzen.</p>',
'closable' => true,
'created_at' => time(),
],
],
'active_tab' => 'tab_1',
'max_tabs' => 10,
'allow_close' => true,
'allow_add' => true,
'tab_style' => 'default',
])
);
return new ViewResult(
template: 'livecomponent-tabs-demo',
metaData: MetaData::create('LiveComponent Tabs Demo'),
data: [
'tabs' => $tabs,
]
);
}
#[Route('/livecomponent-comment-thread', method: Method::GET)]
public function commentThreadDemo(): ViewResult
{
// Create comment thread component with initial demo comments
$commentThread = new CommentThreadComponent(
id: ComponentId::fromString('comment-thread:demo'),
comments: [],
sortBy: 'newest',
showReactions: true,
allowEdit: true,
allowDelete: true,
maxNestingLevel: 3
);
return new ViewResult(
template: 'livecomponent-comment-thread-demo',
metaData: MetaData::create('LiveComponent Comment Thread Demo'),
data: [
'comment_thread' => $commentThread,
]
);
}
#[Route('/livecomponent-live-presence', method: Method::GET)]
public function livePresenceDemo(): ViewResult
{
// Create live presence component with empty initial state
$livePresence = new LivePresenceComponent(
id: ComponentId::fromString('live-presence:demo'),
initialData: ComponentData::fromArray([
'users' => [],
'show_avatars' => true,
'show_names' => true,
'show_count' => true,
'max_visible_users' => 10,
'presence_timeout' => 300, // 5 minutes
'allow_anonymous' => false,
])
);
return new ViewResult(
template: 'livecomponent-live-presence-demo',
metaData: MetaData::create('LiveComponent Live Presence Demo'),
data: [
'live_presence' => $livePresence,
]
);
}
#[Route('/livecomponent-shopping-cart', method: Method::GET)]
public function shoppingCartDemo(): ViewResult
{
// Create shopping cart component with empty initial state
$shoppingCart = new ShoppingCartComponent(
id: ComponentId::fromString('shopping-cart:demo'),
initialData: ComponentData::fromArray([
'items' => [],
'discount_code' => null,
'discount_percentage' => 0,
'shipping_method' => 'standard',
'tax_rate' => 0.19,
'currency' => 'EUR',
'min_order_value' => 0,
'free_shipping_threshold' => 50,
])
);
return new ViewResult(
template: 'livecomponent-shopping-cart-demo',
metaData: MetaData::create('LiveComponent Shopping Cart Demo'),
data: [
'shopping_cart' => $shoppingCart,
]
);
}
#[Route('/livecomponent-product-filter', method: Method::GET)]
public function productFilterDemo(): ViewResult
{
// Create product filter component with initial state
$productFilter = new ProductFilterComponent(
id: ComponentId::fromString('product-filter:demo'),
activeFilters: [
'category' => null,
'price_min' => 0,
'price_max' => 1000,
'brands' => [],
'colors' => [],
'sizes' => [],
'min_rating' => 0,
'in_stock_only' => false,
],
availableCategories: [
['value' => 'electronics', 'label' => 'Electronics', 'count' => 8],
['value' => 'fashion', 'label' => 'Fashion', 'count' => 12],
['value' => 'home', 'label' => 'Home & Living', 'count' => 6],
['value' => 'sports', 'label' => 'Sports', 'count' => 5],
['value' => 'beauty', 'label' => 'Beauty', 'count' => 4],
],
availableBrands: [
['value' => 'TechPro', 'label' => 'TechPro', 'count' => 3],
['value' => 'SmartTech', 'label' => 'SmartTech', 'count' => 2],
['value' => 'FashionBrand', 'label' => 'FashionBrand', 'count' => 1],
['value' => 'StyleCo', 'label' => 'StyleCo', 'count' => 1],
['value' => 'HomeLiving', 'label' => 'HomeLiving', 'count' => 1],
['value' => 'SportGear', 'label' => 'SportGear', 'count' => 1],
],
availableColors: [
['value' => 'red', 'label' => 'Rot', 'hex' => '#f44336', 'count' => 2],
['value' => 'blue', 'label' => 'Blau', 'hex' => '#2196F3', 'count' => 2],
['value' => 'green', 'label' => 'Grün', 'hex' => '#4CAF50', 'count' => 1],
['value' => 'orange', 'label' => 'Orange', 'hex' => '#FF9800', 'count' => 1],
['value' => 'purple', 'label' => 'Lila', 'hex' => '#9C27B0', 'count' => 1],
['value' => 'cyan', 'label' => 'Cyan', 'hex' => '#00BCD4', 'count' => 1],
['value' => 'pink', 'label' => 'Pink', 'hex' => '#E91E63', 'count' => 1],
],
availableSizes: [
['value' => 'XS', 'label' => 'XS', 'count' => 0],
['value' => 'S', 'label' => 'S', 'count' => 4],
['value' => 'M', 'label' => 'M', 'count' => 2],
['value' => 'L', 'label' => 'L', 'count' => 1],
['value' => 'XL', 'label' => 'XL', 'count' => 2],
['value' => 'XXL', 'label' => 'XXL', 'count' => 0],
],
priceRange: ['min' => 0, 'max' => 1000],
sortBy: 'relevance'
);
return new ViewResult(
template: 'livecomponent-product-filter-demo',
metaData: MetaData::create('LiveComponent Product Filter Demo'),
data: [
'product_filter' => $productFilter,
]
);
}
#[Route('/livecomponent-activity-feed', method: Method::GET)]
public function activityFeedDemo(): ViewResult
{
// Create activity feed component - provider resolved automatically
$activityFeed = new ActivityFeedComponent(
id: ComponentId::fromString('activity-feed:demo'),
dataProviderResolver: $this->dataProviderResolver,
initialData: ComponentData::fromArray([
'activities' => [], // Will be loaded from dataProvider
'filter' => 'all',
'page' => 1,
'page_size' => 20,
'show_avatars' => true,
'show_timestamps' => true,
'group_by_date' => true,
'data_source' => 'demo',
])
);
return new ViewResult(
template: 'livecomponent-activity-feed-demo',
metaData: MetaData::create('LiveComponent Activity Feed Demo'),
data: [
'activity_feed' => $activityFeed,
]
);
}
#[Route('/livecomponent-metrics-dashboard', method: Method::GET)]
public function metricsDashboardDemo(): ViewResult
{
// Create metrics dashboard component - provider resolved automatically
$metricsDashboard = new MetricsDashboardComponent(
id: ComponentId::fromString('metrics-dashboard:demo'),
dataProviderResolver: $this->dataProviderResolver,
initialData: ComponentData::fromArray([
'metrics' => [], // Will be loaded from dataProvider
'time_range' => '30d',
'last_updated' => time(),
'auto_refresh' => false,
'refresh_interval' => 60,
'data_source' => 'demo',
])
);
return new ViewResult(
template: 'livecomponent-metrics-dashboard-demo',
metaData: MetaData::create('LiveComponent Metrics Dashboard Demo'),
data: [
'metrics_dashboard' => $metricsDashboard,
]
);
}
private function getDemoData(): array
{
return [
['id' => 1, 'name' => 'John Doe', 'email' => 'john@example.com', 'role' => 'Admin', 'status' => 'Active', 'created' => '2024-01-15'],
['id' => 2, 'name' => 'Jane Smith', 'email' => 'jane@example.com', 'role' => 'User', 'status' => 'Active', 'created' => '2024-02-20'],
['id' => 3, 'name' => 'Bob Johnson', 'email' => 'bob@example.com', 'role' => 'Editor', 'status' => 'Inactive', 'created' => '2024-03-10'],
['id' => 4, 'name' => 'Alice Williams', 'email' => 'alice@example.com', 'role' => 'User', 'status' => 'Active', 'created' => '2024-03-25'],
['id' => 5, 'name' => 'Charlie Brown', 'email' => 'charlie@example.com', 'role' => 'Admin', 'status' => 'Active', 'created' => '2024-04-05'],
['id' => 6, 'name' => 'David Miller', 'email' => 'david@example.com', 'role' => 'User', 'status' => 'Inactive', 'created' => '2024-04-15'],
['id' => 7, 'name' => 'Eva Davis', 'email' => 'eva@example.com', 'role' => 'Editor', 'status' => 'Active', 'created' => '2024-05-01'],
['id' => 8, 'name' => 'Frank Wilson', 'email' => 'frank@example.com', 'role' => 'User', 'status' => 'Active', 'created' => '2024-05-10'],
['id' => 9, 'name' => 'Grace Lee', 'email' => 'grace@example.com', 'role' => 'Admin', 'status' => 'Active', 'created' => '2024-05-15'],
['id' => 10, 'name' => 'Henry Garcia', 'email' => 'henry@example.com', 'role' => 'User', 'status' => 'Inactive', 'created' => '2024-05-20'],
['id' => 11, 'name' => 'Iris Martinez', 'email' => 'iris@example.com', 'role' => 'Editor', 'status' => 'Active', 'created' => '2024-06-01'],
['id' => 12, 'name' => 'Jack Robinson', 'email' => 'jack@example.com', 'role' => 'User', 'status' => 'Active', 'created' => '2024-06-10'],
['id' => 13, 'name' => 'Kate Anderson', 'email' => 'kate@example.com', 'role' => 'Admin', 'status' => 'Active', 'created' => '2024-06-15'],
['id' => 14, 'name' => 'Leo Thomas', 'email' => 'leo@example.com', 'role' => 'User', 'status' => 'Inactive', 'created' => '2024-06-20'],
['id' => 15, 'name' => 'Mia Taylor', 'email' => 'mia@example.com', 'role' => 'Editor', 'status' => 'Active', 'created' => '2024-07-01'],
['id' => 16, 'name' => 'Noah Moore', 'email' => 'noah@example.com', 'role' => 'User', 'status' => 'Active', 'created' => '2024-07-10'],
['id' => 17, 'name' => 'Olivia Jackson', 'email' => 'olivia@example.com', 'role' => 'Admin', 'status' => 'Active', 'created' => '2024-07-15'],
['id' => 18, 'name' => 'Paul White', 'email' => 'paul@example.com', 'role' => 'User', 'status' => 'Inactive', 'created' => '2024-07-20'],
['id' => 19, 'name' => 'Quinn Harris', 'email' => 'quinn@example.com', 'role' => 'Editor', 'status' => 'Active', 'created' => '2024-08-01'],
['id' => 20, 'name' => 'Ryan Clark', 'email' => 'ryan@example.com', 'role' => 'User', 'status' => 'Active', 'created' => '2024-08-10'],
];
}
#[Route('/livecomponent-image-gallery', method: Method::GET)]
public function imageGalleryDemo(): ViewResult
{
// Create image gallery component - provider resolved automatically
$imageGallery = new \App\Application\LiveComponents\ImageGallery\ImageGalleryComponent(
id: ComponentId::fromString('image-gallery:demo'),
dataProviderResolver: $this->dataProviderResolver,
initialData: ComponentData::fromArray([
'images' => [],
'sort_by' => 'created_at',
'sort_direction' => 'desc',
'items_per_page' => 12,
'data_source' => 'demo',
])
);
return new ViewResult(
template: 'livecomponent-image-gallery-demo',
metaData: MetaData::create('LiveComponent Image Gallery Demo'),
data: [
'image_gallery' => $imageGallery,
]
);
}
#[Route('/livecomponent-timer', method: Method::GET)]
public function timerDemo(): ViewResult
{
// Create timer component demonstrating lifecycle hooks
$timer = new TimerComponent(
id: ComponentId::fromString('timer:demo')
);
return new ViewResult(
template: 'livecomponent-timer-demo',
metaData: MetaData::create('LiveComponent Timer Demo - Lifecycle Hooks'),
data: [
'timer' => $timer,
]
);
}
}

View File

@@ -0,0 +1,634 @@
<?php
declare(strict_types=1);
namespace App\Application\Controllers;
use App\Application\Components\CounterComponent;
use App\Application\Components\ImageUploaderComponent;
use App\Application\Components\StatsComponent;
use App\Application\LiveComponents\Autocomplete\AutocompleteComponent;
use App\Application\LiveComponents\Chart\ChartComponent;
use App\Application\LiveComponents\DataTable\DataTableComponent;
use App\Application\LiveComponents\DynamicForm\DynamicFormComponent;
use App\Application\LiveComponents\InfiniteScroll\InfiniteScrollComponent;
use App\Application\LiveComponents\Modal\ModalComponent;
use App\Application\LiveComponents\NotificationCenter\NotificationCenterComponent;
use App\Application\LiveComponents\Search\SearchComponent;
use App\Application\LiveComponents\Tabs\TabsComponent;
use App\Application\LiveComponents\CommentThread\CommentThreadComponent;
use App\Application\LiveComponents\LivePresence\LivePresenceComponent;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\LiveComponents\ValueObjects\ComponentData;
use App\Application\LiveComponents\ShoppingCart\ShoppingCartComponent;
use App\Application\LiveComponents\ProductFilter\ProductFilterComponent;
use App\Application\LiveComponents\ActivityFeed\ActivityFeedComponent;
use App\Application\LiveComponents\MetricsDashboard\MetricsDashboardComponent;
use App\Application\LiveComponents\Services\DemoMetricsDataProvider;
use App\Application\LiveComponents\Services\DemoActivityDataProvider;
use App\Application\LiveComponents\Services\DemoChartDataProvider;
use App\Application\LiveComponents\Services\DemoScrollDataProvider;
use App\Application\LiveComponents\Services\DemoSuggestionProvider;
use App\Framework\Attributes\Route;
use App\Framework\Core\PathProvider;
use App\Framework\Http\Method;
use App\Framework\LiveComponents\ComponentRegistry;
use App\Framework\Meta\MetaData;
use App\Framework\Router\Result\ViewResult;
use App\Framework\View\RawHtml;
final readonly class LiveComponentDemoController
{
public function __construct(
private ComponentRegistry $componentRegistry,
private PathProvider $pathProvider
) {
// Components no longer need providers injected here
// They create their own providers based on data_source in initialData
}
#[Route('/livecomponent-events', method: Method::GET)]
public function eventsDemo(): ViewResult
{
// Create two counter components
$counter1 = new CounterComponent(
id: 'counter:demo1',
initialData: ['count' => 0]
);
$counter2 = new CounterComponent(
id: 'counter:demo2',
initialData: ['count' => 5]
);
return new ViewResult(
template: 'livecomponent-events-demo',
metaData: MetaData::create('LiveComponent Events Demo'),
data: [
'counter1' => $counter1,
'counter2' => $counter2
]
);
}
#[Route('/livecomponent-upload', method: Method::GET)]
public function uploadDemo(): ViewResult
{
// Create image uploader component
$uploader = new ImageUploaderComponent(
id: 'image-uploader:demo',
pathProvider: $this->pathProvider,
initialData: []
);
return new ViewResult(
template: 'livecomponent-upload-demo',
metaData: MetaData::create('LiveComponent Upload Demo'),
data: [
'uploader' => $uploader
]
);
}
#[Route('/livecomponent-cache', method: Method::GET)]
public function cacheDemo(): ViewResult
{
// Create two stats components - one cached, one not
$stats1 = new StatsComponent(
id: 'stats:cached',
initialData: ['cache_enabled' => true, 'last_update' => date('H:i:s'), 'render_count' => 0]
);
$stats2 = new StatsComponent(
id: 'stats:uncached',
initialData: ['cache_enabled' => false, 'last_update' => date('H:i:s'), 'render_count' => 0]
);
return new ViewResult(
template: 'livecomponent-cache-demo',
metaData: MetaData::create('LiveComponent Cache Demo'),
data: [
'stats1' => $stats1,
'stats2' => $stats2
]
);
}
#[Route('/livecomponent-search', method: Method::GET)]
public function searchDemo(): ViewResult
{
// Create search component
$search = new SearchComponent(
id: ComponentId::fromString('search:demo'),
initialData: ComponentData::fromArray([
'query' => '',
'target' => 'all',
'results' => [],
'result_count' => 0
])
);
return new ViewResult(
template: 'livecomponent-search-demo',
metaData: MetaData::create('LiveComponent Search Demo'),
data: [
'search' => $search
]
);
}
#[Route('/livecomponent-form', method: Method::GET)]
public function formDemo(): ViewResult
{
// Create dynamic form component
$form = new DynamicFormComponent(
id: ComponentId::fromString('form:demo'),
initialData: ComponentData::fromArray([
'current_step' => 1,
'form_data' => [],
'errors' => [],
'total_steps' => 4,
'submitted' => false
])
);
return new ViewResult(
template: 'livecomponent-dynamic-form-demo',
metaData: MetaData::create('LiveComponent Dynamic Form Demo'),
data: [
'form' => $form
]
);
}
#[Route('/livecomponent-autocomplete', method: Method::GET)]
public function autocompleteDemo(): ViewResult
{
// Create autocomplete component with DemoSuggestionProvider
$autocomplete = new AutocompleteComponent(
id: 'autocomplete:demo',
suggestionProvider: $this->suggestionProvider,
initialData: [
'query' => '',
'suggestions' => [],
'context' => 'general',
'show_dropdown' => false,
'recent_searches' => []
]
);
return new ViewResult(
template: 'livecomponent-autocomplete-demo',
metaData: MetaData::create('LiveComponent Autocomplete Demo'),
data: [
'autocomplete' => $autocomplete
]
);
}
#[Route('/livecomponent-datatable', method: Method::GET)]
public function datatableDemo(): ViewResult
{
// Create datatable component with initial state
$datatable = new DataTableComponent(
id: ComponentId::fromString('datatable:demo'),
initialData: [
'rows' => array_slice($this->getDemoData(), 0, 10), // First page
'sort' => ['column' => 'id', 'direction' => 'asc'],
'page' => 1,
'page_size' => 10,
'total_rows' => 20,
'total_pages' => 2,
'selected_rows' => [],
'filters' => [
'id' => '',
'name' => '',
'email' => '',
'role' => '',
'status' => ''
]
]
);
return new ViewResult(
template: 'livecomponent-datatable-demo',
metaData: MetaData::create('LiveComponent DataTable Demo'),
data: [
'datatable' => $datatable
]
);
}
#[Route('/livecomponent-infinite-scroll', method: Method::GET)]
public function infiniteScrollDemo(): ViewResult
{
// Create infinite scroll component with DemoScrollDataProvider
$infiniteScroll = new InfiniteScrollComponent(
id: 'infinite-scroll:demo',
dataProvider: $this->scrollDataProvider,
initialData: [
'items' => [], // Will be populated by first loadMore
'current_page' => 0,
'page_size' => 20,
'total_items' => $this->scrollDataProvider->getTotalCount(),
'has_more' => true,
'loading' => false,
'items_loaded' => 0,
'execution_time_ms' => 0,
'last_load_count' => 0
]
);
// Load first batch
$firstBatch = $infiniteScroll->loadMore();
return new ViewResult(
template: 'livecomponent-infinite-scroll-demo',
metaData: MetaData::create('LiveComponent Infinite Scroll Demo'),
data: [
'infinite_scroll' => new InfiniteScrollComponent(
id: 'infinite-scroll:demo',
dataProvider: $this->scrollDataProvider,
initialData: $firstBatch->getData()
)
]
);
}
#[Route('/livecomponent-chart', method: Method::GET)]
public function chartDemo(): ViewResult
{
// Create chart component
// Component creates its own provider based on data_source
$chart = new ChartComponent(
id: ComponentId::fromString('chart:demo'),
initialData: [
'data_source' => 'demo', // Which data to load
'chart_type' => 'line',
'data_range' => '24h',
'auto_refresh' => true,
'refresh_interval' => 5000
]
);
return new ViewResult(
template: 'livecomponent-chart-demo',
metaData: MetaData::create('LiveComponent Chart Demo'),
data: [
'chart' => $chart
]
);
}
#[Route('/livecomponent-notification-center', method: Method::GET)]
public function notificationCenterDemo(): ViewResult
{
// Create notification center with some demo notifications
$notificationCenter = new NotificationCenterComponent(
id: 'notification-center:demo',
initialData: [
'notifications' => [
[
'id' => 'notif_' . uniqid(),
'type' => 'success',
'title' => 'Erfolgreich gespeichert',
'message' => 'Ihre Änderungen wurden erfolgreich gespeichert.',
'read' => false,
'timestamp' => time() - 300
],
[
'id' => 'notif_' . uniqid(),
'type' => 'info',
'title' => 'Neue Version verfügbar',
'message' => 'Eine neue Version ist verfügbar. Bitte aktualisieren Sie.',
'read' => false,
'timestamp' => time() - 600
],
[
'id' => 'notif_' . uniqid(),
'type' => 'warning',
'title' => 'Warnung: Speicherplatz',
'message' => 'Ihr Speicherplatz ist fast voll (85% verwendet).',
'read' => true,
'timestamp' => time() - 1800
],
[
'id' => 'notif_' . uniqid(),
'type' => 'error',
'title' => 'Verbindungsfehler',
'message' => 'Verbindung zum Server konnte nicht hergestellt werden.',
'read' => true,
'timestamp' => time() - 3600
],
],
'unread_count' => 2,
'filter' => 'all',
'show_panel' => false
]
);
return new ViewResult(
template: 'livecomponent-notification-center-demo',
metaData: MetaData::create('LiveComponent Notification Center Demo'),
data: [
'notification_center' => $notificationCenter
]
);
}
#[Route('/livecomponent-modal', method: Method::GET)]
public function modalDemo(): ViewResult
{
// Create modal component with closed initial state
$modal = new ModalComponent(
id: 'modal:demo',
initialData: [
'is_open' => false,
'title' => '',
'content' => '',
'size' => 'medium',
'buttons' => [],
'show_close_button' => true,
'close_on_backdrop' => true,
'close_on_escape' => true,
'animation' => 'fade',
'z_index' => 1050
]
);
return new ViewResult(
template: 'livecomponent-modal-demo',
metaData: MetaData::create('LiveComponent Modal Demo'),
data: [
'modal' => $modal
]
);
}
#[Route('/livecomponent-tabs', method: Method::GET)]
public function tabsDemo(): ViewResult
{
// Create tabs component with initial tabs
$tabs = new TabsComponent(
id: 'tabs:demo',
initialData: [
'tabs' => [
[
'id' => 'tab_1',
'title' => 'Übersicht',
'content' => '<h3>Willkommen</h3><p>Dies ist der Übersichts-Tab mit wichtigen Informationen.</p><ul><li>Feature 1</li><li>Feature 2</li><li>Feature 3</li></ul>',
'closable' => false,
'created_at' => time()
],
[
'id' => 'tab_2',
'title' => 'Details',
'content' => '<h3>Detaillierte Informationen</h3><p>Hier finden Sie weitere Details und spezifische Daten.</p><p>Dieser Tab kann geschlossen werden.</p>',
'closable' => true,
'created_at' => time()
],
[
'id' => 'tab_3',
'title' => 'Einstellungen',
'content' => '<h3>Konfiguration</h3><p>Verwalten Sie hier Ihre Einstellungen und Präferenzen.</p>',
'closable' => true,
'created_at' => time()
]
],
'active_tab' => 'tab_1',
'max_tabs' => 10,
'allow_close' => true,
'allow_add' => true,
'tab_style' => 'default'
]
);
return new ViewResult(
template: 'livecomponent-tabs-demo',
metaData: MetaData::create('LiveComponent Tabs Demo'),
data: [
'tabs' => $tabs
]
);
}
#[Route('/livecomponent-comment-thread', method: Method::GET)]
public function commentThreadDemo(): ViewResult
{
// Create comment thread component with initial demo comments
$commentThread = new CommentThreadComponent(
id: 'comment-thread:demo',
initialData: [
'comments' => [],
'sort_by' => 'newest',
'show_reactions' => true,
'allow_edit' => true,
'allow_delete' => true,
'max_nesting_level' => 3
]
);
return new ViewResult(
template: 'livecomponent-comment-thread-demo',
metaData: MetaData::create('LiveComponent Comment Thread Demo'),
data: [
'comment_thread' => $commentThread
]
);
}
#[Route('/livecomponent-live-presence', method: Method::GET)]
public function livePresenceDemo(): ViewResult
{
// Create live presence component with empty initial state
$livePresence = new LivePresenceComponent(
id: 'live-presence:demo',
initialData: [
'users' => [],
'show_avatars' => true,
'show_names' => true,
'show_count' => true,
'max_visible_users' => 10,
'presence_timeout' => 300, // 5 minutes
'allow_anonymous' => false
]
);
return new ViewResult(
template: 'livecomponent-live-presence-demo',
metaData: MetaData::create('LiveComponent Live Presence Demo'),
data: [
'live_presence' => $livePresence
]
);
}
#[Route('/livecomponent-shopping-cart', method: Method::GET)]
public function shoppingCartDemo(): ViewResult
{
// Create shopping cart component with empty initial state
$shoppingCart = new ShoppingCartComponent(
id: 'shopping-cart:demo',
initialData: [
'items' => [],
'discount_code' => null,
'discount_percentage' => 0,
'shipping_method' => 'standard',
'tax_rate' => 0.19,
'currency' => 'EUR',
'min_order_value' => 0,
'free_shipping_threshold' => 50
]
);
return new ViewResult(
template: 'livecomponent-shopping-cart-demo',
metaData: MetaData::create('LiveComponent Shopping Cart Demo'),
data: [
'shopping_cart' => $shoppingCart
]
);
}
#[Route('/livecomponent-product-filter', method: Method::GET)]
public function productFilterDemo(): ViewResult
{
// Create product filter component with initial state
$productFilter = new ProductFilterComponent(
id: 'product-filter:demo',
initialData: [
'active_filters' => [
'category' => null,
'price_min' => 0,
'price_max' => 1000,
'brands' => [],
'colors' => [],
'sizes' => [],
'min_rating' => 0,
'in_stock_only' => false
],
'available_categories' => [
'electronics' => 'Electronics',
'fashion' => 'Fashion',
'home' => 'Home & Living',
'sports' => 'Sports',
'beauty' => 'Beauty'
],
'available_brands' => [
'TechPro' => ['label' => 'TechPro', 'count' => 3],
'SmartTech' => ['label' => 'SmartTech', 'count' => 2],
'FashionBrand' => ['label' => 'FashionBrand', 'count' => 1],
'StyleCo' => ['label' => 'StyleCo', 'count' => 1],
'HomeLiving' => ['label' => 'HomeLiving', 'count' => 1],
'SportGear' => ['label' => 'SportGear', 'count' => 1]
],
'available_colors' => [
'red' => ['label' => 'Rot', 'hex' => '#f44336', 'count' => 2],
'blue' => ['label' => 'Blau', 'hex' => '#2196F3', 'count' => 2],
'green' => ['label' => 'Grün', 'hex' => '#4CAF50', 'count' => 1],
'orange' => ['label' => 'Orange', 'hex' => '#FF9800', 'count' => 1],
'purple' => ['label' => 'Lila', 'hex' => '#9C27B0', 'count' => 1],
'cyan' => ['label' => 'Cyan', 'hex' => '#00BCD4', 'count' => 1],
'pink' => ['label' => 'Pink', 'hex' => '#E91E63', 'count' => 1]
],
'available_sizes' => [
'XS' => ['label' => 'XS', 'count' => 0],
'S' => ['label' => 'S', 'count' => 4],
'M' => ['label' => 'M', 'count' => 2],
'L' => ['label' => 'L', 'count' => 1],
'XL' => ['label' => 'XL', 'count' => 2],
'XXL' => ['label' => 'XXL', 'count' => 0]
],
'price_range' => ['min' => 0, 'max' => 1000],
'sort_by' => 'relevance',
'result_count' => 8
]
);
return new ViewResult(
template: 'livecomponent-product-filter-demo',
metaData: MetaData::create('LiveComponent Product Filter Demo'),
data: [
'product_filter' => $productFilter
]
);
}
#[Route('/livecomponent-activity-feed', method: Method::GET)]
public function activityFeedDemo(): ViewResult
{
// Create activity feed component with DemoActivityDataProvider
$activityFeed = new ActivityFeedComponent(
id: 'activity-feed:demo',
dataProvider: $this->activityDataProvider,
initialData: [
'activities' => [], // Will be loaded from dataProvider
'filter' => 'all',
'page' => 1,
'page_size' => 20,
'show_avatars' => true,
'show_timestamps' => true,
'group_by_date' => true
]
);
return new ViewResult(
template: 'livecomponent-activity-feed-demo',
metaData: MetaData::create('LiveComponent Activity Feed Demo'),
data: [
'activity_feed' => $activityFeed
]
);
}
#[Route('/livecomponent-metrics-dashboard', method: Method::GET)]
public function metricsDashboardDemo(): ViewResult
{
// Create metrics dashboard component with DemoMetricsDataProvider
$metricsDashboard = new MetricsDashboardComponent(
id: 'metrics-dashboard:demo',
dataProvider: $this->metricsDataProvider,
initialData: [
'metrics' => [], // Will be loaded from dataProvider
'time_range' => '30d',
'last_updated' => time(),
'auto_refresh' => false,
'refresh_interval' => 60
]
);
return new ViewResult(
template: 'livecomponent-metrics-dashboard-demo',
metaData: MetaData::create('LiveComponent Metrics Dashboard Demo'),
data: [
'metrics_dashboard' => $metricsDashboard
]
);
}
private function getDemoData(): array
{
return [
['id' => 1, 'name' => 'John Doe', 'email' => 'john@example.com', 'role' => 'Admin', 'status' => 'Active', 'created' => '2024-01-15'],
['id' => 2, 'name' => 'Jane Smith', 'email' => 'jane@example.com', 'role' => 'User', 'status' => 'Active', 'created' => '2024-02-20'],
['id' => 3, 'name' => 'Bob Johnson', 'email' => 'bob@example.com', 'role' => 'Editor', 'status' => 'Inactive', 'created' => '2024-03-10'],
['id' => 4, 'name' => 'Alice Williams', 'email' => 'alice@example.com', 'role' => 'User', 'status' => 'Active', 'created' => '2024-03-25'],
['id' => 5, 'name' => 'Charlie Brown', 'email' => 'charlie@example.com', 'role' => 'Admin', 'status' => 'Active', 'created' => '2024-04-05'],
['id' => 6, 'name' => 'David Miller', 'email' => 'david@example.com', 'role' => 'User', 'status' => 'Inactive', 'created' => '2024-04-15'],
['id' => 7, 'name' => 'Eva Davis', 'email' => 'eva@example.com', 'role' => 'Editor', 'status' => 'Active', 'created' => '2024-05-01'],
['id' => 8, 'name' => 'Frank Wilson', 'email' => 'frank@example.com', 'role' => 'User', 'status' => 'Active', 'created' => '2024-05-10'],
['id' => 9, 'name' => 'Grace Lee', 'email' => 'grace@example.com', 'role' => 'Admin', 'status' => 'Active', 'created' => '2024-05-15'],
['id' => 10, 'name' => 'Henry Garcia', 'email' => 'henry@example.com', 'role' => 'User', 'status' => 'Inactive', 'created' => '2024-05-20'],
['id' => 11, 'name' => 'Iris Martinez', 'email' => 'iris@example.com', 'role' => 'Editor', 'status' => 'Active', 'created' => '2024-06-01'],
['id' => 12, 'name' => 'Jack Robinson', 'email' => 'jack@example.com', 'role' => 'User', 'status' => 'Active', 'created' => '2024-06-10'],
['id' => 13, 'name' => 'Kate Anderson', 'email' => 'kate@example.com', 'role' => 'Admin', 'status' => 'Active', 'created' => '2024-06-15'],
['id' => 14, 'name' => 'Leo Thomas', 'email' => 'leo@example.com', 'role' => 'User', 'status' => 'Inactive', 'created' => '2024-06-20'],
['id' => 15, 'name' => 'Mia Taylor', 'email' => 'mia@example.com', 'role' => 'Editor', 'status' => 'Active', 'created' => '2024-07-01'],
['id' => 16, 'name' => 'Noah Moore', 'email' => 'noah@example.com', 'role' => 'User', 'status' => 'Active', 'created' => '2024-07-10'],
['id' => 17, 'name' => 'Olivia Jackson', 'email' => 'olivia@example.com', 'role' => 'Admin', 'status' => 'Active', 'created' => '2024-07-15'],
['id' => 18, 'name' => 'Paul White', 'email' => 'paul@example.com', 'role' => 'User', 'status' => 'Inactive', 'created' => '2024-07-20'],
['id' => 19, 'name' => 'Quinn Harris', 'email' => 'quinn@example.com', 'role' => 'Editor', 'status' => 'Active', 'created' => '2024-08-01'],
['id' => 20, 'name' => 'Ryan Clark', 'email' => 'ryan@example.com', 'role' => 'User', 'status' => 'Active', 'created' => '2024-08-10'],
];
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Application\Controllers\Test;
use App\Application\Components\CounterComponent;
use App\Framework\Attributes\Route;
use App\Framework\Http\Method;
use App\Framework\LiveComponents\ComponentRegistry;
use App\Framework\Meta\MetaData;
use App\Framework\Router\Result\ViewResult;
final readonly class LiveComponentTestController
{
public function __construct(
private ComponentRegistry $componentRegistry
) {
}
#[Route('/test/livecomponents', method: Method::GET)]
public function index(): ViewResult
{
// Create Counter Component (no TemplateRenderer needed)
// The component will be automatically rendered by PlaceholderReplacer
$counter = new CounterComponent(
id: ComponentRegistry::makeId('counter', 'demo'),
initialData: ['count' => 0]
);
return new ViewResult(
template: 'livecomponents',
metaData: MetaData::create(
title: 'LiveComponents Test Suite',
description: 'Zero-Dependency Interactive Components Test'
),
data: [
'counter' => $counter, // Direkt das Component-Objekt übergeben
'counterId' => $counter->getId(),
]
);
}
}

View File

@@ -0,0 +1,188 @@
# LiveComponents Test Suite
## Quick Start
1. **Stelle sicher, dass die Server laufen**:
```bash
make up
npm run dev
```
2. **Öffne die Test-Seite**:
```
https://localhost/test/livecomponents
```
3. **Teste die Features**:
- ✅ Click Actions (Increment/Decrement/Reset)
- ✅ Form Submission (Add Amount)
- ✅ Polling (Watch for auto-updates every 10s)
- ✅ State Management (Count + Last Update)
## Test Component: CounterComponent
### Location
- **Controller**: `src/Application/Controllers/Test/LiveComponentTestController.php`
- **Component**: `src/Application/Components/CounterComponent.php`
- **Template**: `src/Framework/LiveComponents/Templates/counter.view.php`
- **View**: `resources/views/test/livecomponents.view.php`
### Features Tested
1. **Basic Actions**:
- `increment()` - Increment counter by 1
- `decrement()` - Decrement counter by 1 (min: 0)
- `reset()` - Reset counter to 0
2. **Parameter Actions**:
- `addAmount(int $amount)` - Add custom amount
3. **Polling**:
- Auto-updates every 10 seconds
- Updates timestamp on each poll
4. **State Management**:
- `count` - Current counter value
- `last_update` - Last update timestamp
- `server_time` - Server timestamp (polling only)
### Code Example
```php
// In Controller
$counter = new CounterComponent(
id: ComponentRegistry::makeId(CounterComponent::class, 'demo'),
initialData: ['count' => 0]
);
return new ViewResult('test/livecomponents', [
'counter' => $counter
]);
```
```html
<!-- In Template -->
{!! counter.toHtml() !!}
```
## Debugging
### Browser Console
Open Developer Console (F12) to see:
- Component initialization
- Action execution
- Polling activity
- State updates
### Network Tab
Monitor these requests:
- `POST /live-component/App\Application\Components\CounterComponent:demo`
- Request Body: `{ component_id, method, params, state }`
- Response: `{ html, events, state }`
### JavaScript API
```javascript
// Manual action call
window.liveComponents.callAction(
'App\\Application\\Components\\CounterComponent:demo',
'increment',
{}
);
// Stop polling
window.liveComponents.stopPolling('App\\Application\\Components\\CounterComponent:demo');
// Start polling
window.liveComponents.startPolling('App\\Application\\Components\\CounterComponent:demo', 10000);
```
## Adding More Test Components
1. **Create Component Class**:
```php
namespace App\Application\Components;
final readonly class MyTestComponent implements LiveComponentContract
{
use LiveComponentTrait;
public function __construct(string $id, array $initialData = [], ?TemplateRenderer $templateRenderer = null) {
$this->id = $id;
$this->initialData = $initialData;
$this->templateRenderer = $templateRenderer;
}
public function render(): string {
return $this->template('Framework/LiveComponents/Templates/my-test', [
'data' => $this->initialData
]);
}
public function myAction(): array {
return ['updated' => true];
}
}
```
2. **Create Template**:
```html
<!-- src/Framework/LiveComponents/Templates/my-test.view.php -->
<div>
<button data-live-action="myAction">Test Action</button>
</div>
```
3. **Add to Test Controller**:
```php
$myTest = new MyTestComponent(
id: ComponentRegistry::makeId(MyTestComponent::class, 'test'),
initialData: []
);
```
4. **Render in View**:
```html
{!! myTest.toHtml() !!}
```
## Troubleshooting
### Component not initializing
- ✅ Check JavaScript is loaded: `/js/live-components.js`
- ✅ Check browser console for errors
- ✅ Verify `data-live-component` attribute exists
### Actions not working
- ✅ Check method exists in component class
- ✅ Check `data-live-action` attribute
- ✅ Monitor Network tab for failed requests
### Polling not working
- ✅ Component must implement `Pollable` interface
- ✅ Check `data-poll-interval` attribute
- ✅ Verify `getPollInterval()` returns milliseconds
### Template not rendering
- ✅ Verify template path in `render()` method
- ✅ Check template exists in `src/Framework/LiveComponents/Templates/`
- ✅ Ensure TemplateRenderer is injected
## Next Steps
1. **Test File Upload**:
- Create `FileUploadTestComponent implements Uploadable`
- Test upload progress tracking
- Validate file size/type with `Byte` VO
2. **Test SSE Integration**:
- Create SSE endpoint for component stream
- Connect via `window.sseManager.connect()`
- Test real-time updates
3. **Test Nested Components**:
- Create parent-child component structure
- Test inter-component communication
- Event dispatching between components

View File

@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Application\Debug;
use App\Framework\OutputBuffer\OutputBuffer;
/**
* Debug Code Evaluator
*
* Safely evaluates PHP code in a controlled environment.
* Captures output, return values, and execution time.
*/
final readonly class DebugCodeEvaluator
{
public function evaluate(string $code): EvaluationResult
{
$startTime = microtime(true);
// Capture output using framework's OutputBuffer
$captured = OutputBuffer::capture(function () use ($code, &$returnValue) {
// Evaluate code
// Note: eval is used here intentionally for REPL functionality
// This is ONLY safe because it's restricted to development mode
$returnValue = eval($code);
return $returnValue;
});
$type = get_debug_type($captured->result);
$output = $captured->content;
// Format output
if ($captured->result !== null) {
$formattedValue = $this->formatValue($captured->result);
$output .= $formattedValue;
}
$executionTime = microtime(true) - $startTime;
return new EvaluationResult(
output: $output ?: '(no output)',
type: $type,
executionTime: $executionTime,
rawValue: $captured->result
);
}
private function formatValue(mixed $value): string
{
if (is_scalar($value)) {
return var_export($value, true);
}
if (is_array($value)) {
return $this->formatArray($value);
}
if (is_object($value)) {
return $this->formatObject($value);
}
return var_export($value, true);
}
private function formatArray(array $array): string
{
if (empty($array)) {
return '[]';
}
$formatted = "[\n";
foreach ($array as $key => $value) {
$formattedKey = is_string($key) ? "'{$key}'" : $key;
$formattedValue = is_scalar($value) ? var_export($value, true) : get_debug_type($value);
$formatted .= " {$formattedKey} => {$formattedValue},\n";
}
$formatted .= ']';
return $formatted;
}
private function formatObject(object $object): string
{
$reflection = new \ReflectionClass($object);
$className = $reflection->getName();
$properties = [];
foreach ($reflection->getProperties() as $property) {
$property->setAccessible(true);
$name = $property->getName();
$value = $property->getValue($object);
$properties[$name] = is_scalar($value) ? $value : get_debug_type($value);
}
return sprintf(
"%s {\n%s\n}",
$className,
$this->formatArray($properties)
);
}
}

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Application\Debug;
use App\Framework\Attributes\Route;
use App\Framework\Config\Environment;
use App\Framework\Config\EnvKey;
use App\Framework\Http\Method;
use App\Framework\Http\Request;
use App\Framework\Router\Result\JsonResult;
use App\Framework\Router\Result\ViewResult;
/**
* Interactive Debug Console Controller
*
* Provides a web-based REPL interface for debugging and development.
* Only available in development mode for security.
*/
final readonly class DebugConsoleController
{
public function __construct(
private Environment $environment,
private DebugCodeEvaluator $evaluator,
private DebugHistoryManager $historyManager
) {
}
/**
* Show the debug console UI
*/
#[Route(path: '/debug/console', method: Method::GET)]
public function console(Request $request): ViewResult
{
$this->ensureDevelopmentMode();
return new ViewResult(
template: 'debug/console',
data: [
'history' => $this->historyManager->getHistory(limit: 50),
'context' => $this->getContextInfo($request),
]
);
}
/**
* Execute PHP code via AJAX
*/
#[Route(path: '/debug/console/execute', method: Method::POST)]
public function execute(Request $request): JsonResult
{
$this->ensureDevelopmentMode();
$code = $request->parsedBody['code'] ?? '';
if (empty($code)) {
return new JsonResult([
'success' => false,
'error' => 'No code provided',
], 400);
}
try {
$result = $this->evaluator->evaluate($code);
// Save to history
$this->historyManager->addEntry($code, $result);
return new JsonResult([
'success' => true,
'result' => $result->output,
'type' => $result->type,
'executionTime' => $result->executionTime,
]);
} catch (\Throwable $e) {
return new JsonResult([
'success' => false,
'error' => $e->getMessage(),
'trace' => $this->formatTrace($e),
], 500);
}
}
/**
* Get command history
*/
#[Route(path: '/debug/console/history', method: Method::GET)]
public function history(Request $request): JsonResult
{
$this->ensureDevelopmentMode();
$limit = (int) ($request->query->get('limit') ?? 50);
return new JsonResult([
'history' => $this->historyManager->getHistory($limit),
]);
}
/**
* Clear command history
*/
#[Route(path: '/debug/console/history/clear', method: Method::POST)]
public function clearHistory(Request $request): JsonResult
{
$this->ensureDevelopmentMode();
$this->historyManager->clearHistory();
return new JsonResult([
'success' => true,
'message' => 'History cleared successfully',
]);
}
private function ensureDevelopmentMode(): void
{
if (! $this->environment->getBool(EnvKey::APP_DEBUG, false)) {
throw new \RuntimeException('Debug Console is only available in development mode');
}
}
private function getContextInfo(Request $request): array
{
return [
'php_version' => PHP_VERSION,
'framework_version' => '1.0.0', // TODO: Get from config
'memory_usage' => memory_get_usage(true),
'memory_peak' => memory_get_peak_usage(true),
'loaded_extensions' => get_loaded_extensions(),
];
}
private function formatTrace(\Throwable $e): array
{
return array_map(function ($trace) {
return [
'file' => $trace['file'] ?? 'unknown',
'line' => $trace['line'] ?? 0,
'function' => $trace['function'] ?? 'unknown',
'class' => $trace['class'] ?? null,
];
}, array_slice($e->getTrace(), 0, 10)); // Limit to 10 frames
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Application\Debug;
use App\Framework\Cache\Cache;
use App\Framework\Cache\CacheKey;
use App\Framework\Core\ValueObjects\Duration;
/**
* Debug History Manager
*
* Manages command history for the debug console.
* Uses cache for persistence across requests.
*/
final readonly class DebugHistoryManager
{
private const CACHE_KEY = 'debug_console_history';
private const MAX_HISTORY_SIZE = 100;
public function __construct(
private Cache $cache
) {
}
public function addEntry(string $code, EvaluationResult $result): void
{
$history = $this->getHistory();
$entry = [
'code' => $code,
'output' => $result->output,
'type' => $result->type,
'executionTime' => $result->executionTime,
'timestamp' => time(),
];
// Add to beginning of array
array_unshift($history, $entry);
// Limit size
if (count($history) > self::MAX_HISTORY_SIZE) {
$history = array_slice($history, 0, self::MAX_HISTORY_SIZE);
}
// Save to cache (24 hour TTL)
$this->cache->set(
CacheKey::fromString(self::CACHE_KEY),
$history,
Duration::fromHours(24)
);
}
public function getHistory(int $limit = 50): array
{
$history = $this->cache->get(CacheKey::fromString(self::CACHE_KEY)) ?? [];
if ($limit > 0 && count($history) > $limit) {
$history = array_slice($history, 0, $limit);
}
return $history;
}
public function clearHistory(): void
{
$this->cache->delete(CacheKey::fromString(self::CACHE_KEY));
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Application\Debug;
/**
* Evaluation Result Value Object
*
* Represents the result of code evaluation in the debug console.
*/
final readonly class EvaluationResult
{
public function __construct(
public string $output,
public string $type,
public float $executionTime,
public mixed $rawValue = null
) {
}
}

View File

@@ -0,0 +1,454 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Debug Console - Interactive PHP REPL</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
background: #1e1e1e;
color: #d4d4d4;
height: 100vh;
display: flex;
flex-direction: column;
}
.header {
background: #252526;
border-bottom: 1px solid #3c3c3c;
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 {
font-size: 1.2rem;
color: #569cd6;
}
.header-info {
display: flex;
gap: 2rem;
font-size: 0.85rem;
color: #858585;
}
.container {
display: flex;
flex: 1;
overflow: hidden;
}
.sidebar {
width: 300px;
background: #252526;
border-right: 1px solid #3c3c3c;
display: flex;
flex-direction: column;
}
.sidebar-header {
padding: 1rem;
border-bottom: 1px solid #3c3c3c;
display: flex;
justify-content: space-between;
align-items: center;
}
.sidebar-header h2 {
font-size: 0.9rem;
color: #cccccc;
}
.clear-history-btn {
background: #c5c5c5;
color: #1e1e1e;
border: none;
padding: 0.3rem 0.8rem;
border-radius: 3px;
cursor: pointer;
font-size: 0.75rem;
font-family: inherit;
}
.clear-history-btn:hover {
background: #e0e0e0;
}
.history-list {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
.history-item {
background: #2d2d30;
border: 1px solid #3c3c3c;
border-radius: 4px;
padding: 0.8rem;
margin-bottom: 0.5rem;
cursor: pointer;
transition: background 0.2s;
}
.history-item:hover {
background: #37373d;
}
.history-item-code {
color: #ce9178;
font-size: 0.85rem;
margin-bottom: 0.3rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.history-item-meta {
font-size: 0.75rem;
color: #858585;
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
}
.editor-section {
padding: 1rem;
border-bottom: 1px solid #3c3c3c;
}
.editor-label {
font-size: 0.9rem;
color: #cccccc;
margin-bottom: 0.5rem;
}
#code-editor {
width: 100%;
min-height: 150px;
background: #1e1e1e;
color: #d4d4d4;
border: 1px solid #3c3c3c;
border-radius: 4px;
padding: 1rem;
font-family: inherit;
font-size: 0.95rem;
resize: vertical;
}
#code-editor:focus {
outline: 1px solid #569cd6;
}
.controls {
display: flex;
gap: 1rem;
margin-top: 1rem;
}
.execute-btn {
background: #0e639c;
color: white;
border: none;
padding: 0.6rem 1.5rem;
border-radius: 4px;
cursor: pointer;
font-family: inherit;
font-size: 0.9rem;
}
.execute-btn:hover {
background: #1177bb;
}
.execute-btn:disabled {
background: #3c3c3c;
cursor: not-allowed;
}
.output-section {
flex: 1;
padding: 1rem;
overflow-y: auto;
}
.output-label {
font-size: 0.9rem;
color: #cccccc;
margin-bottom: 0.5rem;
}
.output-container {
background: #1e1e1e;
border: 1px solid #3c3c3c;
border-radius: 4px;
padding: 1rem;
min-height: 100px;
}
.output-entry {
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid #3c3c3c;
}
.output-entry:last-child {
border-bottom: none;
}
.output-success {
color: #4ec9b0;
}
.output-error {
color: #f48771;
}
.output-meta {
font-size: 0.8rem;
color: #858585;
margin-top: 0.3rem;
}
.output-trace {
background: #2d2d30;
border-left: 3px solid #f48771;
padding: 0.5rem;
margin-top: 0.5rem;
font-size: 0.8rem;
overflow-x: auto;
}
.context-info {
padding: 1rem;
background: #2d2d30;
border-top: 1px solid #3c3c3c;
font-size: 0.8rem;
color: #858585;
}
pre {
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: #1e1e1e;
}
::-webkit-scrollbar-thumb {
background: #424242;
border-radius: 5px;
}
::-webkit-scrollbar-thumb:hover {
background: #4e4e4e;
}
</style>
</head>
<body>
<div class="header">
<h1>🐛 Debug Console - Interactive PHP REPL</h1>
<div class="header-info">
<span>PHP {context.php_version}</span>
<span>Memory: {context.memory_usage} bytes</span>
</div>
</div>
<div class="container">
<div class="sidebar">
<div class="sidebar-header">
<h2>Command History</h2>
<button class="clear-history-btn" onclick="clearHistory()">Clear</button>
</div>
<div class="history-list" id="history-list">
<for items="history" as="entry">
<div class="history-item" onclick="loadHistory(this)"
data-code="{entry.code}">
<div class="history-item-code">{entry.code}</div>
<div class="history-item-meta">
{entry.type} · {entry.executionTime}ms
</div>
</div>
</for>
</div>
</div>
<div class="main-content">
<div class="editor-section">
<div class="editor-label">PHP Code (without &lt;?php tags)</div>
<textarea id="code-editor" placeholder="Enter PHP code here...
Example:
return 2 + 2;
or
$users = ['Alice', 'Bob', 'Charlie'];
foreach ($users as $user) {
echo $user . &quot;\n&quot;;
}
return count($users);"></textarea>
<div class="controls">
<button class="execute-btn" onclick="executeCode()" id="execute-btn">
Execute (Ctrl+Enter)
</button>
<span id="status" style="color: #858585; align-self: center;"></span>
</div>
</div>
<div class="output-section">
<div class="output-label">Output</div>
<div class="output-container" id="output"></div>
</div>
</div>
</div>
<script>
const codeEditor = document.getElementById('code-editor');
const executeBtn = document.getElementById('execute-btn');
const outputContainer = document.getElementById('output');
const statusEl = document.getElementById('status');
const historyList = document.getElementById('history-list');
// Ctrl+Enter to execute
codeEditor.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'Enter') {
e.preventDefault();
executeCode();
}
});
async function executeCode() {
const code = codeEditor.value.trim();
if (!code) {
return;
}
executeBtn.disabled = true;
statusEl.textContent = 'Executing...';
try {
const response = await fetch('/debug/console/execute', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ code })
});
const result = await response.json();
if (result.success) {
addOutput('success', result.result, result.type, result.executionTime);
// Reload history
await loadHistoryList();
} else {
addOutput('error', result.error, null, null, result.trace);
}
statusEl.textContent = '';
} catch (error) {
addOutput('error', error.message);
statusEl.textContent = '';
} finally {
executeBtn.disabled = false;
}
}
function addOutput(type, content, dataType = null, execTime = null, trace = null) {
const entry = document.createElement('div');
entry.className = 'output-entry';
const output = document.createElement('pre');
output.className = type === 'success' ? 'output-success' : 'output-error';
output.textContent = content;
entry.appendChild(output);
if (dataType || execTime) {
const meta = document.createElement('div');
meta.className = 'output-meta';
meta.textContent = `Type: ${dataType} · Execution: ${execTime.toFixed(4)}ms`;
entry.appendChild(meta);
}
if (trace) {
const traceEl = document.createElement('div');
traceEl.className = 'output-trace';
traceEl.innerHTML = '<strong>Stack Trace:</strong>\n' +
trace.map(t => `${t.file}:${t.line} ${t.class || ''}${t.function}()`).join('\n');
entry.appendChild(traceEl);
}
outputContainer.prepend(entry);
}
async function loadHistoryList() {
try {
const response = await fetch('/debug/console/history');
const data = await response.json();
historyList.innerHTML = data.history.map(entry => `
<div class="history-item" onclick="loadHistory(this)"
data-code="${escapeHtml(entry.code)}">
<div class="history-item-code">${escapeHtml(entry.code)}</div>
<div class="history-item-meta">
${entry.type} · ${entry.executionTime.toFixed(4)}ms
</div>
</div>
`).join('');
} catch (error) {
console.error('Failed to load history:', error);
}
}
function loadHistory(element) {
const code = element.dataset.code;
codeEditor.value = code;
codeEditor.focus();
}
async function clearHistory() {
if (!confirm('Clear all command history?')) {
return;
}
try {
await fetch('/debug/console/history/clear', { method: 'POST' });
historyList.innerHTML = '';
statusEl.textContent = 'History cleared';
setTimeout(() => statusEl.textContent = '', 2000);
} catch (error) {
statusEl.textContent = 'Failed to clear history';
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Focus editor on load
codeEditor.focus();
</script>
</body>
</html>

View File

@@ -30,7 +30,7 @@ final readonly class HotReloadController
public function stream(): SseResult
{
// Only allow in development
if (! $this->environment->getBool(EnvKey::APP_DEBUG, false)) {
if (! $this->environment->getBool(EnvKey::APP_DEBUG)) {
throw new \RuntimeException('Hot Reload is only available in development mode');
}
@@ -53,7 +53,7 @@ final readonly class HotReloadController
sleep(1);
}
// Stop when connection is closed
// Stop when the connection is closed
$this->hotReloadServer->stop();
} finally {
// Restore original error reporting

View File

@@ -43,8 +43,8 @@ final readonly class GraphQLController
if (empty($query)) {
return new JsonResult([
'errors' => [
['message' => 'No query provided']
]
['message' => 'No query provided'],
],
], statusCode: 400);
}
@@ -73,7 +73,7 @@ final readonly class GraphQLController
{
return new JsonResult([
'schema' => $this->schema->toSDL(),
'stats' => $this->schema->getStats()
'stats' => $this->schema->getStats(),
]);
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Application\GraphQL;
use App\Framework\Attributes\Route;
use App\Framework\Config\Environment;
use App\Framework\Config\EnvKey;
use App\Framework\Http\Method;
use App\Framework\Http\Request\HttpRequest;
use App\Framework\Router\Result\ViewResult;
/**
* GraphQL Playground Controller
*
* Provides an interactive UI for testing GraphQL queries, mutations, and subscriptions
* Only available in development mode
*/
final readonly class GraphQLPlaygroundController
{
public function __construct(
private Environment $environment
) {
}
/**
* GraphQL Playground UI
*
* Interactive interface for:
* - Writing and executing queries
* - Testing mutations
* - Subscribing to real-time updates
* - Exploring schema with auto-completion
*/
#[Route(path: '/graphql/playground', method: Method::GET)]
public function playground(HttpRequest $request): ViewResult
{
// Only allow in development mode
if (! $this->environment->getBool(EnvKey::APP_DEBUG, false)) {
throw new \RuntimeException('GraphQL Playground is only available in development mode');
}
$protocol = $request->isSecure() ? 'https' : 'http';
$wsProtocol = $request->isSecure() ? 'wss' : 'ws';
$host = $request->server->getHttpHost();
return new ViewResult(
template: 'graphql/playground',
data: [
'graphql_endpoint' => "{$protocol}://{$host}/graphql",
'graphql_ws_endpoint' => "{$wsProtocol}://{$host}/graphql",
'graphql_schema_endpoint' => "{$protocol}://{$host}/graphql/schema",
]
);
}
}

View File

@@ -55,10 +55,8 @@ final readonly class UserQueries
public function users(
#[GraphQLArg(description: 'Maximum number of users to return')]
int $limit = 10,
#[GraphQLArg(description: 'Number of users to skip')]
int $offset = 0,
#[GraphQLArg(description: 'Filter by active status')]
?bool $active = null
): array {
@@ -66,7 +64,7 @@ final readonly class UserQueries
// Apply filters
if ($active !== null) {
$users = array_filter($users, fn($user) => $user->active === $active);
$users = array_filter($users, fn ($user) => $user->active === $active);
}
// Apply pagination
@@ -74,7 +72,7 @@ final readonly class UserQueries
// Convert to UserType
return array_map(
fn($user) => new UserType(
fn ($user) => new UserType(
id: $user->id,
name: $user->name,
email: $user->email,

View File

@@ -19,16 +19,12 @@ final readonly class UserType
public function __construct(
#[GraphQLField(description: 'Unique user identifier')]
public int $id,
#[GraphQLField(description: 'User full name')]
public string $name,
#[GraphQLField(description: 'User email address')]
public EmailAddress $email,
#[GraphQLField(description: 'Whether the user is active')]
public bool $active,
#[GraphQLField(description: 'User registration timestamp')]
public ?int $createdAt = null
) {

View File

@@ -0,0 +1,113 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GraphQL Playground</title>
<!-- GraphiQL CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/graphiql@3/graphiql.min.css" />
<style>
body {
margin: 0;
padding: 0;
height: 100vh;
overflow: hidden;
}
#graphiql {
height: 100vh;
}
.graphiql-container {
height: 100%;
}
</style>
</head>
<body>
<div id="graphiql">Loading GraphQL Playground...</div>
<!-- React, GraphiQL and dependencies -->
<script crossorigin src="https://cdn.jsdelivr.net/npm/react@18/umd/react.production.min.js"></script>
<script crossorigin src="https://cdn.jsdelivr.net/npm/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/graphiql@3/graphiql.min.js"></script>
<script>
// GraphQL endpoints from server
const GRAPHQL_ENDPOINT = '{graphql_endpoint}';
const GRAPHQL_WS_ENDPOINT = '{graphql_ws_endpoint}';
const SCHEMA_ENDPOINT = '{graphql_schema_endpoint}';
// GraphQL fetcher for queries and mutations
const fetcher = GraphiQL.createFetcher({
url: GRAPHQL_ENDPOINT,
subscriptionUrl: GRAPHQL_WS_ENDPOINT,
});
// Default query example
const defaultQuery = `# Welcome to GraphQL Playground!
#
# Type queries here and they will be executed against the GraphQL API.
#
# Example query:
query GetUsers {
users {
id
name
email
}
}
# Example mutation:
# mutation CreateUser($input: CreateUserInput!) {
# createUser(input: $input) {
# id
# name
# email
# }
# }
# Example subscription (requires WebSocket):
# subscription OnUserCreated {
# userCreated {
# id
# name
# email
# createdAt
# }
# }
`;
// Initialize GraphiQL
const root = ReactDOM.createRoot(document.getElementById('graphiql'));
root.render(
React.createElement(GraphiQL, {
fetcher: fetcher,
defaultQuery: defaultQuery,
defaultTabs: [
{
query: defaultQuery,
variables: '{}',
}
],
headerEditorEnabled: true,
shouldPersistHeaders: true,
})
);
// Add custom styling and features
document.addEventListener('DOMContentLoaded', function() {
// Auto-fetch schema on load
fetch(SCHEMA_ENDPOINT)
.then(response => response.json())
.then(data => {
console.log('GraphQL Schema loaded:', data);
})
.catch(error => {
console.error('Failed to load schema:', error);
});
});
</script>
</body>
</html>

View File

@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
namespace App\Application\Health;
use App\Framework\Attributes\Route;
use App\Framework\DateTime\Clock;
use App\Framework\Health\HealthCheckCategory;
use App\Framework\Health\HealthCheckManager;
use App\Framework\Http\Method;
use App\Framework\Http\Status;
use App\Framework\Router\Result\JsonResult;
/**
* Enhanced Health Check Controller with comprehensive monitoring
*
* Provides multiple health check endpoints:
* - /health/detailed - Full health report with all checks
* - /health/summary - Quick summary of overall health
* - /health/category/{category} - Health checks by category
*/
final readonly class EnhancedHealthCheckController
{
public function __construct(
private HealthCheckManager $healthCheckManager,
private Clock $clock
) {
}
/**
* Detailed health report with all registered health checks
*/
#[Route(path: '/health/detailed', method: Method::GET)]
public function detailed(): JsonResult
{
$startTime = $this->clock->time();
$healthReport = $this->healthCheckManager->runAllChecks();
$checks = [];
foreach ($healthReport->results as $name => $result) {
$checks[$name] = [
'status' => $result->status->value,
'healthy' => $result->status->isHealthy(),
'message' => $result->message,
'response_time_ms' => $result->responseTimeMs,
'details' => $result->details->toArray(),
];
if ($result->exception !== null) {
$checks[$name]['error'] = [
'type' => get_class($result->exception),
'message' => $result->exception->getMessage(),
];
}
}
$responseTime = $this->clock->time()->diff($startTime);
return new JsonResult(
data: [
'timestamp' => $this->clock->now()->format('c'),
'overall_status' => $healthReport->overallStatus->value,
'overall_healthy' => $healthReport->overallStatus->isHealthy(),
'checks' => $checks,
'registered_checks' => $this->healthCheckManager->getRegisteredChecks(),
'checks_by_category' => $this->healthCheckManager->getChecksByCategory(),
'response_time_ms' => round($responseTime->toMilliseconds(), 2),
],
status: Status::from($healthReport->overallStatus->isHealthy() ? 200 : 503)
);
}
/**
* Quick summary of overall health status
*/
#[Route(path: '/health/summary', method: Method::GET)]
public function summary(): JsonResult
{
$healthReport = $this->healthCheckManager->runAllChecks();
$healthyCount = 0;
$warningCount = 0;
$unhealthyCount = 0;
foreach ($healthReport->results as $result) {
match ($result->status->value) {
'healthy' => $healthyCount++,
'warning' => $warningCount++,
'unhealthy' => $unhealthyCount++,
default => $unhealthyCount++
};
}
return new JsonResult(
data: [
'timestamp' => $this->clock->now()->format('c'),
'overall_status' => $healthReport->overallStatus->value,
'overall_healthy' => $healthReport->overallStatus->isHealthy(),
'summary' => [
'total_checks' => count($healthReport->results),
'healthy' => $healthyCount,
'warning' => $warningCount,
'unhealthy' => $unhealthyCount,
],
],
status: Status::from($healthReport->overallStatus->isHealthy() ? 200 : 503)
);
}
/**
* Health checks filtered by category
*/
#[Route(path: '/health/category/{category}', method: Method::GET)]
public function byCategory(string $category): JsonResult
{
// Try to parse category
$categoryEnum = HealthCheckCategory::tryFrom($category);
if ($categoryEnum === null) {
return new JsonResult(
data: [
'error' => "Invalid category: {$category}",
'valid_categories' => array_map(
fn($cat) => $cat->value,
HealthCheckCategory::cases()
),
],
status: Status::BAD_REQUEST
);
}
$healthReport = $this->healthCheckManager->runChecksByCategory($categoryEnum);
$checks = [];
foreach ($healthReport->results as $name => $result) {
$checks[$name] = [
'status' => $result->status->value,
'healthy' => $result->status->isHealthy(),
'message' => $result->message,
'response_time_ms' => $result->responseTimeMs,
'details' => $result->details->toArray(),
];
}
return new JsonResult(
data: [
'timestamp' => $this->clock->now()->format('c'),
'category' => $category,
'overall_status' => $healthReport->overallStatus->value,
'overall_healthy' => $healthReport->overallStatus->isHealthy(),
'checks' => $checks,
],
status: Status::from($healthReport->overallStatus->isHealthy() ? 200 : 503)
);
}
/**
* Get list of registered health checks
*/
#[Route(path: '/health/checks', method: Method::GET)]
public function checks(): JsonResult
{
return new JsonResult([
'timestamp' => $this->clock->now()->format('c'),
'registered_checks' => $this->healthCheckManager->getRegisteredChecks(),
'checks_by_category' => $this->healthCheckManager->getChecksByCategory(),
'total_checks' => count($this->healthCheckManager->getRegisteredChecks()),
]);
}
}

View File

@@ -17,8 +17,8 @@ use App\Framework\Http\Method;
use App\Framework\Http\Status;
use App\Framework\Performance\MemoryMonitor;
use App\Framework\Redis\RedisConnectionPool;
use App\Framework\Router\Result\JsonResult;
use App\Framework\Router\HealthRoutes;
use App\Framework\Router\Result\JsonResult;
final readonly class HealthCheckController
{

View File

@@ -10,17 +10,17 @@ use App\Framework\Http\HeaderKey;
use App\Framework\Http\Method;
use App\Framework\Http\Request;
use App\Framework\Http\Status;
use App\Framework\Meta\MetaData;
use App\Framework\Router\ActionResult;
use App\Framework\Router\Result\Redirect;
use App\Framework\Router\Result\ViewResult;
use App\Framework\MagicLinks\Actions\ActionRegistry;
use App\Framework\MagicLinks\Commands\ExecuteMagicLinkCommand;
use App\Framework\MagicLinks\Commands\GenerateMagicLinkCommand;
use App\Framework\MagicLinks\Commands\GenerateMagicLinkHandler;
use App\Framework\MagicLinks\Services\MagicLinkService;
use App\Framework\MagicLinks\MagicLinkToken;
use App\Framework\MagicLinks\Services\MagicLinkService;
use App\Framework\MagicLinks\TokenAction;
use App\Framework\Meta\MetaData;
use App\Framework\Router\ActionResult;
use App\Framework\Router\Result\Redirect;
use App\Framework\Router\Result\ViewResult;
final readonly class MagicLink
{

View File

@@ -0,0 +1,401 @@
<?php
declare(strict_types=1);
namespace App\Application\LiveComponents\ActivityFeed;
use App\Application\LiveComponents\Services\ActivityDataProvider;
use App\Application\LiveComponents\ValueObjects\Activity;
use App\Application\LiveComponents\ValueObjects\ActivityFilter;
use App\Application\LiveComponents\ValueObjects\ActivityType;
use App\Framework\LiveComponents\Attributes\Action;
use App\Framework\LiveComponents\Attributes\LiveComponent;
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
use App\Framework\LiveComponents\DataProviderResolver;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\LiveComponents\ValueObjects\ComponentRenderData;
/**
* ActivityFeedComponent - Real-time Activity Feed with Live Updates
*
* Demonstrates:
* - Type-safe state management with ActivityFeedState Value Object
* - Real-time activity updates
* - Activity filtering by type (comments, likes, shares, follows, mentions, system)
* - Mark as read/unread functionality
* - Pagination with load more
* - Time-based grouping (today, yesterday, this week, older)
* - User avatars and relative timestamps
* - Unread count badge
* - Compile-time type safety for data access
*
* Uses ActivityDataProvider for flexible data sources (Demo, Database, API, etc.)
*/
#[LiveComponent('activity-feed')]
final readonly class ActivityFeedComponent implements LiveComponentContract
{
public function __construct(
public ComponentId $id,
public ActivityFeedState $state,
private readonly DataProviderResolver $dataProviderResolver
) {}
private function getDataProvider(): ActivityDataProvider
{
return $this->dataProviderResolver->resolve(
ActivityDataProvider::class,
$this->state->dataSource
);
}
public function getRenderData(): ComponentRenderData
{
$dataProvider = $this->getDataProvider();
// If no activities loaded yet, load from data provider
$activitiesData = $this->state->activities;
if (empty($activitiesData)) {
$filterEnum = ActivityFilter::fromString($this->state->filter);
$activities = $dataProvider->getActivities($filterEnum);
$activitiesData = $this->activitiesToArray($activities);
}
// Paginate activities
$paginatedActivities = $this->paginateActivities(
$activitiesData,
$this->state->page,
$this->state->pageSize
);
// Prepare activities for display
$preparedActivities = $this->prepareActivitiesForDisplay(
$paginatedActivities,
$this->state->showAvatars,
$this->state->showTimestamps
);
// Group by date if enabled
$groupedActivities = $this->state->groupByDate
? $this->groupActivitiesByDate($preparedActivities)
: [['date_label' => '', 'activities' => $preparedActivities]];
// Calculate stats
$unreadCount = $dataProvider->getUnreadCount();
$totalCount = count($activitiesData);
$hasMore = ($this->state->page * $this->state->pageSize) < $totalCount;
// Prepare filter options
$filterOptions = $this->prepareFilterOptions(
$this->state->filter,
$activitiesData,
$unreadCount
);
return new ComponentRenderData(
templatePath: 'livecomponent-activity-feed',
data: [
// Raw data
'activities' => $activitiesData,
'filter' => $this->state->filter,
'page' => $this->state->page,
'page_size' => $this->state->pageSize,
// Display data
'grouped_activities' => $groupedActivities,
'has_activities' => ! empty($preparedActivities),
'is_empty' => empty($preparedActivities),
'show_avatars' => $this->state->showAvatars,
'show_timestamps' => $this->state->showTimestamps,
// Stats
'unread_count' => $unreadCount,
'total_count' => $totalCount,
'showing_count' => count($preparedActivities),
'has_unread' => $unreadCount > 0,
'unread_badge' => $this->state->getUnreadBadge($unreadCount),
// Pagination
'has_more' => $hasMore,
'current_page' => $this->state->page,
'load_more_label' => $this->state->getLoadMoreLabel(),
// Filters
'filter_options' => $filterOptions,
'active_filter_label' => ActivityFilter::fromString($this->state->filter)->getLabel(),
// UI flags
'show_group_by_date' => $this->state->groupByDate,
'show_filters' => ! empty($activitiesData),
'show_mark_all_read' => $unreadCount > 0,
]
);
}
// Action methods - Type-safe parameters
#[Action]
public function addActivity(
string $activityType,
string $userId,
string $userName,
string $content,
?string $avatarUrl = null,
array $metadata = []
): ActivityFeedState {
$dataProvider = $this->getDataProvider();
// Create Activity Value Object
$activity = new Activity(
id: uniqid('activity_', true),
type: ActivityType::fromString($activityType),
userId: $userId,
userName: $userName,
content: $content,
timestamp: time(),
isRead: false,
avatarUrl: $avatarUrl,
metadata: $metadata
);
// Add via data provider
$dataProvider->addActivity($activity);
// Reload activities from provider
$filter = ActivityFilter::fromString($this->state->filter);
$activities = $dataProvider->getActivities($filter);
return $this->state->withActivities($this->activitiesToArray($activities));
}
#[Action]
public function removeActivity(string $activityId): ActivityFeedState
{
$dataProvider = $this->getDataProvider();
// Delete via data provider
$dataProvider->deleteActivity($activityId);
// Reload activities
$filter = ActivityFilter::fromString($this->state->filter);
$activities = $dataProvider->getActivities($filter);
return $this->state->withActivities($this->activitiesToArray($activities));
}
#[Action]
public function markAsRead(string $activityId): ActivityFeedState
{
$dataProvider = $this->getDataProvider();
// Mark as read via data provider
$dataProvider->markAsRead($activityId);
// Reload activities
$filter = ActivityFilter::fromString($this->state->filter);
$activities = $dataProvider->getActivities($filter);
return $this->state->withActivities($this->activitiesToArray($activities));
}
#[Action]
public function markAllAsRead(): ActivityFeedState
{
$dataProvider = $this->getDataProvider();
// Mark all as read via data provider
$dataProvider->markAllAsRead();
// Reload activities
$filter = ActivityFilter::fromString($this->state->filter);
$activities = $dataProvider->getActivities($filter);
return $this->state->withActivities($this->activitiesToArray($activities));
}
#[Action]
public function setFilter(string $filter): ActivityFeedState
{
$dataProvider = $this->getDataProvider();
// Load activities with new filter
$filterEnum = ActivityFilter::fromString($filter);
$activities = $dataProvider->getActivities($filterEnum);
return $this->state->withFilter($filter, $this->activitiesToArray($activities));
}
#[Action]
public function loadMore(): ActivityFeedState
{
return $this->state->withLoadMore();
}
#[Action]
public function clearAll(): ActivityFeedState
{
return $this->state->withCleared();
}
/**
* Get SSE channel for real-time activity updates
*
* Uses activity-scoped channel for context-based activity feeds.
* Extract context from component ID instance identifier.
*
* Examples:
* - "activity-feed:user-123" → "activity:user-123"
* - "activity-feed:global" → "activity:global"
* - "activity-feed:team-456" → "activity:team-456"
*/
public function getSseChannel(): string
{
// Extract context from component instance ID
// ComponentId format: "activity-feed:user-123" or "activity-feed:global"
$context = $this->id->instanceId;
// Activity channel for context-based updates
return "activity:{$context}";
}
// Helper methods
/**
* Convert Activity objects to arrays
*/
private function activitiesToArray(array $activities): array
{
return array_map(fn (Activity $a) => $a->toArray(), $activities);
}
private function paginateActivities(array $activities, int $page, int $pageSize): array
{
$offset = ($page - 1) * $pageSize;
return array_slice($activities, 0, $offset + $pageSize);
}
private function prepareActivitiesForDisplay(
array $activities,
bool $showAvatars,
bool $showTimestamps
): array {
return array_map(function ($activity) use ($showAvatars, $showTimestamps) {
$type = $activity['type'];
$activityType = ActivityType::fromString($type);
return [
'id' => $activity['id'],
'type' => $type,
'user_id' => $activity['user_id'],
'user_name' => $activity['user_name'],
'avatar_url' => $activity['avatar_url'] ?? $this->state->getDefaultAvatar($activity['user_name']),
'content' => $activity['content'],
'metadata' => $activity['metadata'],
'timestamp' => $activity['timestamp'],
'is_read' => $activity['is_read'],
// Display properties from ActivityType enum
'show_avatar' => $showAvatars,
'show_timestamp' => $showTimestamps,
'icon' => $activityType->getIcon(),
'icon_class' => $activityType->getIconClass(),
'type_label' => $activityType->getLabel(),
'relative_time' => $this->state->formatRelativeTime($activity['timestamp']),
'absolute_time' => $this->state->formatAbsoluteTime($activity['timestamp']),
'read_class' => $activity['is_read'] ? 'read' : 'unread',
'activity_class' => "activity-type-{$type}",
];
}, $activities);
}
private function groupActivitiesByDate(array $activities): array
{
$groups = [
'today' => [],
'yesterday' => [],
'this_week' => [],
'older' => [],
];
$now = time();
$todayStart = strtotime('today', $now);
$yesterdayStart = strtotime('yesterday', $now);
$weekStart = strtotime('last monday', $now);
foreach ($activities as $activity) {
$timestamp = $activity['timestamp'];
if ($timestamp >= $todayStart) {
$groups['today'][] = $activity;
} elseif ($timestamp >= $yesterdayStart) {
$groups['yesterday'][] = $activity;
} elseif ($timestamp >= $weekStart) {
$groups['this_week'][] = $activity;
} else {
$groups['older'][] = $activity;
}
}
$result = [];
if (! empty($groups['today'])) {
$result[] = ['date_label' => 'Heute', 'activities' => $groups['today']];
}
if (! empty($groups['yesterday'])) {
$result[] = ['date_label' => 'Gestern', 'activities' => $groups['yesterday']];
}
if (! empty($groups['this_week'])) {
$result[] = ['date_label' => 'Diese Woche', 'activities' => $groups['this_week']];
}
if (! empty($groups['older'])) {
$result[] = ['date_label' => 'Älter', 'activities' => $groups['older']];
}
return $result;
}
private function prepareFilterOptions(string $currentFilter, array $activities, int $unreadCount): array
{
$typeCounts = $this->getActivityTypeCounts($activities);
$filters = [
ActivityFilter::ALL,
ActivityFilter::UNREAD,
ActivityFilter::COMMENTS,
ActivityFilter::LIKES,
ActivityFilter::SHARES,
ActivityFilter::FOLLOWS,
ActivityFilter::MENTIONS,
ActivityFilter::SYSTEM,
];
return array_map(function (ActivityFilter $filter) use ($currentFilter, $typeCounts, $unreadCount, $activities) {
$value = $filter->value;
$count = match ($filter) {
ActivityFilter::ALL => count($activities),
ActivityFilter::UNREAD => $unreadCount,
default => $typeCounts[$value] ?? 0
};
return [
'value' => $value,
'label' => $filter->getLabel(),
'count' => $count,
'is_active' => $currentFilter === $value,
'selected' => $currentFilter === $value ? 'selected' : '',
];
}, $filters);
}
private function getActivityTypeCounts(array $activities): array
{
$counts = [];
foreach ($activities as $activity) {
$type = $activity['type'];
$counts[$type] = ($counts[$type] ?? 0) + 1;
}
return $counts;
}
}

View File

@@ -0,0 +1,251 @@
<?php
declare(strict_types=1);
namespace App\Application\LiveComponents\ActivityFeed;
use App\Application\LiveComponents\LiveComponentState;
/**
* Type-safe state for ActivityFeedComponent
*
* Provides compile-time type safety for real-time activity feed.
* All properties are readonly and immutable - transformations return new instances.
*/
final readonly class ActivityFeedState implements LiveComponentState
{
public function __construct(
public array $activities = [],
public string $filter = 'all',
public int $page = 1,
public int $pageSize = 20,
public bool $showAvatars = true,
public bool $showTimestamps = true,
public bool $groupByDate = true,
public string $dataSource = 'demo'
) {
}
/**
* Create from ComponentData (Framework → Domain conversion)
*/
public static function fromArray(array $data): self
{
return new self(
activities: ($data['activities'] ?? []),
filter: (string) ($data['filter'] ?? 'all'),
page: (int) ($data['page'] ?? 1),
pageSize: (int) ($data['page_size'] ?? 20),
showAvatars: (bool) ($data['show_avatars'] ?? true),
showTimestamps: (bool) ($data['show_timestamps'] ?? true),
groupByDate: (bool) ($data['group_by_date'] ?? true),
dataSource: (string) ($data['data_source'] ?? 'demo')
);
}
/**
* Create empty state with defaults
*/
public static function empty(): self
{
return new self();
}
/**
* Convert to ComponentData (Domain → Framework conversion)
*/
public function toArray(): array
{
return [
'activities' => $this->activities,
'filter' => $this->filter,
'page' => $this->page,
'page_size' => $this->pageSize,
'show_avatars' => $this->showAvatars,
'show_timestamps' => $this->showTimestamps,
'group_by_date' => $this->groupByDate,
'data_source' => $this->dataSource,
];
}
/**
* Update with new activities
*/
public function withActivities(array $activities): self
{
return new self(
activities: $activities,
filter: $this->filter,
page: $this->page,
pageSize: $this->pageSize,
showAvatars: $this->showAvatars,
showTimestamps: $this->showTimestamps,
groupByDate: $this->groupByDate,
dataSource: $this->dataSource
);
}
/**
* Update filter
*/
public function withFilter(string $filter, array $activities): self
{
return new self(
activities: $activities,
filter: $filter,
page: 1, // Reset to first page when filtering
pageSize: $this->pageSize,
showAvatars: $this->showAvatars,
showTimestamps: $this->showTimestamps,
groupByDate: $this->groupByDate,
dataSource: $this->dataSource
);
}
/**
* Load more (increment page)
*/
public function withLoadMore(): self
{
return new self(
activities: $this->activities,
filter: $this->filter,
page: $this->page + 1,
pageSize: $this->pageSize,
showAvatars: $this->showAvatars,
showTimestamps: $this->showTimestamps,
groupByDate: $this->groupByDate,
dataSource: $this->dataSource
);
}
/**
* Clear all activities
*/
public function withCleared(): self
{
return new self(
activities: [],
filter: 'all',
page: 1,
pageSize: $this->pageSize,
showAvatars: $this->showAvatars,
showTimestamps: $this->showTimestamps,
groupByDate: $this->groupByDate,
dataSource: $this->dataSource
);
}
/**
* Check if has activities
*/
public function hasActivities(): bool
{
return ! empty($this->activities);
}
/**
* Get paginated activities
*/
public function getPaginatedActivities(): array
{
$offset = ($this->page - 1) * $this->pageSize;
return array_slice($this->activities, 0, $offset + $this->pageSize);
}
/**
* Check if has more to load
*/
public function hasMore(): bool
{
return ($this->page * $this->pageSize) < count($this->activities);
}
/**
* Get load more label
*/
public function getLoadMoreLabel(): string
{
if (! $this->hasMore()) {
return 'Alle Aktivitäten geladen';
}
$showingCount = $this->page * $this->pageSize;
$remaining = count($this->activities) - $showingCount;
return "Weitere laden ({$remaining} verbleibend)";
}
/**
* Format relative time
*/
public function formatRelativeTime(int $timestamp): string
{
$diff = time() - $timestamp;
if ($diff < 60) {
return 'gerade eben';
}
if ($diff < 3600) {
$minutes = floor($diff / 60);
return $minutes === 1 ? 'vor 1 Minute' : "vor {$minutes} Minuten";
}
if ($diff < 86400) {
$hours = floor($diff / 3600);
return $hours === 1 ? 'vor 1 Stunde' : "vor {$hours} Stunden";
}
if ($diff < 604800) {
$days = floor($diff / 86400);
return $days === 1 ? 'vor 1 Tag' : "vor {$days} Tagen";
}
if ($diff < 2592000) {
$weeks = floor($diff / 604800);
return $weeks === 1 ? 'vor 1 Woche' : "vor {$weeks} Wochen";
}
$months = floor($diff / 2592000);
return $months === 1 ? 'vor 1 Monat' : "vor {$months} Monaten";
}
/**
* Format absolute time
*/
public function formatAbsoluteTime(int $timestamp): string
{
return date('d.m.Y H:i', $timestamp);
}
/**
* Get default avatar URL
*/
public function getDefaultAvatar(string $userName): string
{
return 'https://ui-avatars.com/api/?name=' . urlencode($userName) . '&size=64&background=random';
}
/**
* Get unread badge text
*/
public function getUnreadBadge(int $unreadCount): string
{
if ($unreadCount === 0) {
return '';
}
if ($unreadCount > 99) {
return '99+';
}
return (string) $unreadCount;
}
}

View File

@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace App\Application\LiveComponents\Autocomplete;
use App\Application\LiveComponents\Services\SuggestionProvider;
use App\Framework\LiveComponents\Attributes\LiveComponent;
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
use App\Framework\LiveComponents\DataProviderResolver;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\LiveComponents\ValueObjects\ComponentRenderData;
/**
* AutocompleteComponent - Intelligent search with suggestions
*
* Demonstrates:
* - Type-safe state management with AutocompleteState Value Object
* - Real-time suggestion fetching
* - Context-aware search
* - Recent searches tracking
* - Keyboard navigation support
*/
#[LiveComponent('autocomplete')]
final readonly class AutocompleteComponent implements LiveComponentContract
{
public function __construct(
public ComponentId $id,
public AutocompleteState $state,
private readonly DataProviderResolver $dataProviderResolver
) {}
private function getDataProvider(): SuggestionProvider
{
$resolvedProvider = $this->dataProviderResolver->resolve(
SuggestionProvider::class,
$this->state->dataSource
);
if ($resolvedProvider === null) {
throw new \RuntimeException(
"No SuggestionProvider found for data source: {$this->state->dataSource}"
);
}
return $resolvedProvider;
}
public function getRenderData(): ComponentRenderData
{
return new ComponentRenderData(
templatePath: 'livecomponent-autocomplete',
data: [
// LiveComponent integration
'componentId' => $this->id->toString(),
'stateJson' => json_encode($this->state->toArray()),
// Raw data
'query' => $this->state->query,
'suggestions' => $this->state->suggestions,
'context' => $this->state->context,
'show_dropdown' => $this->state->showDropdown,
'recent_searches' => $this->state->recentSearches,
'selected_value' => $this->state->selectedValue,
'selected_index' => $this->state->selectedIndex,
// Computed properties
'has_suggestions' => $this->state->hasSuggestions(),
'has_recent_searches' => $this->state->hasRecentSearches(),
'suggestion_count' => $this->state->getSuggestionCount(),
// Display helpers
'show_recent' => empty($this->state->query) && $this->state->hasRecentSearches(),
'placeholder' => $this->getPlaceholderText($this->state->context),
]
);
}
// Action methods - Type-safe parameters
public function getSuggestions(string $query, string $context = 'general'): AutocompleteState
{
$suggestionProvider = $this->getDataProvider();
// Fetch suggestions from provider
$suggestions = $suggestionProvider->getSuggestions($query, $context);
// Update state with new suggestions
return $this->state->withSuggestions($query, $suggestions);
}
public function selectSuggestion(string $value, int $index = -1): AutocompleteState
{
return $this->state->withSelectedSuggestion($value, $index);
}
public function clearRecent(): AutocompleteState
{
return $this->state->withClearedRecent();
}
public function changeContext(string $context): AutocompleteState
{
return $this->state->withContext($context);
}
public function hideDropdown(): AutocompleteState
{
return $this->state->withHiddenDropdown();
}
public function reset(): AutocompleteState
{
return $this->state->reset();
}
// Helper methods
private function getPlaceholderText(string $context): string
{
return match ($context) {
'users' => 'Benutzer suchen...',
'products' => 'Produkte suchen...',
'documents' => 'Dokumente suchen...',
default => 'Suchen...'
};
}
}

View File

@@ -0,0 +1,204 @@
<?php
declare(strict_types=1);
namespace App\Application\LiveComponents\Autocomplete;
use App\Application\LiveComponents\LiveComponentState;
/**
* Type-safe state for AutocompleteComponent
*
* Provides compile-time type safety for autocomplete functionality.
* All properties are readonly and immutable - transformations return new instances.
*/
final readonly class AutocompleteState implements LiveComponentState
{
public function __construct(
public string $query = '',
public array $suggestions = [],
public string $context = 'general',
public bool $showDropdown = false,
public array $recentSearches = [],
public ?string $selectedValue = null,
public int $selectedIndex = -1,
public string $dataSource = 'demo'
) {
}
/**
* Create from ComponentData (Framework → Domain conversion)
*/
public static function fromArray(array $data): self
{
return new self(
query: (string) ($data['query'] ?? ''),
suggestions: ($data['suggestions'] ?? []),
context: (string) ($data['context'] ?? 'general'),
showDropdown: (bool) ($data['show_dropdown'] ?? false),
recentSearches: ($data['recent_searches'] ?? []),
selectedValue: $data['selected_value'],
selectedIndex: (int) ($data['selected_index'] ?? -1),
dataSource: (string) ($data['data_source'] ?? 'demo')
);
}
/**
* Create empty state with defaults
*/
public static function empty(): self
{
return new self();
}
/**
* Convert to ComponentData (Domain → Framework conversion)
*/
public function toArray(): array
{
return [
'query' => $this->query,
'suggestions' => $this->suggestions,
'context' => $this->context,
'show_dropdown' => $this->showDropdown,
'recent_searches' => $this->recentSearches,
'selected_value' => $this->selectedValue,
'selected_index' => $this->selectedIndex,
'data_source' => $this->dataSource,
];
}
/**
* Update with new suggestions
*/
public function withSuggestions(string $query, array $suggestions): self
{
return new self(
query: $query,
suggestions: $suggestions,
context: $this->context,
showDropdown: ! empty($suggestions),
recentSearches: $this->recentSearches,
selectedValue: $this->selectedValue,
selectedIndex: -1,
dataSource: $this->dataSource
);
}
/**
* Select a suggestion
*/
public function withSelectedSuggestion(string $value, int $index): self
{
// Add to recent searches if not already present
$recentSearches = $this->recentSearches;
if (! in_array($value, $recentSearches, true)) {
array_unshift($recentSearches, $value);
$recentSearches = array_slice($recentSearches, 0, 10); // Keep last 10
}
return new self(
query: $value,
suggestions: [],
context: $this->context,
showDropdown: false,
recentSearches: $recentSearches,
selectedValue: $value,
selectedIndex: $index,
dataSource: $this->dataSource
);
}
/**
* Clear recent searches
*/
public function withClearedRecent(): self
{
return new self(
query: $this->query,
suggestions: $this->suggestions,
context: $this->context,
showDropdown: $this->showDropdown,
recentSearches: [],
selectedValue: $this->selectedValue,
selectedIndex: $this->selectedIndex,
dataSource: $this->dataSource
);
}
/**
* Change context
*/
public function withContext(string $context): self
{
return new self(
query: $this->query,
suggestions: [],
context: $context,
showDropdown: false,
recentSearches: $this->recentSearches,
selectedValue: $this->selectedValue,
selectedIndex: -1,
dataSource: $this->dataSource
);
}
/**
* Hide dropdown
*/
public function withHiddenDropdown(): self
{
return new self(
query: $this->query,
suggestions: $this->suggestions,
context: $this->context,
showDropdown: false,
recentSearches: $this->recentSearches,
selectedValue: $this->selectedValue,
selectedIndex: $this->selectedIndex,
dataSource: $this->dataSource
);
}
/**
* Reset to empty state
*/
public function reset(): self
{
return new self(
query: '',
suggestions: [],
context: $this->context,
showDropdown: false,
recentSearches: $this->recentSearches,
selectedValue: null,
selectedIndex: -1,
dataSource: $this->dataSource
);
}
/**
* Check if there are suggestions
*/
public function hasSuggestions(): bool
{
return ! empty($this->suggestions);
}
/**
* Check if there are recent searches
*/
public function hasRecentSearches(): bool
{
return ! empty($this->recentSearches);
}
/**
* Get suggestion count
*/
public function getSuggestionCount(): int
{
return count($this->suggestions);
}
}

View File

@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace App\Application\LiveComponents;
use App\Framework\LiveComponents\Contracts\LiveComponent;
use App\Framework\LiveComponents\Contracts\SupportsSlots;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\LiveComponents\ValueObjects\ComponentState;
use App\Framework\LiveComponents\ValueObjects\SlotContent;
use App\Framework\LiveComponents\ValueObjects\SlotContext;
use App\Framework\LiveComponents\ValueObjects\SlotDefinition;
/**
* Card Component with Slot Support
*
* Demonstrates basic slot usage with header, body, and footer slots.
*
* Example Usage:
* ```html
* <live-component name="card" id="user-card">
* <slot name="header">
* <h2>User Profile</h2>
* </slot>
*
* <slot name="body">
* <p>User information goes here</p>
* </slot>
*
* <slot name="footer">
* <button>Edit Profile</button>
* </slot>
* </live-component>
* ```
*/
final readonly class CardComponent implements LiveComponent, SupportsSlots
{
public function __construct(
public ComponentId $id,
public ComponentState $state
) {
}
public function getSlotDefinitions(): array
{
return [
// Header slot - optional, has default content
SlotDefinition::named(
name: 'header',
defaultContent: '<div class="card-header-default">Card Header</div>'
),
// Body slot - required, no default
SlotDefinition::named(
name: 'body'
)->withRequired(true),
// Footer slot - optional, empty default
SlotDefinition::named(
name: 'footer',
defaultContent: ''
),
];
}
public function getSlotContext(string $slotName): SlotContext
{
// Card doesn't need scoped slots, return empty context
return SlotContext::empty();
}
public function processSlotContent(SlotContent $content): SlotContent
{
// Apply card-specific CSS classes based on slot name
$wrappedContent = match ($content->slotName) {
'header' => '<div class="card-header">' . $content->content . '</div>',
'body' => '<div class="card-body">' . $content->content . '</div>',
'footer' => '<div class="card-footer">' . $content->content . '</div>',
default => $content->content
};
return $content->withContent($wrappedContent);
}
public function validateSlots(array $providedSlots): array
{
$errors = [];
// Custom validation: warn if footer is provided without header
$hasHeader = false;
$hasFooter = false;
foreach ($providedSlots as $slot) {
if ($slot->slotName === 'header') {
$hasHeader = true;
}
if ($slot->slotName === 'footer') {
$hasFooter = true;
}
}
if ($hasFooter && ! $hasHeader) {
$errors[] = 'Card footer provided without header - consider adding a header for visual consistency';
}
return $errors;
}
public function render(): string
{
// Template rendering would be handled by TemplateRenderer
return 'card';
}
public function getComponentType(): string
{
return 'card';
}
public function getId(): ComponentId
{
return $this->id;
}
public function getState(): ComponentState
{
return $this->state;
}
}

View File

@@ -0,0 +1,411 @@
<?php
declare(strict_types=1);
namespace App\Application\LiveComponents\Chart;
use App\Application\LiveComponents\Services\ChartDataProvider;
use App\Application\LiveComponents\ValueObjects\ChartData;
use App\Application\LiveComponents\ValueObjects\ChartType;
use App\Application\LiveComponents\ValueObjects\DataRange;
use App\Application\LiveComponents\ValueObjects\DataSource;
use App\Framework\LiveComponents\Attributes\LiveComponent;
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
use App\Framework\LiveComponents\DataProviderResolver;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\LiveComponents\ValueObjects\ComponentRenderData;
/**
* ChartComponent - Real-time data visualization with multiple chart types
*
* Demonstrates:
* - Type-safe state management with ChartState Value Object
* - Multiple chart types (line, bar, pie, area)
* - Real-time data updates
* - Configurable update intervals
* - Data range selection (1h, 24h, 7d, 30d)
* - Performance metrics tracking
* - Export functionality ready
* - Compile-time type safety for data access
*
* Uses ChartDataProvider for flexible data sources (Demo, Database, API, etc.)
*/
#[LiveComponent('chart')]
final readonly class ChartComponent implements LiveComponentContract
{
public function __construct(
public ComponentId $id,
public ChartState $state,
private readonly DataProviderResolver $dataProviderResolver
) {}
private function getDataProvider(): ChartDataProvider
{
$resolvedProvider = $this->dataProviderResolver->resolve(
ChartDataProvider::class,
$this->state->dataSource
);
if ($resolvedProvider === null) {
throw new \RuntimeException(
"No ChartDataProvider found for data source: {$this->state->dataSource}"
);
}
return $resolvedProvider;
}
public function getRenderData(): ComponentRenderData
{
$dataProvider = $this->getDataProvider();
// File-based logging to bypass log buffering
file_put_contents('/tmp/chart-debug.log', '[' . date('Y-m-d H:i:s') . '] getRenderData() called' . "\n", FILE_APPEND);
error_log('[ChartComponent] getRenderData() called');
$chartData = $this->state->chartData;
file_put_contents('/tmp/chart-debug.log', '[' . date('Y-m-d H:i:s') . '] state chartData: ' . json_encode($chartData) . "\n", FILE_APPEND);
error_log('[ChartComponent] state chartData: ' . json_encode($chartData));
// If no chart data, load from provider
if (empty($chartData)) {
file_put_contents('/tmp/chart-debug.log', '[' . date('Y-m-d H:i:s') . '] chartData is empty, loading from provider' . "\n", FILE_APPEND);
error_log('[ChartComponent] chartData is empty, loading from provider');
$chartType = ChartType::fromString($this->state->chartType);
$dataRange = DataRange::fromString($this->state->dataRange);
$chartDataObj = $dataProvider->getChartData($chartType, $dataRange);
$chartData = $chartDataObj->toArray();
file_put_contents('/tmp/chart-debug.log', '[' . date('Y-m-d H:i:s') . '] loaded chartData from provider: ' . json_encode($chartData) . "\n", FILE_APPEND);
error_log('[ChartComponent] loaded chartData from provider: ' . json_encode($chartData));
}
$chartType = ChartType::fromString($this->state->chartType);
$dataRange = DataRange::fromString($this->state->dataRange);
// Prepare flattened data for template to avoid nested array access issues
$flattenedChartData = $this->flattenChartDataForTemplate($chartData, $chartType);
file_put_contents('/tmp/chart-debug.log', '[' . date('Y-m-d H:i:s') . '] flattenedChartData: ' . json_encode($flattenedChartData) . "\n", FILE_APPEND);
// Calculate actual data points count from chart data
$actualDataPointsCount = isset($chartData['labels']) ? count($chartData['labels']) : 0;
// Use current time as last update if we have data but no timestamp
$lastUpdate = $this->state->lastUpdate;
if ($lastUpdate === 0 && ! empty($chartData)) {
$lastUpdate = time();
}
return new ComponentRenderData(
templatePath: 'livecomponent-chart',
data: array_merge(
[
// LiveComponent integration
'componentId' => $this->id->toString(),
'stateJson' => json_encode($this->state->toArray()),
// Raw data
'chart_type' => $this->state->chartType,
'chart_data' => $chartData,
'data_range' => $this->state->dataRange,
'last_update' => $lastUpdate,
'auto_refresh' => $this->state->autoRefresh,
'refresh_interval' => $this->state->refreshInterval,
'execution_time_ms' => $this->state->executionTimeMs,
'data_points_count' => $actualDataPointsCount, // Use actual count from data
// Display-specific properties from enums
'chartTypeLabel' => $chartType->getLabel(),
'chartTypeOptions' => $this->getChartTypeOptions(),
'dataRangeLabel' => $dataRange->getLabel(),
'dataRangeOptions' => $this->getDataRangeOptions(),
'autoRefreshText' => $this->state->getAutoRefreshText(),
'autoRefreshButtonText' => $this->state->getAutoRefreshButtonText(),
'autoRefreshButtonClass' => $this->state->getAutoRefreshButtonClass(),
'lastUpdateFormatted' => $this->formatLastUpdate($lastUpdate),
'hasChartData' => $this->state->hasChartData(),
'chartDataJson' => $this->state->getChartDataJson(),
'showRefreshButton' => $this->state->shouldShowRefreshButton(),
'chartContainerClass' => $this->state->getChartContainerClass(),
'dataPointsText' => $this->state->getDataPointsText(),
'showPerformanceMetrics' => $this->state->shouldShowPerformanceMetrics(),
'chartIcon' => $chartType->getIcon(),
// Chart type helpers
'isPieChart' => $chartType === ChartType::PIE,
'isAreaChart' => $chartType === ChartType::AREA,
'autoRefreshIndicatorClass' => $this->state->autoRefresh ? 'active' : 'paused',
'autoRefreshStatusText' => $this->state->autoRefresh ? 'Auto-Refresh aktiv' : 'Auto-Refresh pausiert',
'refreshIntervalFormatted' => ($this->state->refreshInterval / 1000) . 's',
],
$flattenedChartData
)
);
}
/**
* Flatten chart data structure for easier template access
* Avoids nested array access issues in ForProcessor
*/
private function flattenChartDataForTemplate(array $chartData, ChartType $chartType): array
{
file_put_contents('/tmp/chart-debug.log', '[' . date('Y-m-d H:i:s') . '] flattenChartDataForTemplate - chartData: ' . json_encode($chartData) . "\n", FILE_APPEND);
error_log('[ChartComponent] flattenChartDataForTemplate - chartData: ' . json_encode($chartData));
if (empty($chartData) || ! isset($chartData['labels'], $chartData['datasets'])) {
file_put_contents('/tmp/chart-debug.log', '[' . date('Y-m-d H:i:s') . '] flattenChartDataForTemplate - chartData is empty or missing labels/datasets' . "\n", FILE_APPEND);
error_log('[ChartComponent] flattenChartDataForTemplate - chartData is empty or missing labels/datasets');
return [
'chartLabels' => [],
'chartDatasets' => [],
'barItems' => [],
'barLabels' => [],
'pieSegments' => [],
];
}
$labels = $chartData['labels'];
$datasets = $chartData['datasets'];
file_put_contents('/tmp/chart-debug.log', '[' . date('Y-m-d H:i:s') . '] flattenChartDataForTemplate - labels count: ' . count($labels) . ', datasets count: ' . count($datasets) . "\n", FILE_APPEND);
error_log('[ChartComponent] flattenChartDataForTemplate - labels count: ' . count($labels) . ', datasets count: ' . count($datasets));
// Completely flatten bar data to avoid nested loops
// Create separate arrays for bar items and labels
$barItems = []; // All individual bars
$barLabels = []; // Just the labels for x-axis (wrapped in objects for ForProcessor)
foreach ($labels as $labelIndex => $label) {
$barLabels[] = ['text' => $label, 'index' => $labelIndex];
foreach ($datasets as $datasetIndex => $dataset) {
$value = $dataset['data'][$labelIndex] ?? 0;
$fill = isset($dataset['fill']) && $dataset['fill'];
// Get color - handle both string and array values
$borderColor = $dataset['borderColor'] ?? null;
$bgColor = $dataset['backgroundColor'] ?? null;
// If color is an array (gradient or multi-color), use first element or convert to string
if (is_array($borderColor)) {
$color = $borderColor[0] ?? 'rgb(100, 100, 100)';
} elseif (is_array($bgColor)) {
$color = $bgColor[0] ?? 'rgb(100, 100, 100)';
} else {
$color = $borderColor ?? $bgColor ?? 'rgb(100, 100, 100)';
}
// Pre-compute CSS classes and styles
$barClass = 'bar';
if ($chartType === ChartType::AREA) {
$barClass .= ' area-bar';
}
$barStyle = "height: {$value}%; background-color: {$color}";
if ($fill) {
$barStyle .= '; opacity: 0.7';
}
$barItems[] = [
'value' => $value,
'color' => $color,
'datasetLabel' => $dataset['label'] ?? 'Dataset ' . ($datasetIndex + 1),
'xLabel' => $label,
'labelIndex' => $labelIndex,
'datasetIndex' => $datasetIndex,
'barClass' => $barClass,
'barStyle' => $barStyle,
'title' => ($dataset['label'] ?? 'Dataset ' . ($datasetIndex + 1)) . ': ' . $value,
];
}
}
file_put_contents('/tmp/chart-debug.log', '[' . date('Y-m-d H:i:s') . '] flattenChartDataForTemplate - barItems count: ' . count($barItems) . ', barLabels count: ' . count($barLabels) . "\n", FILE_APPEND);
// Process datasets to add pre-computed color strings for legend
$processedDatasets = [];
foreach ($datasets as $dataset) {
$borderColor = $dataset['borderColor'] ?? null;
$bgColor = $dataset['backgroundColor'] ?? null;
// Extract string color from potential array
if (is_array($borderColor)) {
$colorString = $borderColor[0] ?? 'rgb(100, 100, 100)';
} elseif (is_array($bgColor)) {
$colorString = $bgColor[0] ?? 'rgb(100, 100, 100)';
} else {
$colorString = $borderColor ?? $bgColor ?? 'rgb(100, 100, 100)';
}
$processedDatasets[] = [
'label' => $dataset['label'] ?? 'Dataset',
'borderColor' => $dataset['borderColor'] ?? null,
'backgroundColor' => $dataset['backgroundColor'] ?? null,
'legendColor' => $colorString, // Pre-computed string color for legend
];
}
// Create pie segments: flatten pie chart data for easier access
$pieSegments = [];
if (! empty($datasets) && isset($datasets[0])) {
$firstDataset = $datasets[0];
$dataValues = $firstDataset['data'] ?? [];
$colors = $firstDataset['backgroundColor'] ?? [];
foreach ($labels as $index => $label) {
$value = $dataValues[$index] ?? 0;
$pieSegments[] = [
'label' => $label,
'value' => $value,
'color' => is_array($colors) ? ($colors[$index] ?? 'rgb(100, 100, 100)') : $colors,
'size' => ($value * 3) . 'px', // Pre-calculate size for CSS
];
}
}
return [
'chartLabels' => $labels,
'chartDatasets' => $processedDatasets, // Now with legendColor
'barItems' => $barItems, // Flat array of all bars
'barLabels' => $barLabels, // Flat array of x-axis labels
'pieSegments' => $pieSegments,
];
}
// Action methods - Type-safe parameters
public function refreshData(): ChartState
{
$dataProvider = $this->getDataProvider();
$startTime = microtime(true);
$chartType = ChartType::fromString($this->state->chartType);
$dataRange = DataRange::fromString($this->state->dataRange);
// Get current chart data
$chartData = $this->arrayToChartData($this->state->chartData, $chartType);
// Add new data point
$newValues = $dataProvider->getNewDataPoint($chartType);
$newLabel = date('H:i:s');
$updatedData = $chartData->addDataPoint($newLabel, $newValues);
// Trim to range
$updatedData = $updatedData->trimToCount($dataRange->getDataPointsCount());
$executionTime = round((microtime(true) - $startTime) * 1000, 2);
return $this->state->withRefreshedData($updatedData->toArray(), $executionTime);
}
public function changeChartType(string $chartType): ChartState
{
$dataProvider = $this->getDataProvider();
$chartTypeEnum = ChartType::fromString($chartType);
$dataRange = DataRange::fromString($this->state->dataRange);
// Load fresh data for new chart type
$chartData = $dataProvider->getChartData($chartTypeEnum, $dataRange);
return $this->state->withChartType($chartType, $chartData->toArray());
}
public function changeDataRange(string $dataRange): ChartState
{
$dataProvider = $this->getDataProvider();
$chartType = ChartType::fromString($this->state->chartType);
$dataRangeEnum = DataRange::fromString($dataRange);
// Load fresh data for new range
$chartData = $dataProvider->getChartData($chartType, $dataRangeEnum);
return $this->state->withDataRange($dataRange, $chartData->toArray());
}
public function toggleAutoRefresh(): ChartState
{
return $this->state->withToggledAutoRefresh();
}
// Helper methods
private function arrayToChartData(array $chartDataArray, ChartType $type): ChartData
{
if (empty($chartDataArray)) {
return new ChartData([], [], $type);
}
return new ChartData(
labels: $chartDataArray['labels'] ?? [],
datasets: $chartDataArray['datasets'] ?? [],
type: $type
);
}
private function getChartTypeOptions(): array
{
$dataProvider = $this->getDataProvider();
$options = $dataProvider->getAvailableChartTypes();
$currentType = $this->state->chartType;
return array_map(function (ChartType $chartType) use ($currentType) {
$isActive = $chartType->value === $currentType;
return [
'value' => $chartType->value,
'label' => $chartType->getLabel(),
'icon' => $chartType->getIcon(),
'isActive' => $isActive,
'activeClass' => $isActive ? 'active' : '',
];
}, $options);
}
private function getDataRangeOptions(): array
{
$dataProvider = $this->getDataProvider();
$options = $dataProvider->getAvailableDataRanges();
$currentRange = $this->state->dataRange;
return array_map(function (DataRange $dataRange) use ($currentRange) {
$isActive = $dataRange->value === $currentRange;
return [
'value' => $dataRange->value,
'label' => $dataRange->getLabel(),
'points' => $dataRange->getDataPointsCount(),
'isActive' => $isActive,
'selectedAttribute' => $isActive ? 'selected' : '', // Pre-computed
];
}, $options);
}
private function formatLastUpdate(int $timestamp): string
{
if ($timestamp === 0) {
return 'Noch nicht aktualisiert';
}
$diff = time() - $timestamp;
if ($diff < 60) {
return 'Gerade eben';
}
if ($diff < 3600) {
$minutes = floor($diff / 60);
return "vor {$minutes} " . ($minutes === 1 ? 'Minute' : 'Minuten');
}
if ($diff < 86400) {
$hours = floor($diff / 3600);
return "vor {$hours} " . ($hours === 1 ? 'Stunde' : 'Stunden');
}
$days = floor($diff / 86400);
return "vor {$days} " . ($days === 1 ? 'Tag' : 'Tagen');
}
}

View File

@@ -0,0 +1,264 @@
<?php
declare(strict_types=1);
namespace App\Application\LiveComponents\Chart;
use App\Application\LiveComponents\LiveComponentState;
/**
* Type-safe state for ChartComponent
*
* Provides compile-time type safety for real-time chart visualization.
* All properties are readonly and immutable - transformations return new instances.
*/
final readonly class ChartState implements LiveComponentState
{
public function __construct(
public string $chartType = 'line',
public array $chartData = [],
public string $dataRange = '24h',
public int $lastUpdate = 0,
public bool $autoRefresh = true,
public int $refreshInterval = 5000,
public float $executionTimeMs = 0,
public int $dataPointsCount = 0,
public string $dataSource = 'demo'
) {
}
/**
* Create from ComponentData (Framework → Domain conversion)
*/
public static function fromArray(array $data): self
{
return new self(
chartType: (string) ($data['chart_type'] ?? 'line'),
chartData: ($data['chart_data'] ?? []),
dataRange: (string) ($data['data_range'] ?? '24h'),
lastUpdate: (int) ($data['last_update'] ?? 0),
autoRefresh: (bool) ($data['auto_refresh'] ?? true),
refreshInterval: (int) ($data['refresh_interval'] ?? 5000),
executionTimeMs: (float) ($data['execution_time_ms'] ?? 0),
dataPointsCount: (int) ($data['data_points_count'] ?? 0),
dataSource: (string) ($data['data_source'] ?? 'demo')
);
}
/**
* Create empty state with defaults
*/
public static function empty(): self
{
return new self();
}
/**
* Convert to ComponentData (Domain → Framework conversion)
*/
public function toArray(): array
{
return [
'chart_type' => $this->chartType,
'chart_data' => $this->chartData,
'data_range' => $this->dataRange,
'last_update' => $this->lastUpdate,
'auto_refresh' => $this->autoRefresh,
'refresh_interval' => $this->refreshInterval,
'execution_time_ms' => $this->executionTimeMs,
'data_points_count' => $this->dataPointsCount,
'data_source' => $this->dataSource,
];
}
/**
* Update chart with refreshed data
*/
public function withRefreshedData(array $chartData, float $executionTimeMs): self
{
return new self(
chartType: $this->chartType,
chartData: $chartData,
dataRange: $this->dataRange,
lastUpdate: time(),
autoRefresh: $this->autoRefresh,
refreshInterval: $this->refreshInterval,
executionTimeMs: $executionTimeMs,
dataPointsCount: $this->calculateDataPointsCount($chartData),
dataSource: $this->dataSource
);
}
/**
* Change chart type
*/
public function withChartType(string $chartType, array $chartData): self
{
return new self(
chartType: $chartType,
chartData: $chartData,
dataRange: $this->dataRange,
lastUpdate: time(),
autoRefresh: $this->autoRefresh,
refreshInterval: $this->refreshInterval,
executionTimeMs: 0,
dataPointsCount: $this->calculateDataPointsCount($chartData),
dataSource: $this->dataSource
);
}
/**
* Change data range
*/
public function withDataRange(string $dataRange, array $chartData): self
{
return new self(
chartType: $this->chartType,
chartData: $chartData,
dataRange: $dataRange,
lastUpdate: time(),
autoRefresh: $this->autoRefresh,
refreshInterval: $this->refreshInterval,
executionTimeMs: 0,
dataPointsCount: $this->calculateDataPointsCount($chartData),
dataSource: $this->dataSource
);
}
/**
* Toggle auto-refresh
*/
public function withToggledAutoRefresh(): self
{
return new self(
chartType: $this->chartType,
chartData: $this->chartData,
dataRange: $this->dataRange,
lastUpdate: $this->lastUpdate,
autoRefresh: ! $this->autoRefresh,
refreshInterval: $this->refreshInterval,
executionTimeMs: $this->executionTimeMs,
dataPointsCount: $this->dataPointsCount,
dataSource: $this->dataSource
);
}
/**
* Calculate data points count from chart data
*/
private function calculateDataPointsCount(array $chartData): int
{
if (empty($chartData)) {
return 0;
}
return count($chartData['labels'] ?? []);
}
/**
* Check if has chart data
*/
public function hasChartData(): bool
{
return ! empty($this->chartData);
}
/**
* Get chart data as JSON
*/
public function getChartDataJson(): string
{
return ! empty($this->chartData) ? json_encode($this->chartData) : '{}';
}
/**
* Format last update timestamp
*/
public function getFormattedLastUpdate(): string
{
if ($this->lastUpdate === 0) {
return 'Noch nicht aktualisiert';
}
$diff = time() - $this->lastUpdate;
if ($diff < 60) {
return 'Gerade eben';
}
if ($diff < 3600) {
$minutes = floor($diff / 60);
return "Vor {$minutes} Minute" . ($minutes > 1 ? 'n' : '');
}
if ($diff < 86400) {
$hours = floor($diff / 3600);
return "Vor {$hours} Stunde" . ($hours > 1 ? 'n' : '');
}
return date('d.m.Y H:i', $this->lastUpdate);
}
/**
* Get auto-refresh status text
*/
public function getAutoRefreshText(): string
{
return $this->autoRefresh ? 'Auto-Refresh aktiv' : 'Auto-Refresh pausiert';
}
/**
* Get auto-refresh button text
*/
public function getAutoRefreshButtonText(): string
{
return $this->autoRefresh ? 'Pausieren' : 'Starten';
}
/**
* Get auto-refresh button CSS class
*/
public function getAutoRefreshButtonClass(): string
{
return $this->autoRefresh ? 'btn-warning' : 'btn-success';
}
/**
* Get data points text
*/
public function getDataPointsText(): string
{
if ($this->dataPointsCount === 0) {
return 'Keine Datenpunkte';
}
return $this->dataPointsCount === 1 ? '1 Datenpunkt' : "{$this->dataPointsCount} Datenpunkte";
}
/**
* Check if should show refresh button
*/
public function shouldShowRefreshButton(): bool
{
return ! $this->autoRefresh;
}
/**
* Check if should show performance metrics
*/
public function shouldShowPerformanceMetrics(): bool
{
return $this->executionTimeMs > 0;
}
/**
* Get chart container CSS class
*/
public function getChartContainerClass(): string
{
return 'chart-container chart-type-' . $this->chartType;
}
}

View File

@@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
namespace App\Application\LiveComponents\CommentThread;
use App\Framework\LiveComponents\Attributes\Action;
use App\Framework\LiveComponents\Attributes\LiveComponent;
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\LiveComponents\ValueObjects\ComponentRenderData;
/**
* CommentThreadComponent - Collaborative comment thread with replies and reactions
*
* Demonstrates:
* - Type-safe state management with CommentThreadState Value Object
* - Nested comment replies
* - Comment reactions (like, emoji)
* - Real-time comment updates
* - User mentions
* - Comment editing and deletion
* - Sorting (newest, oldest, most liked)
* - Compile-time type safety for data access
*/
#[LiveComponent('comment-thread')]
final readonly class CommentThreadComponent implements LiveComponentContract
{
public function __construct(
public ComponentId $id,
public CommentThreadState $state
) {}
public function getRenderData(): ComponentRenderData
{
// Build comment tree
$commentTree = $this->state->buildCommentTree();
// Prepare comments for display
$preparedComments = $this->prepareCommentsForDisplay($commentTree);
// Get statistics
$statistics = $this->state->getStatistics();
return new ComponentRenderData(
templatePath: 'livecomponent-comment-thread',
data: [
// Raw data
'comments' => $this->state->comments,
'sort_by' => $this->state->sortBy,
'show_reactions' => $this->state->showReactions,
'allow_edit' => $this->state->allowEdit,
'allow_delete' => $this->state->allowDelete,
'max_nesting_level' => $this->state->maxNestingLevel,
// Display data
'prepared_comments' => $preparedComments,
'has_comments' => $statistics['has_comments'],
'total_comments' => $statistics['total_comments'],
'top_level_count' => $statistics['top_level_count'],
'total_reactions' => $statistics['total_reactions'],
'sort_label' => $this->state->getSortLabel(),
'sort_options' => $this->state->getSortOptions(),
]
);
}
// Action methods - Type-safe parameters with SSE support
#[Action]
public function addComment(
string $content,
string $authorId,
string $authorName,
?string $parentId = null
): CommentThreadState {
return $this->state->withCommentAdded($content, $authorId, $authorName, $parentId);
}
#[Action]
public function editComment(string $commentId, string $newContent): CommentThreadState
{
return $this->state->withCommentEdited($commentId, $newContent);
}
#[Action]
public function deleteComment(string $commentId): CommentThreadState
{
return $this->state->withCommentDeleted($commentId);
}
#[Action]
public function addReaction(string $commentId, string $reaction, string $userId): CommentThreadState
{
return $this->state->withReactionToggled($commentId, $reaction, $userId);
}
#[Action]
public function changeSorting(string $sortBy): CommentThreadState
{
return $this->state->withSorting($sortBy);
}
/**
* Get SSE channel for real-time comment updates
*
* Uses comments-scoped channel for context-based comment threads.
* Extract context from component ID instance identifier.
*
* Examples:
* - "comment-thread:post-123" → "comments:post-123"
* - "comment-thread:article-456" → "comments:article-456"
* - "comment-thread:global" → "comments:global"
*/
public function getSseChannel(): string
{
// Extract context from component instance ID
// ComponentId format: "comment-thread:post-123" or "comment-thread:article-456"
$context = $this->id->instanceId;
// Comments channel for context-based updates
return "comments:{$context}";
}
// Helper methods
private function prepareCommentsForDisplay(array $commentTree): array
{
return array_map(function ($comment) {
$reactions = $comment['reactions'] ?? [];
$groupedReactions = $this->groupReactions($reactions);
$prepared = [
'id' => $comment['id'],
'content' => $comment['content'],
'author_name' => $comment['author_name'],
'created_at' => $comment['created_at'],
'updated_at' => $comment['updated_at'],
'is_edited' => $comment['is_edited'] ?? false,
'level' => $comment['level'] ?? 0,
'reply_count' => $comment['reply_count'] ?? 0,
// Display properties
'formatted_date' => $this->formatDate($comment['created_at']),
'edited_label' => ($comment['is_edited'] ?? false) ? ' (bearbeitet)' : '',
'indent_style' => 'margin-left: ' . (($comment['level'] ?? 0) * 2) . 'rem;',
'has_replies' => ! empty($comment['replies']),
// Reactions
'show_reactions' => $this->state->showReactions,
'grouped_reactions' => $groupedReactions,
'total_reactions' => count($reactions),
'has_reactions' => ! empty($reactions),
// Actions
'can_edit' => $this->state->allowEdit,
'can_delete' => $this->state->allowDelete,
'can_reply' => ($comment['level'] ?? 0) < ($this->state->maxNestingLevel - 1),
// Nested replies
'replies' => ! empty($comment['replies'])
? $this->prepareCommentsForDisplay($comment['replies'])
: [],
];
return $prepared;
}, $commentTree);
}
private function groupReactions(array $reactions): array
{
$grouped = [];
foreach ($reactions as $reaction) {
$type = $reaction['type'];
if (! isset($grouped[$type])) {
$grouped[$type] = [
'type' => $type,
'count' => 0,
'users' => [],
];
}
$grouped[$type]['count']++;
$grouped[$type]['users'][] = $reaction['user_id'];
}
return array_values($grouped);
}
private function formatDate(int $timestamp): string
{
$diff = time() - $timestamp;
if ($diff < 60) {
return 'gerade eben';
}
if ($diff < 3600) {
$minutes = floor($diff / 60);
return "vor {$minutes} Minute" . ($minutes > 1 ? 'n' : '');
}
if ($diff < 86400) {
$hours = floor($diff / 3600);
return "vor {$hours} Stunde" . ($hours > 1 ? 'n' : '');
}
return date('d.m.Y H:i', $timestamp);
}
}

View File

@@ -0,0 +1,348 @@
<?php
declare(strict_types=1);
namespace App\Application\LiveComponents\CommentThread;
use App\Application\LiveComponents\LiveComponentState;
/**
* Type-safe state for CommentThreadComponent
*
* Provides compile-time type safety for collaborative comment threads.
* All properties are readonly and immutable - transformations return new instances.
*/
final readonly class CommentThreadState implements LiveComponentState
{
public function __construct(
public array $comments = [],
public string $sortBy = 'newest',
public bool $showReactions = true,
public bool $allowEdit = true,
public bool $allowDelete = true,
public int $maxNestingLevel = 3
) {
}
/**
* Create from ComponentData (Framework → Domain conversion)
*/
public static function fromArray(array $data): self
{
return new self(
comments: ($data['comments'] ?? []),
sortBy: (string) ($data['sort_by'] ?? 'newest'),
showReactions: (bool) ($data['show_reactions'] ?? true),
allowEdit: (bool) ($data['allow_edit'] ?? true),
allowDelete: (bool) ($data['allow_delete'] ?? true),
maxNestingLevel: (int) ($data['max_nesting_level'] ?? 3)
);
}
/**
* Create empty state with defaults
*/
public static function empty(): self
{
return new self();
}
/**
* Convert to ComponentData (Domain → Framework conversion)
*/
public function toArray(): array
{
return [
'comments' => $this->comments,
'sort_by' => $this->sortBy,
'show_reactions' => $this->showReactions,
'allow_edit' => $this->allowEdit,
'allow_delete' => $this->allowDelete,
'max_nesting_level' => $this->maxNestingLevel,
];
}
/**
* Add new comment
*/
public function withCommentAdded(
string $content,
string $authorId,
string $authorName,
?string $parentId = null
): self {
$comments = $this->comments;
$newComment = [
'id' => uniqid('comment_', true),
'content' => $content,
'author_id' => $authorId,
'author_name' => $authorName,
'parent_id' => $parentId,
'created_at' => time(),
'updated_at' => null,
'reactions' => [],
'reply_count' => 0,
'is_edited' => false,
];
$comments[] = $newComment;
// Update parent's reply count if this is a reply
if ($parentId) {
foreach ($comments as &$comment) {
if ($comment['id'] === $parentId) {
$comment['reply_count'] = ($comment['reply_count'] ?? 0) + 1;
break;
}
}
}
return new self(
comments: $comments,
sortBy: $this->sortBy,
showReactions: $this->showReactions,
allowEdit: $this->allowEdit,
allowDelete: $this->allowDelete,
maxNestingLevel: $this->maxNestingLevel
);
}
/**
* Edit comment
*/
public function withCommentEdited(string $commentId, string $newContent): self
{
$comments = $this->comments;
foreach ($comments as &$comment) {
if ($comment['id'] === $commentId) {
$comment['content'] = $newContent;
$comment['updated_at'] = time();
$comment['is_edited'] = true;
break;
}
}
return new self(
comments: $comments,
sortBy: $this->sortBy,
showReactions: $this->showReactions,
allowEdit: $this->allowEdit,
allowDelete: $this->allowDelete,
maxNestingLevel: $this->maxNestingLevel
);
}
/**
* Delete comment and its replies
*/
public function withCommentDeleted(string $commentId): self
{
$comments = array_filter($this->comments, function ($comment) use ($commentId) {
return $comment['id'] !== $commentId && $comment['parent_id'] !== $commentId;
});
return new self(
comments: array_values($comments),
sortBy: $this->sortBy,
showReactions: $this->showReactions,
allowEdit: $this->allowEdit,
allowDelete: $this->allowDelete,
maxNestingLevel: $this->maxNestingLevel
);
}
/**
* Add or toggle reaction
*/
public function withReactionToggled(string $commentId, string $reaction, string $userId): self
{
$comments = $this->comments;
foreach ($comments as &$comment) {
if ($comment['id'] === $commentId) {
$reactions = $comment['reactions'] ?? [];
// Check if user already reacted with this
$existingIndex = -1;
foreach ($reactions as $index => $r) {
if ($r['user_id'] === $userId && $r['type'] === $reaction) {
$existingIndex = $index;
break;
}
}
if ($existingIndex === -1) {
// Add new reaction
$reactions[] = [
'type' => $reaction,
'user_id' => $userId,
'created_at' => time(),
];
} else {
// Remove reaction (toggle)
array_splice($reactions, $existingIndex, 1);
}
$comment['reactions'] = $reactions;
break;
}
}
return new self(
comments: $comments,
sortBy: $this->sortBy,
showReactions: $this->showReactions,
allowEdit: $this->allowEdit,
allowDelete: $this->allowDelete,
maxNestingLevel: $this->maxNestingLevel
);
}
/**
* Change sorting
*/
public function withSorting(string $sortBy): self
{
return new self(
comments: $this->comments,
sortBy: $sortBy,
showReactions: $this->showReactions,
allowEdit: $this->allowEdit,
allowDelete: $this->allowDelete,
maxNestingLevel: $this->maxNestingLevel
);
}
/**
* Sort comments by criteria
*/
public function getSortedComments(): array
{
$sorted = $this->comments;
usort($sorted, function ($a, $b) {
return match ($this->sortBy) {
'newest' => ($b['created_at'] ?? 0) <=> ($a['created_at'] ?? 0),
'oldest' => ($a['created_at'] ?? 0) <=> ($b['created_at'] ?? 0),
'most_liked' => count($b['reactions'] ?? []) <=> count($a['reactions'] ?? []),
default => 0
};
});
return $sorted;
}
/**
* Build comment tree
*/
public function buildCommentTree(?string $parentId = null, int $level = 0): array
{
if ($level >= $this->maxNestingLevel) {
return [];
}
$tree = [];
$sortedComments = $this->getSortedComments();
foreach ($sortedComments as $comment) {
$commentParentId = $comment['parent_id'] ?? null;
if ($commentParentId === $parentId) {
$comment['level'] = $level;
$comment['replies'] = $this->buildCommentTreeForComments(
$sortedComments,
$comment['id'],
$level + 1
);
$tree[] = $comment;
}
}
return $tree;
}
/**
* Helper for building tree recursively
*/
private function buildCommentTreeForComments(array $comments, string $parentId, int $level): array
{
if ($level >= $this->maxNestingLevel) {
return [];
}
$tree = [];
foreach ($comments as $comment) {
$commentParentId = $comment['parent_id'] ?? null;
if ($commentParentId === $parentId) {
$comment['level'] = $level;
$comment['replies'] = $this->buildCommentTreeForComments($comments, $comment['id'], $level + 1);
$tree[] = $comment;
}
}
return $tree;
}
/**
* Get statistics
*/
public function getStatistics(): array
{
$totalComments = count($this->comments);
$topLevelComments = count(array_filter($this->comments, fn ($c) => ! isset($c['parent_id']) || $c['parent_id'] === null));
$totalReactions = array_sum(array_map(fn ($c) => count($c['reactions'] ?? []), $this->comments));
return [
'total_comments' => $totalComments,
'top_level_count' => $topLevelComments,
'total_reactions' => $totalReactions,
'has_comments' => $totalComments > 0,
];
}
/**
* Get sort label
*/
public function getSortLabel(): string
{
return match ($this->sortBy) {
'newest' => 'Neueste zuerst',
'oldest' => 'Älteste zuerst',
'most_liked' => 'Meist geliked',
default => 'Neueste zuerst'
};
}
/**
* Get sort options with current selection
*/
public function getSortOptions(): array
{
return [
[
'value' => 'newest',
'label' => 'Neueste zuerst',
'is_selected' => $this->sortBy === 'newest' ? 'selected' : '',
],
[
'value' => 'oldest',
'label' => 'Älteste zuerst',
'is_selected' => $this->sortBy === 'oldest' ? 'selected' : '',
],
[
'value' => 'most_liked',
'label' => 'Meist geliked',
'is_selected' => $this->sortBy === 'most_liked' ? 'selected' : '',
],
];
}
}

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Application\LiveComponents;
use App\Framework\LiveComponents\Contracts\LiveComponent;
use App\Framework\LiveComponents\Contracts\SupportsSlots;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\LiveComponents\ValueObjects\ComponentState;
use App\Framework\LiveComponents\ValueObjects\SlotContent;
use App\Framework\LiveComponents\ValueObjects\SlotContext;
use App\Framework\LiveComponents\ValueObjects\SlotDefinition;
/**
* Container Component with Default Slot
*
* Demonstrates the default (unnamed) slot pattern where content
* not assigned to named slots goes into the default slot.
*
* Example Usage:
* ```html
* <live-component name="container" id="content-wrapper">
* <!-- This content goes into the default slot -->
* <h1>Welcome</h1>
* <p>This is the main content</p>
*
* <!-- Named slot for actions -->
* <slot name="actions">
* <button>Save</button>
* <button>Cancel</button>
* </slot>
* </live-component>
* ```
*/
final readonly class ContainerComponent implements LiveComponent, SupportsSlots
{
public function __construct(
public ComponentId $id,
public ComponentState $state
) {
}
public function getSlotDefinitions(): array
{
return [
// Default slot - receives all unnamed content
SlotDefinition::default(
defaultContent: '<div class="empty-container">No content provided</div>'
),
// Actions slot - optional, for buttons/controls
SlotDefinition::named(
name: 'actions',
defaultContent: ''
),
// Title slot - scoped with container metadata
SlotDefinition::scoped(
name: 'title',
props: ['containerId', 'hasContent'],
defaultContent: '<h2>Container</h2>'
),
];
}
public function getSlotContext(string $slotName): SlotContext
{
$containerId = $this->id->toString();
$hasContent = ! empty($this->state->get('content'));
return match ($slotName) {
'title' => SlotContext::create([
'containerId' => $containerId,
'hasContent' => $hasContent,
]),
default => SlotContext::empty()
};
}
public function processSlotContent(SlotContent $content): SlotContent
{
// Get container padding from state
$padding = $this->state->get('padding', 'medium');
$paddingClass = 'container-padding-' . $padding;
// Apply container-specific structure
$wrappedContent = match ($content->slotName) {
'default' => "<div class=\"container-content {$paddingClass}\">" . $content->content . '</div>',
'title' => '<div class="container-title">' . $content->content . '</div>',
'actions' => '<div class="container-actions">' . $content->content . '</div>',
default => $content->content
};
return $content->withContent($wrappedContent);
}
public function validateSlots(array $providedSlots): array
{
$errors = [];
// Check if default slot has content
$hasDefaultContent = false;
foreach ($providedSlots as $slot) {
if ($slot->isDefault() && ! $slot->isEmpty()) {
$hasDefaultContent = true;
}
}
// Warn if no default content but actions are provided
$hasActions = false;
foreach ($providedSlots as $slot) {
if ($slot->slotName === 'actions' && ! $slot->isEmpty()) {
$hasActions = true;
}
}
if ($hasActions && ! $hasDefaultContent) {
$errors[] = 'Container has actions but no content - consider providing content or removing actions';
}
return $errors;
}
public function render(): string
{
return 'container';
}
public function getComponentType(): string
{
return 'container';
}
public function getId(): ComponentId
{
return $this->id;
}
public function getState(): ComponentState
{
return $this->state;
}
}

View File

@@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
namespace App\Application\LiveComponents\Counter;
use App\Framework\LiveComponents\Attributes\Action;
use App\Framework\LiveComponents\Attributes\LiveComponent;
use App\Framework\LiveComponents\ComponentEventDispatcher;
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
use App\Framework\LiveComponents\Contracts\Pollable;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\LiveComponents\ValueObjects\ComponentRenderData;
use App\Framework\LiveComponents\ValueObjects\EventPayload;
/**
* Test Component: Simple Counter with Polling
*
* Demonstrates:
* - Type-safe state management with CounterState Value Object
* - Action handling (increment, decrement, reset)
* - Event dispatching (counter:changed, counter:milestone events)
* - Polling capability
* - Clean separation: Component = State + Actions, Handler = Lifecycle
*/
#[LiveComponent('counter')]
final readonly class CounterComponent implements LiveComponentContract, Pollable
{
/**
* Create Counter with type-safe State VO
*
* Component receives a fully constructed CounterState from ComponentRegistry.
* No need for a conditional initialization - state is always present.
*/
public function __construct(
public ComponentId $id,
public CounterState $state
) {
}
public function getRenderData(): ComponentRenderData
{
return new ComponentRenderData(
templatePath: 'livecomponent-counter',
data: [
// LiveComponent integration
'componentId' => $this->id->toString(),
'stateJson' => json_encode($this->state->toArray()),
'pollInterval' => $this->getPollInterval(),
// Component data
'count' => $this->state->count,
'lastUpdate' => $this->state->lastUpdate,
]
);
}
#[Action]
public function increment(?ComponentEventDispatcher $events = null): CounterState
{
$oldCount = $this->state->count;
$newState = $this->state->increment();
// Dispatch counter:changed event
$events?->dispatch('counter:changed', EventPayload::fromArray([
'component_id' => $this->id->toString(),
'old_value' => $oldCount,
'new_value' => $newState->count,
'change' => '+1',
]));
// Dispatch milestone event at multiples of 10
if ($newState->count % 10 === 0) {
$events?->dispatch('counter:milestone', EventPayload::fromArray([
'component_id' => $this->id->toString(),
'milestone' => $newState->count,
'message' => "Counter reached {$newState->count}!",
]));
}
return $newState;
}
#[Action]
public function decrement(?ComponentEventDispatcher $events = null): CounterState
{
$oldCount = $this->state->count;
$newState = $this->state->decrement();
// Prevent negative counts
if ($newState->count < 0) {
$newState = $newState->withCount(0);
}
// Dispatch counter:changed event
$events?->dispatch('counter:changed', EventPayload::fromArray([
'component_id' => $this->id->toString(),
'old_value' => $oldCount,
'new_value' => $newState->count,
'change' => '-1',
]));
return $newState;
}
#[Action]
public function reset(?ComponentEventDispatcher $events = null): CounterState
{
$oldCount = $this->state->count;
$newState = $this->state->reset();
// Dispatch counter:reset event
$events?->dispatch('counter:reset', EventPayload::fromArray([
'component_id' => $this->id->toString(),
'previous_value' => $oldCount,
]));
return $newState;
}
#[Action]
public function addAmount(int $amount, ?ComponentEventDispatcher $events = null): CounterState
{
$oldCount = $this->state->count;
$newState = $this->state->addAmount($amount);
// Prevent negative counts
if ($newState->count < 0) {
$newState = $newState->withCount(0);
}
// Dispatch counter:changed event
$events?->dispatch('counter:changed', EventPayload::fromArray([
'component_id' => $this->id->toString(),
'old_value' => $oldCount,
'new_value' => $newState->count,
'change' => ($amount >= 0 ? '+' : '') . $amount,
]));
// Dispatch milestone event if crossed a multiple of 10
$oldMilestone = (int)($oldCount / 10) * 10;
$newMilestone = (int)($newState->count / 10) * 10;
if ($newMilestone > $oldMilestone) {
$events?->dispatch('counter:milestone', EventPayload::fromArray([
'component_id' => $this->id->toString(),
'milestone' => $newMilestone,
'message' => "Counter reached {$newMilestone}!",
]));
}
return $newState;
}
#[Action]
public function poll(): CounterState
{
// Simulate polling - in real app this would fetch data from service
return $this->state->withLastUpdate(date('H:i:s'));
}
public function getPollInterval(): int
{
return 10000; // Poll every 10 seconds
}
}

View File

@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace App\Application\LiveComponents\Counter;
use App\Application\LiveComponents\LiveComponentState;
/**
* Type-safe state for CounterComponent
*
* Provides compile-time type safety for component state.
* All properties are readonly and immutable - transformations return new instances.
*
* Implements LiveComponentState interface for framework integration.
*/
final readonly class CounterState implements LiveComponentState
{
public function __construct(
public int $count = 0,
public string $lastUpdate = '',
public int $renderCount = 0
) {
}
/**
* Create from array data (from client or storage)
*/
public static function fromArray(array $data): self
{
return new self(
count: (int) ($data['count'] ?? 0),
lastUpdate: (string) ($data['last_update'] ?? ''),
renderCount: (int) ($data['render_count'] ?? 0)
);
}
/**
* Create empty state with defaults
*/
public static function empty(): self
{
return new self();
}
/**
* Increment counter (immutable transformation)
*/
public function increment(): self
{
return new self(
count: $this->count + 1,
lastUpdate: date('H:i:s'),
renderCount: $this->renderCount
);
}
/**
* Decrement counter (immutable transformation)
*/
public function decrement(): self
{
return new self(
count: $this->count - 1,
lastUpdate: date('H:i:s'),
renderCount: $this->renderCount
);
}
/**
* Add specific amount to counter
*/
public function addAmount(int $amount): self
{
return new self(
count: $this->count + $amount,
lastUpdate: date('H:i:s'),
renderCount: $this->renderCount
);
}
/**
* Reset counter to zero
*/
public function reset(): self
{
return new self(
count: 0,
lastUpdate: date('H:i:s'),
renderCount: $this->renderCount
);
}
/**
* Set specific count value
*/
public function withCount(int $count): self
{
return new self(
count: $count,
lastUpdate: $this->lastUpdate,
renderCount: $this->renderCount
);
}
/**
* Update last update timestamp
*/
public function withLastUpdate(string $timestamp): self
{
return new self(
count: $this->count,
lastUpdate: $timestamp,
renderCount: $this->renderCount
);
}
/**
* Increment render count (for tracking component renders)
*/
public function incrementRenderCount(): self
{
return new self(
count: $this->count,
lastUpdate: $this->lastUpdate,
renderCount: $this->renderCount + 1
);
}
/**
* Convert to array for template rendering
*/
public function toArray(): array
{
return [
'count' => $this->count,
'last_update' => $this->lastUpdate,
'render_count' => $this->renderCount,
];
}
}

View File

@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace App\Application\LiveComponents\DTOs;
/**
* Create Order Request DTO
*
* Complex example with nested structures and validation.
* Demonstrates advanced parameter binding capabilities.
*
* Usage in LiveComponent action:
* ```php
* #[Action]
* public function createOrder(CreateOrderRequest $request): ComponentData
* {
* $order = $this->orderService->create($request);
* return $this->getData()->with('order', $order);
* }
* ```
*/
final readonly class CreateOrderRequest
{
/**
* @param array<OrderItemData> $items Order items
*/
public function __construct(
public string $customerId,
public array $items,
public ?string $shippingAddress = null,
public ?string $notes = null,
public bool $expressShipping = false
) {
$this->validate();
}
/**
* Validate order request
*/
private function validate(): void
{
if (empty($this->customerId)) {
throw new \InvalidArgumentException('Customer ID is required');
}
if (empty($this->items)) {
throw new \InvalidArgumentException('Order must contain at least one item');
}
if (count($this->items) > 100) {
throw new \InvalidArgumentException('Order cannot contain more than 100 items');
}
// Validate each item
foreach ($this->items as $index => $item) {
if (! is_array($item) || ! isset($item['product_id'], $item['quantity'])) {
throw new \InvalidArgumentException(
"Invalid item structure at index {$index}. Expected product_id and quantity."
);
}
if ($item['quantity'] < 1) {
throw new \InvalidArgumentException(
"Item quantity must be at least 1 at index {$index}"
);
}
}
if ($this->notes !== null && strlen($this->notes) > 1000) {
throw new \InvalidArgumentException('Notes cannot exceed 1000 characters');
}
}
/**
* Get total items count
*/
public function getTotalItems(): int
{
return count($this->items);
}
/**
* Get total quantity across all items
*/
public function getTotalQuantity(): int
{
return array_sum(array_column($this->items, 'quantity'));
}
/**
* Check if order requires shipping
*/
public function requiresShipping(): bool
{
return $this->shippingAddress !== null;
}
/**
* Convert to array for storage
*/
public function toArray(): array
{
return [
'customer_id' => $this->customerId,
'items' => $this->items,
'shipping_address' => $this->shippingAddress,
'notes' => $this->notes,
'express_shipping' => $this->expressShipping,
'total_items' => $this->getTotalItems(),
'total_quantity' => $this->getTotalQuantity(),
];
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Application\LiveComponents\DTOs;
/**
* Update Profile Request DTO
*
* Example DTO for testing parameter binding with constructor promotion.
* Demonstrates automatic instantiation from action parameters.
*
* Usage in LiveComponent action:
* ```php
* #[Action]
* public function updateProfile(UpdateProfileRequest $request): ComponentData
* {
* // $request is automatically instantiated from action parameters
* $this->name = $request->name;
* $this->email = $request->email;
*
* return $this->getData();
* }
* ```
*/
final readonly class UpdateProfileRequest
{
public function __construct(
public string $name,
public string $email,
public ?string $bio = null,
public ?string $website = null
) {
$this->validate();
}
/**
* Validate DTO data
*/
private function validate(): void
{
if (empty(trim($this->name))) {
throw new \InvalidArgumentException('Name cannot be empty');
}
if (! filter_var($this->email, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException('Invalid email address');
}
if ($this->website !== null && ! filter_var($this->website, FILTER_VALIDATE_URL)) {
throw new \InvalidArgumentException('Invalid website URL');
}
if ($this->bio !== null && strlen($this->bio) > 500) {
throw new \InvalidArgumentException('Bio cannot exceed 500 characters');
}
}
/**
* Convert to array for storage
*/
public function toArray(): array
{
return [
'name' => $this->name,
'email' => $this->email,
'bio' => $this->bio,
'website' => $this->website,
];
}
}

View File

@@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
namespace App\Application\LiveComponents\Dashboard;
use App\Framework\LiveComponents\Attributes\Action;
use App\Framework\LiveComponents\Attributes\LiveComponent;
use App\Framework\LiveComponents\ComponentEventDispatcher;
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
use App\Framework\LiveComponents\Contracts\Pollable;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\LiveComponents\ValueObjects\ComponentRenderData;
use App\Framework\LiveComponents\ValueObjects\EventPayload;
use App\Framework\Queue\Services\DeadLetterManager;
/**
* Failed Jobs List LiveComponent
*
* Displays and manages failed jobs from dead letter queue:
* - Total failed jobs count
* - Recent failed jobs with details
* - Retry functionality via actions
* - Statistics by queue and error type
*
* Polls every 10 seconds (failed jobs change less frequently).
*/
#[LiveComponent('failed-jobs-list')]
final readonly class FailedJobsListComponent implements LiveComponentContract, Pollable
{
private const MAX_DISPLAY_JOBS = 20;
public function __construct(
public ComponentId $id,
public FailedJobsState $state,
private DeadLetterManager $deadLetterManager
) {}
public function getRenderData(): ComponentRenderData
{
// Initial poll if state is empty (lastUpdated is empty string)
$state = $this->state->lastUpdated === ''
? $this->poll()
: $this->state;
return new ComponentRenderData(
templatePath: 'livecomponent-failed-jobs-list',
data: [
// LiveComponent integration
'componentId' => $this->id->toString(),
'stateJson' => json_encode($state->toArray()),
'pollInterval' => $this->getPollInterval(),
// Component data
'totalFailedJobs' => $state->totalFailedJobs,
'failedJobs' => $state->failedJobs,
'statistics' => $state->statistics,
'lastUpdated' => $state->lastUpdated,
]
);
}
#[Action]
public function retryJob(string $jobId, ?ComponentEventDispatcher $events = null): FailedJobsState
{
$success = $this->deadLetterManager->retryJob($jobId);
if ($success) {
$events?->dispatch('failed-jobs:retry-success', EventPayload::fromArray([
'component_id' => $this->id->toString(),
'job_id' => $jobId,
'message' => 'Job queued for retry',
]));
} else {
$events?->dispatch('failed-jobs:retry-failed', EventPayload::fromArray([
'component_id' => $this->id->toString(),
'job_id' => $jobId,
'message' => 'Failed to retry job',
]));
}
// Return updated state after retry
return $this->poll();
}
#[Action]
public function deleteJob(string $jobId, ?ComponentEventDispatcher $events = null): FailedJobsState
{
$success = $this->deadLetterManager->deleteJob($jobId);
if ($success) {
$events?->dispatch('failed-jobs:delete-success', EventPayload::fromArray([
'component_id' => $this->id->toString(),
'job_id' => $jobId,
'message' => 'Job deleted',
]));
}
// Return updated state after delete
return $this->poll();
}
#[Action]
public function poll(): FailedJobsState
{
// Get failed jobs (limit to recent ones)
$failedJobs = $this->deadLetterManager->getFailedJobs(
originalQueue: null,
limit: self::MAX_DISPLAY_JOBS
);
// Get statistics
$statistics = $this->deadLetterManager->getStatistics();
// Transform failed jobs to array format
$jobsArray = [];
foreach ($failedJobs as $job) {
$jobsArray[] = [
'id' => $job->id,
'queue' => $job->originalQueue,
'error' => $job->failureReason?->message ?? 'Unknown error',
'failedAt' => $job->failedAt,
'attempts' => $job->attempts,
'jobType' => $job->jobType ?? 'Unknown',
'payloadPreview' => $this->getPayloadPreview($job->jobPayload ?? ''),
];
}
return $this->state->withFailedJobs(
totalFailedJobs: count($failedJobs),
failedJobs: $jobsArray,
statistics: $statistics
);
}
public function getPollInterval(): int
{
return 10000; // Poll every 10 seconds (failed jobs change less frequently)
}
/**
* Get shortened payload preview for display
*/
private function getPayloadPreview(string $payload): string
{
if (empty($payload)) {
return '';
}
// Try to decode JSON for better preview
$decoded = json_decode($payload, true);
if ($decoded !== null) {
// Get class name if available
if (isset($decoded['class'])) {
return $decoded['class'];
}
// Get first key-value pair
foreach ($decoded as $key => $value) {
$valueStr = is_array($value) ? json_encode($value) : (string) $value;
if (strlen($valueStr) > 50) {
$valueStr = substr($valueStr, 0, 50) . '...';
}
return "{$key}: {$valueStr}";
}
}
// Fallback: truncate raw payload
return strlen($payload) > 100
? substr($payload, 0, 100) . '...'
: $payload;
}
}

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Application\LiveComponents\Dashboard;
use App\Application\LiveComponents\LiveComponentState;
/**
* Type-safe state for FailedJobsListComponent
*
* Provides real-time failed jobs monitoring and management.
* Displays dead letter queue jobs with retry functionality.
*/
final readonly class FailedJobsState implements LiveComponentState
{
public function __construct(
public int $totalFailedJobs = 0,
public array $failedJobs = [], // [['id' => string, 'queue' => string, 'error' => string, 'failed_at' => string, 'attempts' => int]]
public array $statistics = [], // ['by_queue' => [...], 'by_error_type' => [...]]
public string $lastUpdated = ''
) {}
/**
* Create from array data (from client or storage)
*/
public static function fromArray(array $data): self
{
return new self(
totalFailedJobs: (int) ($data['totalFailedJobs'] ?? $data['total_failed_jobs'] ?? 0),
failedJobs: (array) ($data['failedJobs'] ?? $data['failed_jobs'] ?? []),
statistics: (array) ($data['statistics'] ?? []),
lastUpdated: (string) ($data['lastUpdated'] ?? $data['last_updated'] ?? '')
);
}
/**
* Create empty state with defaults
*/
public static function empty(): self
{
return new self();
}
/**
* Update with fresh failed jobs data
*/
public function withFailedJobs(
int $totalFailedJobs,
array $failedJobs,
array $statistics
): self {
return new self(
totalFailedJobs: $totalFailedJobs,
failedJobs: $failedJobs,
statistics: $statistics,
lastUpdated: date('H:i:s')
);
}
/**
* Update timestamp
*/
public function withLastUpdated(string $timestamp): self
{
return new self(
totalFailedJobs: $this->totalFailedJobs,
failedJobs: $this->failedJobs,
statistics: $this->statistics,
lastUpdated: $timestamp
);
}
/**
* Convert to array for template rendering
*/
public function toArray(): array
{
return [
'totalFailedJobs' => $this->totalFailedJobs,
'failedJobs' => $this->failedJobs,
'statistics' => $this->statistics,
'lastUpdated' => $this->lastUpdated,
];
}
}

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace App\Application\LiveComponents\Dashboard;
use App\Framework\LiveComponents\Attributes\Action;
use App\Framework\LiveComponents\Attributes\LiveComponent;
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
use App\Framework\LiveComponents\Contracts\Pollable;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\LiveComponents\ValueObjects\ComponentRenderData;
use App\Framework\Queue\Queue;
use App\Framework\Queue\Services\JobMetricsManagerInterface;
/**
* Queue Statistics LiveComponent
*
* Displays real-time queue metrics:
* - Current queue size
* - Total jobs processed
* - Success/failure counts
* - Success rate percentage
* - Average execution time
*
* Polls every 5 seconds for frequent updates.
*/
#[LiveComponent('queue-stats')]
final readonly class QueueStatsComponent implements LiveComponentContract, Pollable
{
public function __construct(
public ComponentId $id,
public QueueStatsState $state,
private Queue $queue,
private JobMetricsManagerInterface $metricsManager
) {}
public function getRenderData(): ComponentRenderData
{
// Initial poll if state is empty (lastUpdated is empty string)
$state = $this->state->lastUpdated === ''
? $this->poll()
: $this->state;
return new ComponentRenderData(
templatePath: 'livecomponent-queue-stats',
data: [
// LiveComponent integration
'componentId' => $this->id->toString(),
'stateJson' => json_encode($state->toArray()),
'pollInterval' => $this->getPollInterval(),
// Component data
'currentQueueSize' => $state->currentQueueSize,
'totalJobs' => $state->totalJobs,
'successfulJobs' => $state->successfulJobs,
'failedJobs' => $state->failedJobs,
'successRate' => $state->successRate,
'avgExecutionTimeMs' => $state->avgExecutionTimeMs,
'lastUpdated' => $state->lastUpdated,
]
);
}
#[Action]
public function poll(): QueueStatsState
{
// Get current queue stats
$stats = $this->queue->getStats();
$queueSize = $stats['total_size'] ?? 0;
// Get metrics from last hour
$metrics = $this->metricsManager->getAllQueueMetrics('1 hour');
$totalJobs = 0;
$successfulJobs = 0;
$failedJobs = 0;
$totalExecutionTime = 0;
foreach ($metrics as $queueMetrics) {
$totalJobs += $queueMetrics->totalJobs;
$successfulJobs += $queueMetrics->successfulJobs;
$failedJobs += $queueMetrics->failedJobs;
$totalExecutionTime += $queueMetrics->totalExecutionTime;
}
$avgExecutionTime = $totalJobs > 0
? $totalExecutionTime / $totalJobs
: 0.0;
return $this->state->withStats(
queueSize: $queueSize,
total: $totalJobs,
successful: $successfulJobs,
failed: $failedJobs,
avgExecutionTime: $avgExecutionTime
);
}
public function getPollInterval(): int
{
return 5000; // Poll every 5 seconds for real-time updates
}
}

View File

@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App\Application\LiveComponents\Dashboard;
use App\Application\LiveComponents\LiveComponentState;
/**
* Type-safe state for QueueStatsComponent
*
* Provides real-time queue statistics and metrics.
* Immutable value object - transformations return new instances.
*/
final readonly class QueueStatsState implements LiveComponentState
{
public function __construct(
public int $currentQueueSize = 0,
public int $totalJobs = 0,
public int $successfulJobs = 0,
public int $failedJobs = 0,
public float $successRate = 0.0,
public float $avgExecutionTimeMs = 0.0,
public string $lastUpdated = ''
) {}
/**
* Create from array data (from client or storage)
*/
public static function fromArray(array $data): self
{
return new self(
currentQueueSize: (int) ($data['currentQueueSize'] ?? $data['current_queue_size'] ?? 0),
totalJobs: (int) ($data['totalJobs'] ?? $data['total_jobs'] ?? 0),
successfulJobs: (int) ($data['successfulJobs'] ?? $data['successful_jobs'] ?? 0),
failedJobs: (int) ($data['failedJobs'] ?? $data['failed_jobs'] ?? 0),
successRate: (float) ($data['successRate'] ?? $data['success_rate'] ?? 0.0),
avgExecutionTimeMs: (float) ($data['avgExecutionTimeMs'] ?? $data['avg_execution_time_ms'] ?? 0.0),
lastUpdated: (string) ($data['lastUpdated'] ?? $data['last_updated'] ?? '')
);
}
/**
* Create empty state with defaults
*/
public static function empty(): self
{
return new self();
}
/**
* Update with fresh queue stats
*/
public function withStats(
int $queueSize,
int $total,
int $successful,
int $failed,
float $avgExecutionTime
): self {
$successRate = $total > 0
? round(($successful / $total) * 100, 2)
: 0.0;
return new self(
currentQueueSize: $queueSize,
totalJobs: $total,
successfulJobs: $successful,
failedJobs: $failed,
successRate: $successRate,
avgExecutionTimeMs: round($avgExecutionTime, 2),
lastUpdated: date('H:i:s')
);
}
/**
* Update timestamp
*/
public function withLastUpdated(string $timestamp): self
{
return new self(
currentQueueSize: $this->currentQueueSize,
totalJobs: $this->totalJobs,
successfulJobs: $this->successfulJobs,
failedJobs: $this->failedJobs,
successRate: $this->successRate,
avgExecutionTimeMs: $this->avgExecutionTimeMs,
lastUpdated: $timestamp
);
}
/**
* Convert to array for template rendering
*/
public function toArray(): array
{
return [
'currentQueueSize' => $this->currentQueueSize,
'totalJobs' => $this->totalJobs,
'successfulJobs' => $this->successfulJobs,
'failedJobs' => $this->failedJobs,
'successRate' => $this->successRate,
'avgExecutionTimeMs' => $this->avgExecutionTimeMs,
'lastUpdated' => $this->lastUpdated,
];
}
}

Some files were not shown because too many files have changed in this diff Show More