Enable Discovery debug logging for production troubleshooting

- Add DISCOVERY_LOG_LEVEL=debug
- Add DISCOVERY_SHOW_PROGRESS=true
- Temporary changes for debugging InitializerProcessor fixes on production
This commit is contained in:
2025-08-11 20:13:26 +02:00
parent 59fd3dd3b1
commit 55a330b223
3683 changed files with 2956207 additions and 16948 deletions

View File

@@ -0,0 +1,253 @@
<?php
declare(strict_types=1);
namespace App\Application\Admin;
use App\Framework\Attributes\Route;
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\Result\JsonResult;
use App\Framework\Router\Result\ViewResult;
final readonly class CacheMetricsController
{
public function __construct(
private CacheMetricsInterface $cacheMetrics,
private Clock $clock
) {
}
#[Route('/admin/cache', Method::GET)]
public function showDashboard(): ViewResult
{
$metaData = new MetaData(
title: 'Cache Metriken Dashboard | Admin',
description: 'Real-time Cache Performance Monitoring und Analytics Dashboard'
);
// Get initial cache stats for template
$stats = $this->cacheMetrics->getStats();
return new ViewResult('cache-metrics', $metaData, [
'pageClass' => 'cache-metrics-page',
'initialStats' => $stats,
]);
}
#[Route('/admin/cache/metrics', Method::GET)]
public function getMetrics(): JsonResult
{
$stats = $this->cacheMetrics->getStats();
// Add debugging info about what's causing cache activity
$topKeys = $this->cacheMetrics->getTopKeys(10);
$frameworkKeys = array_filter(
array_keys($topKeys),
fn ($key) =>
str_contains($key, 'discovery') ||
str_contains($key, 'route') ||
str_contains($key, 'template') ||
str_contains($key, 'attribute')
);
return new JsonResult([
'status' => 'success',
'data' => [
'cache_stats' => $stats->toArray(),
'recommendations' => $stats->getRecommendations(),
'performance_summary' => [
'efficiency_rating' => $stats->getEfficiencyRating(),
'total_operations' => $stats->getTotalOperations(),
'ops_per_second' => $stats->calculateOpsPerSecond(),
],
'real_time' => [
'current_hit_rate' => $this->cacheMetrics->getHitRate(),
'timestamp' => $this->clock->time(),
'framework_cache_activity' => count($frameworkKeys),
'sampling_note' => 'Only ~10% of operations are recorded (sampling)',
],
],
]);
}
#[Route('/admin/cache/metrics/driver/{driver}', Method::GET)]
public function getDriverMetrics(string $driver): JsonResult
{
$driverStats = $this->cacheMetrics->getStatsForDriver($driver);
if (empty($driverStats)) {
return new JsonResult([
'status' => 'error',
'message' => "Driver '{$driver}' not found or has no statistics",
], \App\Framework\Http\Status::NOT_FOUND);
}
return new JsonResult([
'status' => 'success',
'data' => [
'driver' => $driver,
'stats' => $driverStats,
],
]);
}
#[Route('/admin/cache/metrics/top-keys', Method::GET)]
public function getTopKeys(): JsonResult
{
$topKeys = $this->cacheMetrics->getTopKeys(20);
$heaviestKeys = $this->cacheMetrics->getHeaviestKeys(20);
return new JsonResult([
'status' => 'success',
'data' => [
'most_accessed' => $topKeys,
'heaviest_keys' => $heaviestKeys,
'analysis' => $this->analyzeKeyPatterns($topKeys, $heaviestKeys),
],
]);
}
#[Route('/admin/cache/metrics/reset', Method::POST)]
public function resetMetrics(): JsonResult
{
$this->cacheMetrics->reset();
return new JsonResult([
'status' => 'success',
'message' => 'Cache metrics have been reset',
'timestamp' => $this->clock->time(),
]);
}
#[Route('/admin/cache/reset', Method::GET)]
public function resetMetricsGet(): JsonResult
{
// Allow GET for easier testing (normally should be POST)
$this->cacheMetrics->reset();
return new JsonResult([
'status' => 'success',
'message' => 'Cache metrics have been reset via GET',
'timestamp' => $this->clock->time(),
'note' => 'You can now visit /admin/cache/debug to generate test data',
]);
}
#[Route('/admin/cache/debug', Method::GET)]
public function debugTemplates(): JsonResult
{
// Generate test operations to show variety
// Generate mixed test operations to show variety
$this->cacheMetrics->recordMiss('test', 'cold_cache_miss_1', 0.012);
$this->cacheMetrics->recordMiss('test', 'cold_cache_miss_2', 0.015);
$this->cacheMetrics->recordSet('test', 'new_item_1', 2048, 3600, 0.004);
$this->cacheMetrics->recordSet('test', 'new_item_2', 4096, 1800, 0.006);
$this->cacheMetrics->recordHit('test', 'warm_cache_hit_1', 1024, 0.001);
$this->cacheMetrics->recordHit('test', 'warm_cache_hit_2', 2048, 0.002);
$this->cacheMetrics->recordDelete('test', 'expired_item', 0.003);
$stats = $this->cacheMetrics->getStats();
return new JsonResult([
'message' => 'Test cache operations generated',
'stats' => $stats->toArray(),
'hit_rate' => $this->cacheMetrics->getHitRate(),
'top_keys' => $this->cacheMetrics->getTopKeys(5),
'heaviest_keys' => $this->cacheMetrics->getHeaviestKeys(5),
]);
}
#[Route('/admin/cache/metrics/summary', Method::GET)]
public function getSummary(): JsonResult
{
$stats = $this->cacheMetrics->getStats();
return new JsonResult([
'status' => 'success',
'data' => [
'hit_rate' => round($stats->hitRate * 100, 2),
'total_operations' => $stats->getTotalOperations(),
'efficiency' => $stats->getEfficiencyRating(),
'avg_latency_ms' => round($stats->avgLatency * 1000, 2),
'total_size_mb' => round($stats->totalSize / 1024 / 1024, 2),
'active_drivers' => count($stats->driverStats),
'recommendations_count' => count($stats->getRecommendations()),
'health_status' => $this->getHealthStatus($stats),
],
]);
}
/**
* @param array<string, int> $topKeys
* @param array<string, int> $heaviestKeys
* @return array<string, mixed>
*/
private function analyzeKeyPatterns(array $topKeys, array $heaviestKeys): array
{
$analysis = [
'patterns' => [],
'insights' => [],
];
// Find common prefixes in top keys
$prefixes = [];
foreach (array_keys($topKeys) as $key) {
$parts = explode(':', $key, 2);
if (count($parts) > 1) {
$prefix = $parts[0];
$prefixes[$prefix] = ($prefixes[$prefix] ?? 0) + 1;
}
}
if (! empty($prefixes)) {
arsort($prefixes);
$analysis['patterns']['common_prefixes'] = array_slice($prefixes, 0, 5, true);
}
// Generate insights
if ($stats = $this->cacheMetrics->getStats()) {
if ($stats->hitRate < 0.7) {
$analysis['insights'][] = 'Low hit rate suggests cache keys may be too specific or TTL too short';
}
if (! empty($heaviestKeys)) {
$maxSize = 0;
foreach ($heaviestKeys as $keyData) {
if (isset($keyData['size']) && is_numeric($keyData['size'])) {
$maxSize = max($maxSize, (int)$keyData['size']);
}
}
if ($maxSize > 1024 * 1024) { // 1MB
$analysis['insights'][] = 'Large cache values detected - consider data compression or normalization';
}
}
}
return $analysis;
}
private function getHealthStatus($stats): string
{
$issues = 0;
if ($stats->hitRate < 0.7) {
$issues++;
}
if ($stats->avgLatency > 0.01) {
$issues++;
}
if ($stats->totalSize > 100 * 1024 * 1024) {
$issues++;
} // 100MB
return match (true) {
$issues === 0 => 'healthy',
$issues === 1 => 'warning',
default => 'critical'
};
}
}

View File

@@ -1,43 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Application\Admin;
use App\Framework\Attributes\Route;
use App\Framework\Auth\Auth;
use App\Framework\Config\Environment;
use App\Framework\Config\TypedConfiguration;
use App\Framework\Core\VersionInfo;
use App\Framework\DateTime\Clock;
use App\Framework\DI\DefaultContainer;
use App\Framework\Http\HttpResponse;
use App\Framework\Http\Method;
use App\Framework\Http\Response;
use App\Framework\Meta\MetaData;
use App\Framework\Meta\OpenGraphTypeWebsite;
use App\Framework\Performance\MemoryUsageTracker;
use App\Framework\Router\Result\ViewResult;
use App\Framework\Http\Session\SessionManager;
use App\Framework\Http\Status;
use Dom\HTMLDocument;
use App\Framework\Meta\MetaData;
use App\Framework\Performance\MemoryMonitor;
use App\Framework\Redis\RedisConnectionPool;
use App\Framework\Router\Result\ViewResult;
final readonly class Dashboard
{
public function __construct(
private DefaultContainer $container,
private VersionInfo $versionInfo,
private MemoryUsageTracker $memoryTracker,
private TypedConfiguration $config,
) {}
private MemoryMonitor $memoryMonitor,
private Clock $clock,
) {
}
#[Auth]
#[Route(path: '/admin', method: Method::GET)]
public function show(): ViewResult
{
/** @var array<string, mixed> $stats */
$stats = [
'frameworkVersion' => $this->versionInfo->getVersion(),
'phpVersion' => PHP_VERSION,
'memoryUsage' => $this->formatBytes(memory_get_usage(true)),
'peakMemoryUsage' => $this->formatBytes(memory_get_peak_usage(true)),
'memoryUsage' => $this->memoryMonitor->getCurrentMemory()->toHumanReadable(),
'peakMemoryUsage' => $this->memoryMonitor->getPeakMemory()->toHumanReadable(),
'serverInfo' => $_SERVER['SERVER_SOFTWARE'] ?? 'Unknown',
'serverTime' => date('Y-m-d H:i:s'),
'serverTime' => $this->clock->now()->format('Y-m-d H:i:s'),
'timezone' => date_default_timezone_get(),
'operatingSystem' => PHP_OS,
'loadedExtensions' => $this->getLoadedExtensions(),
@@ -51,9 +57,10 @@ final readonly class Dashboard
return new ViewResult(
template: 'dashboard',
metaData: new MetaData('Admin Dashboard'),
/** @var array<string, mixed> */
data: [
'title' => 'Admin Dashboard',
'stats' => $stats
'stats' => $stats,
]
);
}
@@ -66,7 +73,7 @@ final readonly class Dashboard
$routes = $routeRegistry->getRoutes();
// Sort routes by path for better readability
usort($routes, function($a, $b) {
usort($routes, function ($a, $b) {
return strcmp($a->path, $b->path);
});
@@ -75,7 +82,7 @@ final readonly class Dashboard
metaData: new MetaData('', ''),
data: [
'title' => 'Routen-Übersicht',
'routes' => $routes
'routes' => $routes,
]
);
}
@@ -84,14 +91,44 @@ final readonly class Dashboard
#[Route(path: '/admin/services', method: Method::GET)]
public function services(): ViewResult
{
$services = $this->container->getServiceIds();
sort($services);
$registeredServices = $this->container->getRegisteredServices();
// The registered services are returned as a numeric array with class names as values
$serviceNames = array_filter($registeredServices, 'is_string');
$serviceNames = array_unique($serviceNames); // Remove duplicates
sort($serviceNames);
// Prepare service data with categorization
$serviceData = [];
foreach ($serviceNames as $serviceName) {
// Ensure $serviceName is a string before exploding
if (is_string($serviceName) && strpos($serviceName, '\\') !== false) {
$parts = explode('\\', $serviceName);
$category = $parts[1] ?? 'Unknown';
$subCategory = $parts[2] ?? '';
$serviceData[] = [
'name' => $serviceName,
'category' => $category,
'subCategory' => $subCategory,
];
} else {
// Handle non-namespaced services
$serviceData[] = [
'name' => (string)$serviceName,
'category' => 'Other',
'subCategory' => '',
];
}
}
return new ViewResult(
template: 'admin/services',
template: 'services',
metaData: new MetaData('', ''),
data: [
'title' => 'Registrierte Dienste',
'services' => $services
'services' => $serviceData,
'servicesCount' => count($serviceData),
]
);
}
@@ -100,27 +137,32 @@ final readonly class Dashboard
#[Route(path: '/admin/environment', method: Method::GET)]
public function environment(): ViewResult
{
$environment = $this->container->get(Environment::class);
/** @var array<int, array<string, string>> $env */
$env = [];
foreach ($_ENV as $key => $value) {
foreach ($environment->all() as $key => $value) {
// Maskiere sensible Daten
if (str_contains(strtolower($key), 'password') ||
str_contains(strtolower($key), 'secret') ||
str_contains(strtolower($key), 'key')) {
$value = '********';
}
$env[$key] = $value;
$env[] = [
'key' => $key,
'value' => is_array($value) ? json_encode($value) : (string)$value,
];
}
ksort($env);
dd($env);
// Sort by key
usort($env, fn ($a, $b) => strcmp($a['key'], $b['key']));
return new ViewResult(
template: 'admin/environment',
template: 'environment',
metaData: new MetaData('', ''),
data: [
'title' => 'Umgebungsvariablen',
'env' => $env
'env' => $env,
'current_year' => $this->clock->now()->format('Y'),
]
);
}
@@ -128,10 +170,10 @@ final readonly class Dashboard
#[Auth]
#[Route('/admin/phpinfo')]
#[Route(path: '/admin/phpinfo/{mode}', method: Method::GET)]
public function phpInfo(int $mode = 1): Response
public function phpInfo(string $mode = '1'): Response
{
ob_start();
phpinfo($mode);
phpinfo((int)$mode);
$phpinfo = ob_get_clean();
// Extraktion des <body> Inhalts, um nur den relevanten Teil anzuzeigen
@@ -182,15 +224,17 @@ final readonly class Dashboard
#[Route(path: '/admin/performance', method: Method::GET)]
public function performance(): ViewResult
{
/** @var array<string, mixed> $performanceData */
$performanceData = [
'currentMemoryUsage' => $this->formatBytes(memory_get_usage(true)),
'peakMemoryUsage' => $this->formatBytes(memory_get_peak_usage(true)),
'memoryLimit' => $this->formatBytes($this->getMemoryLimitInBytes()),
'memoryUsagePercentage' => round((memory_get_usage(true) / $this->getMemoryLimitInBytes()) * 100, 2),
'currentMemoryUsage' => $this->memoryMonitor->getCurrentMemory()->toHumanReadable(),
'peakMemoryUsage' => $this->memoryMonitor->getPeakMemory()->toHumanReadable(),
'memoryLimit' => $this->memoryMonitor->getMemoryLimit()->toHumanReadable(),
'memoryUsagePercentage' => $this->memoryMonitor->getMemoryUsagePercentage()->format(2),
'loadAverage' => function_exists('sys_getloadavg') ? sys_getloadavg() : ['N/A', 'N/A', 'N/A'],
'opcacheEnabled' => function_exists('opcache_get_status') ? 'Ja' : 'Nein',
'executionTime' => number_format(microtime(true) - $_SERVER['REQUEST_TIME_FLOAT'], 4) . ' Sekunden',
'includedFiles' => count(get_included_files()),
'files' => get_included_files(),
];
if (function_exists('opcache_get_status')) {
@@ -210,9 +254,11 @@ final readonly class Dashboard
return new ViewResult(
template: 'performance',
metaData: new MetaData('Performance-Daten', 'Performance-Daten'),
data: [
'title' => 'Performance-Daten',
'performance' => $performanceData
'performance' => $performanceData,
'current_year' => $this->clock->now()->format('Y'),
]
);
}
@@ -221,10 +267,11 @@ final readonly class Dashboard
#[Route(path: '/admin/redis', method: Method::GET)]
public function redisInfo(): ViewResult
{
/** @var array<string, mixed> $redisInfo */
$redisInfo = [];
try {
$redis = $this->container->get('Predis\Client');
$redis = $this->container->get(RedisConnectionPool::class)->getConnection()->getClient();
$info = $redis->info();
$redisInfo['status'] = 'Verbunden';
$redisInfo['version'] = $info['redis_version'];
@@ -236,16 +283,20 @@ final readonly class Dashboard
// Einige Schlüssel auflisten (begrenzt auf 50)
$keys = $redis->keys('*');
$redisInfo['key_sample'] = array_slice($keys, 0, 50);
/** @var array<int, string> $keySample */
$keySample = array_slice($keys, 0, 50);
$redisInfo['key_sample'] = $keySample;
} catch (\Throwable $e) {
$redisInfo['status'] = 'Fehler: ' . $e->getMessage();
}
return new ViewResult(
template: 'admin/redis',
template: 'redis',
metaData: new MetaData('Redis Information', 'Redis Information'),
data: [
'title' => 'Redis Information',
'redis' => $redisInfo
'redis' => $redisInfo,
'current_year' => $this->clock->now()->format('Y'),
]
);
}
@@ -272,25 +323,32 @@ final readonly class Dashboard
}
$value = (int) $memoryLimit;
$unit = strtolower($memoryLimit[strlen($memoryLimit)-1]);
$unit = strtolower($memoryLimit[strlen($memoryLimit) - 1]);
switch($unit) {
switch ($unit) {
case 'g':
$value *= 1024;
// no break
case 'm':
$value *= 1024;
// no break
case 'k':
$value *= 1024;
break;
}
return $value;
}
/**
* @return array<int, string>
*/
private function getLoadedExtensions(): array
{
$extensions = get_loaded_extensions();
sort($extensions);
return $extensions;
}
@@ -299,6 +357,7 @@ final readonly class Dashboard
try {
if ($this->container->has(SessionManager::class)) {
$sessionManager = $this->container->get(SessionManager::class);
// Diese Methode müsste implementiert werden
return $sessionManager->getActiveSessionCount();
}

View File

@@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
namespace App\Application\Admin;
use App\Framework\Attributes\Route;
use App\Framework\Health\HealthCheckCategory;
use App\Framework\Health\HealthCheckManager;
use App\Framework\Http\Method;
use App\Framework\Meta\MetaData;
use App\Framework\Router\Result\JsonResult;
use App\Framework\Router\Result\ViewResult;
final readonly class HealthController
{
public function __construct(
private HealthCheckManager $healthManager
) {
}
#[Route('/admin/health', Method::GET)]
public function showDashboard(): ViewResult
{
$metaData = new MetaData(
title: 'System Health Dashboard',
description: 'Real-time system health monitoring and diagnostics'
);
return new ViewResult('health-dashboard', $metaData, [
'pageClass' => 'health-dashboard-page',
]);
}
#[Route('/admin/health-optimized', Method::GET)]
public function showOptimizedDashboard(): ViewResult
{
// Get initial health data for view processors
$healthReport = $this->healthManager->runAllChecks();
$metaData = new MetaData(
title: 'System Health Dashboard',
description: 'Real-time system health monitoring'
);
// Prepare data for view processors
$healthChecks = [];
foreach ($healthReport->results as $componentName => $result) {
$details = [];
if (! empty($result->details)) {
foreach ($result->details as $key => $value) {
$details[] = ['key' => $key, 'value' => is_scalar($value) ? (string)$value : json_encode($value)];
}
}
$healthChecks[] = [
'componentName' => $componentName,
'status' => $result->status->value,
'output' => $result->message,
'details' => $details,
'time' => $result->responseTime ? round($result->responseTime * 1000, 2) . 'ms' : null,
];
}
return new ViewResult('health-dashboard-optimized', $metaData, [
'overall_status' => $healthReport->overallStatus->value,
'health_checks' => $healthChecks,
'is_loading' => false,
'error_message' => null,
'subtitle' => 'Real-time System Health Monitoring',
'pageClass' => 'health-dashboard-page',
]);
}
#[Route('/admin/health/api/status', Method::GET)]
/**
* @return JsonResult<array{status: string, data: array<string, mixed>}>
*/
public function getSystemStatus(): JsonResult
{
$report = $this->healthManager->runAllChecks();
return new JsonResult([
'status' => 'success',
'data' => $report->toArray(),
]);
}
#[Route('/admin/health/api/check/{name}', Method::GET)]
/**
* @return JsonResult<array{status: string, data?: array{check_name: string, result: array<string, mixed>}, message?: string, available_checks?: array<int, string>}>
*/
public function runSingleCheck(string $name): JsonResult
{
$result = $this->healthManager->runCheck($name);
if ($result === null) {
return new JsonResult([
'status' => 'error',
'message' => "Health check '{$name}' not found",
'available_checks' => $this->healthManager->getRegisteredChecks(),
], \App\Framework\Http\Status::NOT_FOUND);
}
return new JsonResult([
'status' => 'success',
'data' => [
'check_name' => $name,
'result' => $result->toArray(),
],
]);
}
#[Route('/admin/health/api/category/{category}', Method::GET)]
/**
* @return JsonResult<array{status: string, data?: array{category: string, category_display: string, category_icon: string, report: array<string, mixed>}, message?: string, available_categories?: array<int, string>}>
*/
public function getChecksByCategory(string $category): JsonResult
{
try {
$categoryEnum = HealthCheckCategory::from($category);
$report = $this->healthManager->runChecksByCategory($categoryEnum);
return new JsonResult([
'status' => 'success',
'data' => [
'category' => $category,
'category_display' => $categoryEnum->getDisplayName(),
'category_icon' => $categoryEnum->getIcon(),
'report' => $report->toArray(),
],
]);
} catch (\ValueError $e) {
return new JsonResult([
'status' => 'error',
'message' => "Invalid category '{$category}'",
'available_categories' => array_column(HealthCheckCategory::cases(), 'value'),
], \App\Framework\Http\Status::BAD_REQUEST);
}
}
#[Route('/admin/health/api/summary', Method::GET)]
/**
* @return JsonResult<array{status: string, data: array{overall_status: string, overall_icon: string, overall_color: string, summary_text: string, quick_stats: array<string, int>, categories: array<string, mixed>, timestamp: int}}>
*/
public function getHealthSummary(): JsonResult
{
$report = $this->healthManager->runAllChecks();
$checksByCategory = $this->healthManager->getChecksByCategory();
return new JsonResult([
'status' => 'success',
'data' => [
'overall_status' => $report->overallStatus->value,
'overall_icon' => $report->overallStatus->getIcon(),
'overall_color' => $report->overallStatus->getColor(),
'summary_text' => $report->getSummary(),
'quick_stats' => [
'total_checks' => count($report->results),
'healthy' => count($report->getHealthyChecks()),
'warnings' => count($report->getWarningChecks()),
'failed' => count($report->getFailedChecks()),
'categories' => count($checksByCategory),
],
'categories' => $checksByCategory,
'timestamp' => time(),
],
]);
}
#[Route('/admin/health/api/run-all', Method::POST)]
/**
* @return JsonResult<array{status: string, message: string, data: array<string, mixed>}>
*/
public function runAllChecks(): JsonResult
{
$report = $this->healthManager->runAllChecks();
return new JsonResult([
'status' => 'success',
'message' => 'All health checks completed',
'data' => $report->toArray(),
]);
}
}

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Application\Admin;
@@ -12,21 +13,27 @@ final readonly class Images
{
#[Auth]
#[Route('/admin/images')]
public function showAll(ImageRepository $imageRepository)
public function showAll(ImageRepository $imageRepository): never
{
$images = $imageRepository->findAll();
echo "<div style='background-color: #222; display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); grid-gap: 16px; padding: 16px;'>";
/** @var Image $image */
foreach($images as $image) {
foreach ($images as $image) {
echo sprintf("<a href='/images/%s'>", $image->filename);
echo "<img src='/images/" . $image->filename . "' style='width: 400px; aspect-ratio: 1; object-fit: cover; border-radius: 16px;'/>";
echo "</a>";
var_dump($image->variants[0]->filename ?? 'no variant');
// Check if variants are loaded to avoid uninitialized property error
try {
$variantInfo = $image->variants[0]->filename ?? 'no variant';
echo "<small>Variant: " . htmlspecialchars($variantInfo) . "</small>";
} catch (Error $e) {
echo "<small>Variants not loaded yet</small>";
}
}
echo "</div>";

View File

@@ -0,0 +1,211 @@
<?php
declare(strict_types=1);
namespace App\Application\Admin;
use App\Framework\Attributes\Route;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\DateTime\Timer;
use App\Framework\Http\Method;
use App\Framework\Http\SseStream;
use App\Framework\Http\Status;
use App\Framework\Logging\LogViewer;
use App\Framework\Meta\MetaData;
use App\Framework\Router\Result\JsonResult;
use App\Framework\Router\Result\SseResult;
use App\Framework\Router\Result\ViewResult;
final readonly class LogViewerController
{
public function __construct(
private LogViewer $logViewer,
private Timer $timer
) {
}
#[Route('/admin/logs', Method::GET)]
public function showLogViewer(): ViewResult
{
$metaData = new MetaData(
title: 'Log Viewer | Admin',
description: 'Real-time log viewing and analysis'
);
return new ViewResult('log-viewer', $metaData, [
'pageClass' => 'log-viewer-page',
]);
}
#[Route('/admin/logs/api/list', Method::GET)]
/**
* @return JsonResult<array{status: string, data: array{logs: array<string, mixed>, total_logs: int}}>
*/
public function getAvailableLogs(): JsonResult
{
$logs = $this->logViewer->getAvailableLogs();
return new JsonResult([
'status' => 'success',
'data' => [
'logs' => $logs,
'total_logs' => count($logs),
],
]);
}
#[Route('/admin/logs/api/read/{logName}', Method::GET)]
/**
* @return JsonResult<array{status: string, data?: array<string, mixed>, message?: string, available_logs?: array<int, string>}>
*/
public function readLog(string $logName): JsonResult
{
$limit = (int) ($_GET['limit'] ?? 100);
$level = $_GET['level'] ?? null;
$search = $_GET['search'] ?? null;
try {
$logData = $this->logViewer->readLog($logName, $limit, $level, $search);
return new JsonResult([
'status' => 'success',
'data' => $logData,
]);
} catch (\InvalidArgumentException $e) {
return new JsonResult([
'status' => 'error',
'message' => $e->getMessage(),
'available_logs' => array_keys($this->logViewer->getAvailableLogs()),
], Status::NOT_FOUND);
}
}
#[Route('/admin/logs/api/tail/{logName}', Method::GET)]
/**
* @return JsonResult<array{status: string, data?: array<string, mixed>, message?: string}>
*/
public function tailLog(string $logName): JsonResult
{
$lines = (int) ($_GET['lines'] ?? 50);
try {
$logData = $this->logViewer->tailLog($logName, $lines);
return new JsonResult([
'status' => 'success',
'data' => $logData,
]);
} catch (\InvalidArgumentException $e) {
return new JsonResult([
'status' => 'error',
'message' => $e->getMessage(),
], Status::NOT_FOUND);
}
}
#[Route('/admin/logs/api/search', Method::GET)]
/**
* @return JsonResult<array{status: string, data?: array<string, mixed>, message?: string}>
*/
public function searchLogs(): JsonResult
{
$query = $_GET['query'] ?? '';
$level = $_GET['level'] ?? null;
$logs = isset($_GET['logs']) ? explode(',', $_GET['logs']) : null;
if (empty($query)) {
return new JsonResult([
'status' => 'error',
'message' => 'Query parameter is required',
], Status::BAD_REQUEST);
}
$results = $this->logViewer->searchLogs($query, $logs, $level);
return new JsonResult([
'status' => 'success',
'data' => $results,
]);
}
#[Route('/admin/logs/api/levels', Method::GET)]
/**
* @return JsonResult<array{status: string, data: array{levels: array<string, array{color: string, icon: string}>}}>
*/
public function getLogLevels(): JsonResult
{
return new JsonResult([
'status' => 'success',
'data' => [
'levels' => [
'DEBUG' => ['color' => '#6c757d', 'icon' => '🔍'],
'INFO' => ['color' => '#17a2b8', 'icon' => ''],
'WARNING' => ['color' => '#ffc107', 'icon' => '⚠️'],
'ERROR' => ['color' => '#dc3545', 'icon' => '❌'],
'CRITICAL' => ['color' => '#800020', 'icon' => '🚨'],
],
],
]);
}
#[Route('/admin/logs/api/stream/{logName}', Method::GET)]
public function streamLog(string $logName): SseResult
{
$level = $_GET['level'] ?? null;
$search = $_GET['search'] ?? null;
$batchSize = (int) ($_GET['batch_size'] ?? 10);
try {
static $executed = false;
return new SseResult(
callback: function (SseStream $stream) use ($logName, $level, $search, $batchSize, &$executed) {
// Verhindere mehrfache Ausführung
if ($executed || ! $stream->isActive()) {
return;
}
$executed = true;
foreach ($this->logViewer->streamLog($logName, $level, $search, $batchSize) as $batch) {
// Prüfe Verbindung vor jedem Batch
if (! $stream->isConnectionActive() || ! $stream->isActive()) {
break;
}
$stream->sendJson([
'status' => 'success',
'batch' => $batch['batch'],
'batch_number' => $batch['batch_number'],
'entries_in_batch' => $batch['entries_in_batch'],
'total_processed' => $batch['total_processed'],
'is_final' => $batch['is_final'] ?? false,
], 'log_batch');
// Kleine Pause für bessere Performance
$this->timer->sleep(Duration::fromMilliseconds(50));
}
// Sende finales Event
if ($stream->isConnectionActive() && $stream->isActive()) {
$stream->sendJson(['status' => 'complete'], 'stream_complete');
}
// Stream beenden
$stream->close('Log streaming completed');
}, // 1 Minute Maximum
maxDuration: 60, // Heartbeat alle 10 Sekunden
heartbeatInterval: 10
);
} catch (\InvalidArgumentException $e) {
return new SseResult(callback: function (SseStream $stream) use ($e) {
$stream->sendJson([
'status' => 'error',
'message' => $e->getMessage(),
], 'error');
});
}
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Application\Admin;
use App\Framework\View\Template;
@@ -8,12 +10,13 @@ use App\Framework\View\Template;
class RoutesViewModel
{
public string $name = 'Michael';
public string $title = 'Routes';
public function __construct(
/** @var array<string, mixed> */
public array $routes = []
)
{
) {
}

View File

@@ -1,37 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Application\Admin;
use App\Framework\Attributes\Route;
use App\Framework\Auth\Auth;
use App\Framework\Cache\Cache;
use App\Framework\Discovery\Results\DiscoveryResults;
use App\Framework\View\TemplateDiscoveryVisitor;
use App\Framework\Discovery\Results\DiscoveryRegistry;
class ShowDiscovery
{
public function __construct(
private DiscoveryResults $results,
)
{
private DiscoveryRegistry $results,
) {
}
#[Auth]
#[Route('/admin/discovery')]
public function show(
#Cache $cache
)
{
$attributes = $this->results->getAllAttributeResults();
): void {
$attributeTypes = $this->results->attributes()->getAllTypes();
foreach ($attributes as $name => $attribute) {
echo "Attribute: $name <br/>";
foreach ($attributeTypes as $attributeType) {
echo "Attribute: $attributeType <br/>";
echo "<ul>";
foreach ($attribute as $result) {
echo "<li>" . $result['class'] . '::'.($result['method'] ?? '').'()</li>';
};
$attributeMappings = $this->results->attributes()->get($attributeType);
foreach ($attributeMappings as $attributeMapping) {
$className = $attributeMapping->class->getFullyQualified();
$methodName = $attributeMapping->method?->toString() ?? '';
echo "<li>" . $className . '::' . $methodName . '()</li>';
}
echo "</ul>";
};
}
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Application\Admin;
use App\Domain\Media\ImageRepository;
use App\Domain\Media\ImageSlotRepository;
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;
final readonly class ShowImageManager
{
public function __construct(
private ImageSlotRepository $slotRepository,
private ImageRepository $imageRepository
) {
}
#[Route(path: '/admin/image-manager', method: Method::GET)]
// #[Auth(strategy: 'ip', allowedIps: ['127.0.0.1', '::1'])]
public function show(): ViewResult
{
$slots = $this->slotRepository->findAllWithImages();
$images = $this->imageRepository->findAll(100, 0);
return new ViewResult(
'image-manager',
new MetaData('Image Manager'),
[
'slots' => $slots,
'images' => $images,
]
);
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Application\Admin;
use App\Domain\Media\ImageSlot;
@@ -15,20 +17,21 @@ class ShowImageSlots
{
public function __construct(
private ImageSlotRepository $imageSlotRepository,
) {}
) {
}
#[Auth]
#[Route('/admin/imageslots')]
public function show()
public function show(): ViewResult
{
$slots = $this->imageSlotRepository->getSlots();
/** @var ImageSlot $slot */
foreach($slots as $slot) {
foreach ($slots as $slot) {
#echo $slot->slotName . '<br/>';
if($slot->image !== null) {
# echo $slot->image->filename . '<br/>';
if (isset($slot->image)) {
echo $slot->image->filename . '<br/>';
}
$slotName = $slot->slotName;
@@ -59,7 +62,7 @@ class ShowImageSlots
#[Auth]
#[Route('/admin/imageslots/create', method: Method::POST)]
public function create(Request $request)
public function create(Request $request): void
{
$name = $request->parsedBody->get('slotName');
@@ -68,11 +71,12 @@ class ShowImageSlots
$this->imageSlotRepository->save($slot);
debug($name);
// TODO: Return proper response or redirect
}
#[Auth]
#[Route('/admin/imageslots/edit/{id}', method: Method::PUT)]
public function edit(Request $request, string $id)
public function edit(Request $request, string $id): void
{
$name = $request->parsedBody->get('slotName');

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Application\Admin;
use App\Domain\Media\Image;
@@ -10,30 +12,23 @@ use App\Domain\Media\SaveImageFile;
use App\Framework\Attributes\Route;
use App\Framework\Auth\Auth;
use App\Framework\Core\PathProvider;
use App\Framework\Database\Transaction;
use App\Framework\DateTime\SystemClock;
use App\Framework\Http\Method;
use App\Framework\Http\Request;
use App\Framework\Http\UploadedFile;
use App\Framework\Ulid\StringConverter;
use App\Framework\Ulid\Ulid;
use Media\Services\ImageService;
use function imagecopyresampled;
use function imagecreatefromjpeg;
use function imagecreatetruecolor;
use function imagedestroy;
use function imagejpeg;
class ShowImageUpload
{
public function __construct(
private PathProvider $pathProvider,
private StringConverter $stringConverter,
) {}
) {
}
#[Auth]
#[Route('/upload')]
public function __invoke()
public function __invoke(): void
{
$html = <<<HTML
<form action="/upload" method="post" enctype="multipart/form-data">
@@ -47,9 +42,10 @@ class ShowImageUpload
echo $html;
die();
}
#[Auth]
#[Route('/upload', Method::POST)]
public function upload(Request $request, Ulid $ulid, ImageRepository $imageRepository, ImageVariantRepository $imageVariantRepository,)
public function upload(Request $request, Ulid $ulid, ImageRepository $imageRepository, ImageVariantRepository $imageVariantRepository): void
{
try {
/** @var UploadedFile $file */
@@ -76,11 +72,13 @@ class ShowImageUpload
echo "<h2>Bild bereits vorhanden</h2>";
echo "<p>Dieses Bild wurde bereits hochgeladen.</p>";
echo "<p>Bild-ID: " . htmlspecialchars($existingImage->ulid) . "</p>";
return;
}
$idStr = str_pad((string)$id, 9, '0', STR_PAD_LEFT);
$filePathPattern = sprintf('%s/%s/%s',
$filePathPattern = sprintf(
'%s/%s/%s',
substr($idStr, 0, 3),
substr($idStr, 3, 3),
substr($idStr, 6, 3),
@@ -125,7 +123,8 @@ class ShowImageUpload
#mkdir($path, 0755, true);
$variant = new ImageResizer()($image, 50, 50);
$imageVariantRepository->save($variant);;
$imageVariantRepository->save($variant);
;
$href = "/images/".$variant->filename;
echo "<a href='$href'>$href</a>";

View File

@@ -1,29 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Application\Admin;
use App\Framework\Attributes\Route;
use App\Framework\Auth\Auth;
use App\Framework\Core\PathProvider;
use App\Framework\Core\RouteCache;
use App\Framework\Discovery\Results\DiscoveryResults;
use App\Framework\Discovery\Results\DiscoveryRegistry;
use App\Framework\Meta\MetaData;
use App\Framework\Router\Result\ViewResult;
final readonly class ShowRoutes
{
public function __construct(
private PathProvider $pathProvider,
private DiscoveryResults $processedResults
){}
private DiscoveryRegistry $processedResults
) {
}
#[Auth]
#[Route('/admin/routes')]
public function show(): ViewResult
{
$routes = $this->processedResults->get(Route::class);
// Get routes from the AttributeRegistry (Routes are stored as Route::class attributes)
$routeMappings = $this->processedResults->attributes->get(Route::class);
sort($routes);
// Convert DiscoveredAttribute objects to display format
$routes = [];
foreach ($routeMappings as $discoveredAttribute) {
$routeData = $discoveredAttribute->additionalData;
$routes[] = [
'path' => $routeData['path'] ?? '',
'method' => $routeData['method'] ?? '',
'controller' => $discoveredAttribute->className->getFullyQualified(),
'handler' => $discoveredAttribute->methodName?->toString() ?? '',
'file' => $discoveredAttribute->filePath?->toString() ?? '',
'middleware' => $routeData['middleware'] ?? [],
'name' => $routeData['name'] ?? '',
'signature' => $routeData['signature'] ?? '',
];
}
// Sort routes by path for a better overview
usort($routes, fn ($a, $b) => strcmp($a['path'], $b['path']));
/*
@@ -45,12 +63,14 @@ final readonly class ShowRoutes
echo str_repeat('-', 50) . "<br/></div>";*/
return new ViewResult('routes',
return new ViewResult(
'routes',
metaData: new MetaData('Routes', 'Routes'),
data: [
'name' => 'Michael',
'title' => 'Routes',
],
model: new RoutesViewModel($routes));
model: new RoutesViewModel($routes)
);
}
}

View File

@@ -0,0 +1,184 @@
<?php
declare(strict_types=1);
namespace App\Application\Admin;
use App\Framework\Attributes\Route;
use App\Framework\Http\Method;
use App\Framework\Meta\MetaData;
use App\Framework\Router\Result\ViewResult;
final class StyleguideController
{
#[Route(path: '/admin/styleguide', method: Method::GET)]
public function showStyleguide(): ViewResult
{
$metaData = new MetaData(
title: 'Admin Styleguide',
description: 'Design System Components for Admin Interface'
);
return new ViewResult(
template: 'styleguide',
metaData: $metaData,
data: [
'components' => $this->getComponentExamples(),
'colorTokens' => $this->getColorTokens(),
'spacingTokens' => $this->getSpacingTokens(),
]
);
}
/**
* @return array<string, array<string, string>>
*/
private function getComponentExamples(): array
{
return [
'buttons' => [
'primary' => '<button class="admin-button">Primary Button</button>',
'secondary' => '<button class="admin-button admin-button--secondary">Secondary Button</button>',
'success' => '<button class="admin-button admin-button--success">Success Button</button>',
'warning' => '<button class="admin-button admin-button--warning">Warning Button</button>',
'error' => '<button class="admin-button admin-button--error">Error Button</button>',
'small' => '<button class="admin-button admin-button--small">Small Button</button>',
'large' => '<button class="admin-button admin-button--large">Large Button</button>',
],
'cards' => [
'basic' => '
<div class="admin-card">
<div class="admin-card__header">
<h3 class="admin-card__title">Basic Card</h3>
</div>
<div class="admin-card__content">
This is a basic admin card with header and content.
</div>
</div>
',
'status_success' => '
<div class="admin-card status-card status-card--success">
<div class="admin-card__header">
<h3 class="admin-card__title">✅ Success Card</h3>
<span class="admin-table__status admin-table__status--success">
<span class="status-indicator status-indicator--success"></span>
PASS
</span>
</div>
<div class="admin-card__content">
This is a success status card.
</div>
</div>
',
'metric' => '
<div class="admin-card metric-card">
<div class="metric-card__value">1,234</div>
<div class="metric-card__label">Total Users</div>
<div class="metric-card__change metric-card__change--positive">+12% this month</div>
</div>
',
],
'forms' => [
'input' => '<input type="text" class="admin-input" placeholder="Enter text here">',
'select' => '
<select class="admin-input admin-select">
<option>Choose option</option>
<option>Option 1</option>
<option>Option 2</option>
</select>
',
'search' => '
<div class="admin-search">
<input type="search" class="admin-input admin-search__input" placeholder="Search...">
</div>
',
],
'tables' => [
'basic' => '
<div class="admin-table-wrapper">
<div class="admin-table-wrapper__header">
<h3 class="admin-table-wrapper__title">Sample Data</h3>
</div>
<table class="admin-table">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>Value</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr>
<td>Sample Item 1</td>
<td>
<span class="admin-table__status admin-table__status--success">
<span class="status-indicator status-indicator--success"></span>
ACTIVE
</span>
</td>
<td>$123.45</td>
<td class="admin-table__actions">
<a href="#" class="admin-table__action">Edit</a>
<a href="#" class="admin-table__action admin-table__action--danger">Delete</a>
</td>
</tr>
<tr>
<td>Sample Item 2</td>
<td>
<span class="admin-table__status admin-table__status--warning">
<span class="status-indicator status-indicator--warning"></span>
PENDING
</span>
</td>
<td>$67.89</td>
<td class="admin-table__actions">
<a href="#" class="admin-table__action">Edit</a>
<a href="#" class="admin-table__action admin-table__action--danger">Delete</a>
</td>
</tr>
</tbody>
</table>
</div>
',
],
];
}
/**
* @return array<string, array<string, string>>
*/
private function getColorTokens(): array
{
return [
'primary' => [
'accent' => 'var(--accent)',
'bg' => 'var(--bg)',
'bg-alt' => 'var(--bg-alt)',
'text' => 'var(--text)',
'muted' => 'var(--muted)',
'border' => 'var(--border)',
],
'semantic' => [
'success' => 'var(--success)',
'warning' => 'var(--warning)',
'error' => 'var(--error)',
'info' => 'var(--info-base)',
],
];
}
/**
* @return array<string, string>
*/
private function getSpacingTokens(): array
{
return [
'space-sm' => 'var(--space-sm)',
'space-md' => 'var(--space-md)',
'space-lg' => 'var(--space-lg)',
'radius-md' => 'var(--radius-md)',
'radius-lg' => 'var(--radius-lg)',
];
}
}

View File

@@ -0,0 +1,321 @@
<?php
/** @var \App\Framework\Core\ViewModel $this */
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>System Health Dashboard - Admin</title>
<!-- CSS wird automatisch über AssetInjector geladen -->
</head>
<body class="admin-page">
<!-- Admin Header -->
<header class="admin-header">
<div class="admin-header__info">
<h1 class="admin-header__title">🏥 System Health Dashboard</h1>
<p class="admin-header__subtitle">Real-time System Health Monitoring</p>
</div>
<div class="admin-header__actions">
<button class="admin-button admin-button--secondary admin-button--small" id="refreshBtn">
🔄 Refresh All
</button>
</div>
</header>
<!-- Admin Main Content -->
<main class="admin-main">
<!-- Overall Status Card -->
<div class="admin-card status-card status-card--info" id="overallStatus">
<div class="admin-card__content" style="text-align: center;">
<div style="font-size: 4rem; margin-bottom: 1rem;" id="statusIcon">⏳</div>
<div class="metric-card__value" id="statusText" style="font-size: 1.5rem; margin-bottom: 0.5rem;">Loading...</div>
<div class="admin-card__subtitle" id="statusDescription">Checking system health...</div>
</div>
</div>
<!-- Health Checks Grid -->
<div class="admin-cards admin-cards--3-col" id="healthGrid">
<!-- Health cards will be populated here -->
</div>
<!-- Quick Actions -->
<div class="admin-card">
<div class="admin-card__header">
<h3 class="admin-card__title">Quick Actions</h3>
</div>
<div class="admin-card__content">
<div style="display: flex; gap: var(--space-md); flex-wrap: wrap;">
<button class="admin-button" id="runAllBtn">🔍 Run All Checks</button>
<button class="admin-button admin-button--secondary" id="exportBtn">📊 Export Report</button>
<button class="admin-button admin-button--secondary" onclick="window.location.href='../../../../logs'">📄 View Logs</button>
</div>
</div>
</div>
</main>
<script>
class HealthDashboard {
constructor() {
this.refreshButton = document.getElementById('refreshBtn');
this.runAllButton = document.getElementById('runAllBtn');
this.exportButton = document.getElementById('exportBtn');
this.setupEventListeners();
this.loadHealthStatus();
// Auto-refresh every 30 seconds
setInterval(() => this.loadHealthStatus(), 30000);
}
setupEventListeners() {
this.refreshButton.addEventListener('click', () => {
this.loadHealthStatus();
});
this.runAllButton.addEventListener('click', () => {
this.runAllChecks();
});
this.exportButton.addEventListener('click', () => {
this.exportReport();
});
}
async loadHealthStatus() {
try {
this.setLoading(true);
const response = await fetch('/admin/health/api/status');
if (!response.ok) throw new Error('Network error');
const data = await response.json();
if (data.status === 'success') {
this.updateOverallStatus(data.data);
this.updateHealthCards(data.data.checks);
} else {
throw new Error(data.message || 'Unknown error occurred');
}
} catch (error) {
console.error('Health check failed:', error);
this.showError(error.message);
} finally {
this.setLoading(false);
}
}
updateOverallStatus(data) {
const statusIcon = document.getElementById('statusIcon');
const statusText = document.getElementById('statusText');
const statusDescription = document.getElementById('statusDescription');
const overallCard = document.getElementById('overallStatus');
// Remove all status classes
overallCard.classList.remove('status-card--success', 'status-card--warning', 'status-card--error', 'status-card--info');
if (data.overall_status === 'healthy') {
statusIcon.textContent = '✅';
statusText.textContent = 'System Healthy';
statusDescription.textContent = 'All systems are operating normally';
overallCard.classList.add('status-card--success');
} else if (data.overall_status === 'warning') {
statusIcon.textContent = '⚠️';
statusText.textContent = 'System Warning';
statusDescription.textContent = 'Some issues detected that need attention';
overallCard.classList.add('status-card--warning');
} else if (data.overall_status === 'critical') {
statusIcon.textContent = '❌';
statusText.textContent = 'System Critical';
statusDescription.textContent = 'Critical issues detected requiring immediate attention';
overallCard.classList.add('status-card--error');
} else {
statusIcon.textContent = '❓';
statusText.textContent = 'Status Unknown';
statusDescription.textContent = 'Unable to determine system status';
overallCard.classList.add('status-card--info');
}
}
updateHealthCards(checks) {
const healthGrid = document.getElementById('healthGrid');
healthGrid.innerHTML = '';
checks.forEach(check => {
const card = this.createHealthCard(check);
healthGrid.appendChild(card);
});
}
createHealthCard(check) {
const card = document.createElement('div');
card.className = 'admin-card status-card';
// Add status-specific class
if (check.status === 'pass') {
card.classList.add('status-card--success');
} else if (check.status === 'warn') {
card.classList.add('status-card--warning');
} else if (check.status === 'fail') {
card.classList.add('status-card--error');
} else {
card.classList.add('status-card--info');
}
card.innerHTML = `
<div class="admin-card__header">
<h3 class="admin-card__title">${this.getCheckIcon(check.status)} ${check.componentName}</h3>
<span class="admin-table__status admin-table__status--${this.getStatusVariant(check.status)}">
<span class="status-indicator status-indicator--${this.getStatusVariant(check.status)}"></span>
${check.status.toUpperCase()}
</span>
</div>
<div class="admin-card__content">
${check.output ? `<p style="margin-bottom: var(--space-sm);">${check.output}</p>` : ''}
${check.details ? this.renderCheckDetails(check.details) : ''}
${check.time ? `<p class="admin-table__timestamp">Last checked: ${new Date(check.time).toLocaleString()}</p>` : ''}
</div>
`;
return card;
}
getCheckIcon(status) {
switch(status) {
case 'pass': return '✅';
case 'warn': return '⚠️';
case 'fail': return '❌';
default: return '❓';
}
}
getStatusVariant(status) {
switch(status) {
case 'pass': return 'success';
case 'warn': return 'warning';
case 'fail': return 'error';
default: return 'info';
}
}
renderCheckDetails(details) {
if (typeof details === 'object') {
return Object.entries(details).map(([key, value]) =>
`<div style="display: flex; justify-content: space-between; margin-bottom: 0.25rem;">
<span style="color: var(--muted); font-size: 0.875rem;">${key}:</span>
<span style="font-weight: 500; font-size: 0.875rem;">${value}</span>
</div>`
).join('');
}
return `<p>${details}</p>`;
}
async runAllChecks() {
try {
this.runAllButton.disabled = true;
this.runAllButton.textContent = '🔄 Running...';
const response = await fetch('/admin/health/api/run-all', {
method: 'POST'
});
if (!response.ok) throw new Error('Failed to run checks');
// Reload status after running checks
await this.loadHealthStatus();
this.showSuccess('All health checks completed successfully');
} catch (error) {
console.error('Failed to run checks:', error);
this.showError('Failed to run health checks: ' + error.message);
} finally {
this.runAllButton.disabled = false;
this.runAllButton.textContent = '🔍 Run All Checks';
}
}
async exportReport() {
try {
this.exportButton.disabled = true;
this.exportButton.textContent = '📤 Exporting...';
const response = await fetch('/admin/health/api/export');
if (!response.ok) throw new Error('Export failed');
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `health-report-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
this.showSuccess('Health report exported successfully');
} catch (error) {
console.error('Export failed:', error);
this.showError('Failed to export report: ' + error.message);
} finally {
this.exportButton.disabled = false;
this.exportButton.textContent = '📊 Export Report';
}
}
setLoading(loading) {
if (loading) {
document.getElementById('statusIcon').textContent = '⏳';
document.getElementById('statusText').textContent = 'Loading...';
document.getElementById('statusDescription').textContent = 'Checking system health...';
}
}
showError(message) {
// Create temporary error notification
const errorDiv = document.createElement('div');
errorDiv.className = 'admin-card status-card status-card--error';
errorDiv.style.cssText = 'position: fixed; top: 20px; right: 20px; z-index: 1000; min-width: 300px;';
errorDiv.innerHTML = `
<div class="admin-card__content">
<strong>Error:</strong> ${message}
<button onclick="this.parentElement.parentElement.remove()" style="float: right; background: none; border: none; color: var(--error); cursor: pointer;">×</button>
</div>
`;
document.body.appendChild(errorDiv);
// Auto-remove after 5 seconds
setTimeout(() => {
if (errorDiv.parentElement) {
errorDiv.parentElement.removeChild(errorDiv);
}
}, 5000);
}
showSuccess(message) {
// Create temporary success notification
const successDiv = document.createElement('div');
successDiv.className = 'admin-card status-card status-card--success';
successDiv.style.cssText = 'position: fixed; top: 20px; right: 20px; z-index: 1000; min-width: 300px;';
successDiv.innerHTML = `
<div class="admin-card__content">
<strong>Success:</strong> ${message}
<button onclick="this.parentElement.parentElement.remove()" style="float: right; background: none; border: none; color: var(--success); cursor: pointer;">×</button>
</div>
`;
document.body.appendChild(successDiv);
// Auto-remove after 3 seconds
setTimeout(() => {
if (successDiv.parentElement) {
successDiv.parentElement.removeChild(successDiv);
}
}, 3000);
}
}
// Initialize dashboard when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
new HealthDashboard();
});
</script>
</body>
</html>

View File

@@ -0,0 +1,496 @@
<?php
/** @var \App\Framework\Core\ViewModel $this */
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>System Health Dashboard - Admin</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f7;
color: #1d1d1f;
line-height: 1.6;
}
.header {
background: #fff;
border-bottom: 1px solid #e5e5e7;
padding: 1rem 2rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 {
font-size: 2rem;
font-weight: 700;
color: #1d1d1f;
}
.header .subtitle {
color: #86868b;
margin-top: 0.5rem;
}
.dashboard {
padding: 2rem;
max-width: 1400px;
margin: 0 auto;
}
.overall-status {
background: #fff;
border-radius: 12px;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: 0 4px 6px rgba(0,0,0,0.07);
border: 1px solid #e5e5e7;
text-align: center;
}
.status-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
.status-text {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.status-description {
color: #86868b;
font-size: 1rem;
}
.health-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 2rem;
margin-bottom: 2rem;
}
.health-card {
background: #fff;
border-radius: 12px;
padding: 2rem;
box-shadow: 0 4px 6px rgba(0,0,0,0.07);
border: 1px solid #e5e5e7;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.health-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 15px rgba(0,0,0,0.1);
}
.card-header {
display: flex;
align-items: center;
margin-bottom: 1rem;
}
.card-icon {
font-size: 2rem;
margin-right: 1rem;
}
.card-title {
font-size: 1.2rem;
font-weight: 600;
}
.status-badge {
display: inline-flex;
align-items: center;
padding: 0.5rem 1rem;
border-radius: 20px;
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 1rem;
}
.status-healthy {
background: #d1f2db;
color: #0f5132;
}
.status-warning {
background: #fff3cd;
color: #664d03;
}
.status-unhealthy {
background: #f8d7da;
color: #721c24;
}
.check-details {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #f2f2f7;
}
.detail-item {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
font-size: 0.9rem;
}
.detail-label {
color: #86868b;
}
.detail-value {
font-weight: 500;
}
.actions {
background: #fff;
border-radius: 12px;
padding: 2rem;
box-shadow: 0 4px 6px rgba(0,0,0,0.07);
border: 1px solid #e5e5e7;
}
.action-button {
background: #007aff;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s ease;
margin-right: 1rem;
}
.action-button:hover {
background: #0051d0;
}
.action-button.secondary {
background: #f2f2f7;
color: #1d1d1f;
}
.action-button.secondary:hover {
background: #e5e5e7;
}
.loading {
opacity: 0.6;
pointer-events: none;
}
.error-message {
background: #f8d7da;
border: 1px solid #f5c6cb;
color: #721c24;
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
}
@media (max-width: 768px) {
.header {
padding: 1rem;
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.dashboard {
padding: 1rem;
}
.health-grid {
grid-template-columns: 1fr;
gap: 1rem;
}
}
</style>
</head>
<body>
<div class="header">
<div>
<h1>🏥 System Health Dashboard</h1>
<p class="subtitle">Real-time System Health Monitoring</p>
</div>
<div>
<button class="action-button" id="refreshBtn">
🔄 Refresh All
</button>
</div>
</div>
<div class="dashboard" id="dashboard">
<!-- Overall Status -->
<div class="overall-status" id="overallStatus">
<div class="status-icon" id="statusIcon">⏳</div>
<div class="status-text" id="statusText">Loading...</div>
<div class="status-description" id="statusDescription">Checking system health...</div>
</div>
<!-- Health Checks Grid -->
<div class="health-grid" id="healthGrid">
<!-- Health cards will be populated here -->
</div>
<!-- Actions -->
<div class="actions">
<h3>Quick Actions</h3>
<button class="action-button" id="runAllBtn">🔍 Run All Checks</button>
<button class="action-button secondary" id="exportBtn">📊 Export Report</button>
<button class="action-button secondary" onclick="window.location.href='../../../../logs'">📄 View Logs</button>
</div>
</div>
<script>
class HealthDashboard {
constructor() {
this.refreshButton = document.getElementById('refreshBtn');
this.runAllButton = document.getElementById('runAllBtn');
this.exportButton = document.getElementById('exportBtn');
this.setupEventListeners();
this.loadHealthStatus();
// Auto-refresh every 30 seconds
setInterval(() => this.loadHealthStatus(), 30000);
}
setupEventListeners() {
this.refreshButton.addEventListener('click', () => {
this.loadHealthStatus();
});
this.runAllButton.addEventListener('click', () => {
this.runAllChecks();
});
this.exportButton.addEventListener('click', () => {
this.exportReport();
});
}
async loadHealthStatus() {
try {
this.setLoading(true);
const response = await fetch('/admin/health/api/status');
if (!response.ok) throw new Error('Network error');
const data = await response.json();
if (data.status === 'success') {
this.updateOverallStatus(data.data);
this.updateHealthCards(data.data.checks);
}
} catch (error) {
console.error('Error loading health status:', error);
this.showError('Failed to load health status');
} finally {
this.setLoading(false);
}
}
async runAllChecks() {
try {
this.setLoading(true);
const response = await fetch('/admin/health/api/run-all', {
method: 'POST'
});
if (!response.ok) throw new Error('Network error');
const data = await response.json();
if (data.status === 'success') {
this.updateOverallStatus(data.data);
this.updateHealthCards(data.data.checks);
}
} catch (error) {
console.error('Error running health checks:', error);
this.showError('Failed to run health checks');
} finally {
this.setLoading(false);
}
}
updateOverallStatus(data) {
const statusIcon = document.getElementById('statusIcon');
const statusText = document.getElementById('statusText');
const statusDescription = document.getElementById('statusDescription');
statusIcon.textContent = data.overall_icon;
statusIcon.style.color = data.overall_color;
statusText.textContent = data.overall_status.charAt(0).toUpperCase() + data.overall_status.slice(1);
statusDescription.textContent = `${data.summary.healthy_count} healthy, ${data.summary.warning_count} warnings, ${data.summary.unhealthy_count} failed`;
}
updateHealthCards(checks) {
const grid = document.getElementById('healthGrid');
grid.innerHTML = '';
checks.forEach(check => {
const card = this.createHealthCard(check);
grid.appendChild(card);
});
}
createHealthCard(check) {
const result = check.result;
const card = document.createElement('div');
card.className = 'health-card';
const statusClass = this.getStatusClass(result.status);
const responseTime = result.response_time_ms ? `${result.response_time_ms}ms` : 'N/A';
card.innerHTML = `
<div class="card-header">
<div class="card-icon">${this.getCategoryIcon(check.name)}</div>
<div class="card-title">${check.name}</div>
</div>
<div class="status-badge ${statusClass}">
${result.status.charAt(0).toUpperCase() + result.status.slice(1)}
</div>
<p>${result.message}</p>
<div class="check-details">
<div class="detail-item">
<span class="detail-label">Response Time:</span>
<span class="detail-value">${responseTime}</span>
</div>
${this.renderDetails(result.details)}
</div>
`;
return card;
}
renderDetails(details) {
if (!details || Object.keys(details).length === 0) {
return '';
}
return Object.entries(details).map(([key, value]) => `
<div class="detail-item">
<span class="detail-label">${this.formatKey(key)}:</span>
<span class="detail-value">${this.formatValue(value)}</span>
</div>
`).join('');
}
getStatusClass(status) {
switch (status) {
case 'healthy': return 'status-healthy';
case 'warning': return 'status-warning';
case 'unhealthy': return 'status-unhealthy';
default: return 'status-unhealthy';
}
}
getCategoryIcon(name) {
const icons = {
'Database Connection': '🗄️',
'Cache System': '⚡',
'Disk Space': '💾',
'System Resources': '🖥️'
};
return icons[name] || '🔧';
}
formatKey(key) {
return key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
}
formatValue(value) {
if (typeof value === 'number') {
return value.toLocaleString();
}
if (Array.isArray(value)) {
return value.join(', ');
}
if (typeof value === 'object' && value !== null) {
// Handle objects that might be arrays in disguise
if (Object.prototype.toString.call(value) === '[object Array]') {
return value.join(', ');
}
// Check if it's an object with numeric keys (PHP array)
const keys = Object.keys(value);
if (keys.length > 0 && keys.every(key => !isNaN(key))) {
return Object.values(value).join(', ');
}
// For other objects, try to stringify
try {
return JSON.stringify(value);
} catch (e) {
return String(value);
}
}
return String(value);
}
setLoading(loading) {
const dashboard = document.getElementById('dashboard');
if (loading) {
dashboard.classList.add('loading');
this.refreshButton.textContent = '⏳ Loading...';
} else {
dashboard.classList.remove('loading');
this.refreshButton.textContent = '🔄 Refresh All';
}
}
showError(message) {
const grid = document.getElementById('healthGrid');
grid.innerHTML = `
<div class="error-message">
<strong>Error:</strong> ${message}
</div>
`;
}
async exportReport() {
try {
const response = await fetch('/admin/health/api/status');
const data = await response.json();
const report = JSON.stringify(data, null, 2);
const blob = new Blob([report], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `health-report-${new Date().toISOString().slice(0, 19)}.json`;
a.click();
URL.revokeObjectURL(url);
} catch (error) {
console.error('Export failed:', error);
}
}
}
// Initialize dashboard when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
new HealthDashboard();
});
</script>
</body>
</html>

View File

@@ -0,0 +1,747 @@
<?php
/** @var \App\Framework\Core\ViewModel $this */
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Log Viewer - Admin</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f7;
color: #1d1d1f;
line-height: 1.6;
}
.header {
background: #fff;
border-bottom: 1px solid #e5e5e7;
padding: 1rem 2rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 {
font-size: 2rem;
font-weight: 700;
color: #1d1d1f;
}
.header .subtitle {
color: #86868b;
margin-top: 0.5rem;
}
.container {
display: flex;
height: calc(100vh - 80px);
}
.sidebar {
width: 300px;
background: #fff;
border-right: 1px solid #e5e5e7;
padding: 1rem;
overflow-y: auto;
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
padding: 1rem;
}
.controls {
background: #fff;
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
display: flex;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
}
.log-list {
list-style: none;
}
.log-item {
padding: 0.75rem;
border-radius: 8px;
cursor: pointer;
margin-bottom: 0.5rem;
border: 1px solid #f2f2f7;
transition: all 0.2s ease;
}
.log-item:hover {
background: #f2f2f7;
border-color: #007aff;
}
.log-item.active {
background: #007aff;
color: white;
border-color: #007aff;
}
.log-name {
font-weight: 600;
margin-bottom: 0.25rem;
}
.log-info {
font-size: 0.85rem;
opacity: 0.7;
}
.log-content {
background: #1e1e1e;
color: #f8f8f2;
border-radius: 8px;
padding: 1rem;
height: 100%;
overflow-y: auto;
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: 0.9rem;
line-height: 1.4;
}
.log-entry {
margin-bottom: 0.5rem;
padding: 0.5rem;
border-radius: 4px;
border-left: 3px solid transparent;
}
.log-entry.level-debug {
border-left-color: #6c757d;
background: rgba(108, 117, 125, 0.1);
}
.log-entry.level-info {
border-left-color: #17a2b8;
background: rgba(23, 162, 184, 0.1);
}
.log-entry.level-warning {
border-left-color: #ffc107;
background: rgba(255, 193, 7, 0.1);
}
.log-entry.level-error {
border-left-color: #dc3545;
background: rgba(220, 53, 69, 0.1);
}
.log-entry.level-critical {
border-left-color: #800020;
background: rgba(128, 0, 32, 0.2);
}
.log-timestamp {
color: #a6a6a6;
font-size: 0.85rem;
}
.log-level {
display: inline-block;
padding: 0.2rem 0.5rem;
border-radius: 3px;
font-size: 0.75rem;
font-weight: 600;
margin: 0 0.5rem;
}
.log-level.debug { background: #6c757d; color: white; }
.log-level.info { background: #17a2b8; color: white; }
.log-level.warning { background: #ffc107; color: black; }
.log-level.error { background: #dc3545; color: white; }
.log-level.critical { background: #800020; color: white; }
.log-message {
margin-top: 0.25rem;
}
.log-context {
margin-top: 0.25rem;
font-size: 0.8rem;
color: #a6a6a6;
font-style: italic;
}
.form-control {
padding: 0.5rem;
border: 1px solid #e5e5e7;
border-radius: 4px;
font-size: 0.9rem;
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: background 0.2s ease;
}
.btn-primary {
background: #007aff;
color: white;
}
.btn-primary:hover {
background: #0051d0;
}
.btn-secondary {
background: #f2f2f7;
color: #1d1d1f;
}
.btn-secondary:hover {
background: #e5e5e7;
}
.stream-indicator {
display: inline-block;
width: 8px;
height: 8px;
background: #4CAF50;
border-radius: 50%;
margin-right: 0.5rem;
animation: pulse 2s infinite;
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
.stream-status {
background: linear-gradient(135deg, #2a2a2a, #333);
border: 1px solid #4CAF50;
}
.empty-state {
text-align: center;
padding: 4rem 2rem;
color: #86868b;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
color: #86868b;
}
@media (max-width: 768px) {
.container {
flex-direction: column;
height: auto;
}
.sidebar {
width: 100%;
height: auto;
max-height: 200px;
}
.controls {
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
}
.log-content {
height: 400px;
}
}
</style>
</head>
<body>
<div class="header">
<div>
<h1>📄 Log Viewer</h1>
<p class="subtitle">Real-time Log Analysis and Monitoring</p>
</div>
<div>
<button class="btn btn-secondary" onclick="window.location.href='/admin/health'">
🏥 Health Dashboard
</button>
</div>
</div>
<div class="container">
<div class="sidebar">
<h3>Available Logs</h3>
<ul class="log-list" id="logList">
<li class="loading">Loading logs...</li>
</ul>
</div>
<div class="main-content">
<div class="controls">
<select class="form-control" id="levelFilter">
<option value="">All Levels</option>
<option value="DEBUG">Debug</option>
<option value="INFO">Info</option>
<option value="WARNING">Warning</option>
<option value="ERROR">Error</option>
<option value="CRITICAL">Critical</option>
</select>
<input type="text" class="form-control" id="searchInput" placeholder="Search logs...">
<input type="number" class="form-control" id="limitInput" value="100" min="10" max="1000" placeholder="Limit">
<button class="btn btn-primary" id="refreshBtn">🔄 Refresh</button>
<button class="btn btn-secondary" id="tailBtn">📡 Tail</button>
<button class="btn btn-secondary" id="streamBtn">📺 Stream Live</button>
<button class="btn btn-secondary" id="searchBtn">🔍 Search All</button>
</div>
<div class="log-content" id="logContent">
<div class="empty-state">
<h3>Select a log file to view</h3>
<p>Choose a log file from the sidebar to start viewing logs</p>
</div>
</div>
</div>
</div>
<script>
class LogViewer {
constructor() {
this.currentLog = null;
this.refreshInterval = null;
this.streamEventSource = null;
this.isStreaming = false;
this.streamEntries = [];
this.setupEventListeners();
this.loadAvailableLogs();
}
setupEventListeners() {
document.getElementById('refreshBtn').addEventListener('click', () => {
if (this.currentLog) {
this.loadLogContent(this.currentLog);
}
});
document.getElementById('tailBtn').addEventListener('click', () => {
if (this.currentLog) {
this.tailLog(this.currentLog);
}
});
document.getElementById('streamBtn').addEventListener('click', () => {
if (this.currentLog) {
this.toggleStream();
}
});
document.getElementById('searchBtn').addEventListener('click', () => {
this.searchAllLogs();
});
document.getElementById('searchInput').addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
if (this.currentLog) {
this.loadLogContent(this.currentLog);
}
}
});
document.getElementById('levelFilter').addEventListener('change', () => {
if (this.currentLog) {
this.loadLogContent(this.currentLog);
}
});
}
async loadAvailableLogs() {
try {
const response = await fetch('/admin/logs/api/list');
const data = await response.json();
if (data.status === 'success') {
this.renderLogList(data.data.logs);
}
} catch (error) {
console.error('Error loading logs:', error);
document.getElementById('logList').innerHTML =
'<li class="log-item">Error loading logs</li>';
}
}
renderLogList(logs) {
const logList = document.getElementById('logList');
logList.innerHTML = '';
Object.values(logs).forEach(log => {
const li = document.createElement('li');
li.className = 'log-item';
li.innerHTML = `
<div class="log-name">${log.name}</div>
<div class="log-info">
${log.size_human} • ${log.modified_human}
${!log.readable ? ' • ⚠️ Not readable' : ''}
</div>
`;
if (log.readable) {
li.addEventListener('click', () => {
this.selectLog(log.name, li);
});
} else {
li.style.opacity = '0.5';
li.style.cursor = 'not-allowed';
}
logList.appendChild(li);
});
}
selectLog(logName, element) {
// Stop any active stream
if (this.isStreaming) {
this.stopStream();
}
// Update active state
document.querySelectorAll('.log-item').forEach(item => {
item.classList.remove('active');
});
element.classList.add('active');
this.currentLog = logName;
this.loadLogContent(logName);
}
async loadLogContent(logName) {
const logContent = document.getElementById('logContent');
logContent.innerHTML = '<div class="loading">Loading log content...</div>';
try {
const params = new URLSearchParams();
const level = document.getElementById('levelFilter').value;
const search = document.getElementById('searchInput').value;
const limit = document.getElementById('limitInput').value;
if (level) params.set('level', level);
if (search) params.set('search', search);
if (limit) params.set('limit', limit);
const response = await fetch(`/admin/logs/api/read/${logName}?${params}`);
const data = await response.json();
if (data.status === 'success') {
this.renderLogEntries(data.data.entries);
} else {
logContent.innerHTML = `<div class="empty-state">Error: ${data.message}</div>`;
}
} catch (error) {
console.error('Error loading log content:', error);
logContent.innerHTML = '<div class="empty-state">Error loading log content</div>';
}
}
async tailLog(logName) {
try {
const lines = document.getElementById('limitInput').value || 50;
const response = await fetch(`/admin/logs/api/tail/${logName}?lines=${lines}`);
const data = await response.json();
if (data.status === 'success') {
this.renderLogEntries(data.data.entries);
}
} catch (error) {
console.error('Error tailing log:', error);
}
}
async searchAllLogs() {
const search = document.getElementById('searchInput').value;
if (!search) {
alert('Please enter a search query');
return;
}
const logContent = document.getElementById('logContent');
logContent.innerHTML = '<div class="loading">Searching all logs...</div>';
try {
const params = new URLSearchParams();
params.set('query', search);
const level = document.getElementById('levelFilter').value;
if (level) params.set('level', level);
const response = await fetch(`/admin/logs/api/search?${params}`);
const data = await response.json();
if (data.status === 'success') {
this.renderSearchResults(data.data);
}
} catch (error) {
console.error('Error searching logs:', error);
logContent.innerHTML = '<div class="empty-state">Error searching logs</div>';
}
}
toggleStream() {
if (this.isStreaming) {
this.stopStream();
} else {
this.startStream();
}
}
startStream() {
if (!this.currentLog || this.isStreaming) return;
const streamBtn = document.getElementById('streamBtn');
streamBtn.textContent = '⏹️ Stop Stream';
streamBtn.classList.remove('btn-secondary');
streamBtn.classList.add('btn-primary');
this.isStreaming = true;
this.streamEntries = [];
const logContent = document.getElementById('logContent');
logContent.innerHTML = `
<div class="stream-status" style="padding: 1rem; border-radius: 4px; margin-bottom: 1rem; color: #4CAF50;">
<span class="stream-indicator"></span><strong>Live Streaming:</strong> ${this.currentLog}
<span style="float: right;" id="streamStats">0 entries</span>
</div>
<div id="streamContent"></div>
`;
const params = new URLSearchParams();
const level = document.getElementById('levelFilter').value;
const search = document.getElementById('searchInput').value;
const batchSize = 10;
if (level) params.set('level', level);
if (search) params.set('search', search);
params.set('batch_size', batchSize);
const url = `/admin/logs/api/stream/${this.currentLog}?${params}`;
this.streamEventSource = new EventSource(url);
this.streamEventSource.addEventListener('log_batch', (event) => {
try {
const data = JSON.parse(event.data);
if (data.status === 'success') {
this.handleStreamBatch(data);
}
} catch (error) {
console.error('Error parsing stream data:', error);
}
});
this.streamEventSource.addEventListener('stream_complete', (event) => {
console.log('Stream completed');
this.updateStreamStatus('Stream completed');
});
this.streamEventSource.addEventListener('error', (event) => {
console.error('Stream error:', event);
this.stopStream();
this.updateStreamStatus('Stream error occurred');
});
this.streamEventSource.onerror = (error) => {
console.error('EventSource error:', error);
this.stopStream();
};
}
stopStream() {
if (!this.isStreaming) return;
const streamBtn = document.getElementById('streamBtn');
streamBtn.textContent = '📺 Stream Live';
streamBtn.classList.remove('btn-primary');
streamBtn.classList.add('btn-secondary');
this.isStreaming = false;
if (this.streamEventSource) {
this.streamEventSource.close();
this.streamEventSource = null;
}
this.updateStreamStatus('Stream stopped');
}
handleStreamBatch(data) {
const streamContent = document.getElementById('streamContent');
const streamStats = document.getElementById('streamStats');
// Füge neue Einträge hinzu
data.batch.forEach(entry => {
this.streamEntries.push(entry);
});
// Update Statistics
if (streamStats) {
streamStats.textContent = `${this.streamEntries.length} entries (Batch ${data.batch_number})`;
}
// Render neue Einträge
const newEntriesHtml = data.batch.map(entry => {
const levelClass = `level-${entry.level.toLowerCase()}`;
return `
<div class="log-entry ${levelClass}">
<div>
<span class="log-timestamp">${entry.timestamp}</span>
<span class="log-level ${entry.level.toLowerCase()}">${entry.level}</span>
</div>
<div class="log-message">${this.escapeHtml(entry.message)}</div>
${entry.context ? `<div class="log-context">${this.escapeHtml(entry.context)}</div>` : ''}
</div>
`;
}).join('');
if (streamContent) {
streamContent.innerHTML += newEntriesHtml;
// Auto-scroll to bottom
const logContent = document.getElementById('logContent');
logContent.scrollTop = logContent.scrollHeight;
}
// Begrenze die Anzahl der angezeigten Einträge (Performance)
if (this.streamEntries.length > 500) {
this.streamEntries = this.streamEntries.slice(-400); // Keep last 400
this.reRenderStreamEntries();
}
}
reRenderStreamEntries() {
const streamContent = document.getElementById('streamContent');
if (streamContent) {
streamContent.innerHTML = this.streamEntries.map(entry => {
const levelClass = `level-${entry.level.toLowerCase()}`;
return `
<div class="log-entry ${levelClass}">
<div>
<span class="log-timestamp">${entry.timestamp}</span>
<span class="log-level ${entry.level.toLowerCase()}">${entry.level}</span>
</div>
<div class="log-message">${this.escapeHtml(entry.message)}</div>
${entry.context ? `<div class="log-context">${this.escapeHtml(entry.context)}</div>` : ''}
</div>
`;
}).join('');
}
}
updateStreamStatus(message) {
const streamStats = document.getElementById('streamStats');
if (streamStats) {
streamStats.textContent = message;
}
}
renderLogEntries(entries) {
const logContent = document.getElementById('logContent');
if (entries.length === 0) {
logContent.innerHTML = '<div class="empty-state">No log entries found</div>';
return;
}
logContent.innerHTML = entries.map(entry => {
const levelClass = `level-${entry.level.toLowerCase()}`;
return `
<div class="log-entry ${levelClass}">
<div>
<span class="log-timestamp">${entry.timestamp}</span>
<span class="log-level ${entry.level.toLowerCase()}">${entry.level}</span>
</div>
<div class="log-message">${this.escapeHtml(entry.message)}</div>
${entry.context ? `<div class="log-context">${this.escapeHtml(entry.context)}</div>` : ''}
</div>
`;
}).join('');
// Scroll to bottom
logContent.scrollTop = logContent.scrollHeight;
}
renderSearchResults(searchData) {
const logContent = document.getElementById('logContent');
if (searchData.total_matches === 0) {
logContent.innerHTML = '<div class="empty-state">No matches found</div>';
return;
}
let html = `
<div style="padding: 1rem; background: #2a2a2a; border-radius: 4px; margin-bottom: 1rem;">
<strong>Search Results:</strong> ${searchData.total_matches} matches for "${searchData.query}"
</div>
`;
Object.entries(searchData.results).forEach(([logName, logData]) => {
if (logData.entries) {
html += `
<div style="padding: 0.5rem; background: #333; margin-bottom: 1rem; border-radius: 4px;">
<strong>${logName}</strong> (${logData.total_entries} matches)
</div>
`;
logData.entries.forEach(entry => {
const levelClass = `level-${entry.level.toLowerCase()}`;
html += `
<div class="log-entry ${levelClass}">
<div>
<span class="log-timestamp">${entry.timestamp}</span>
<span class="log-level ${entry.level.toLowerCase()}">${entry.level}</span>
</div>
<div class="log-message">${this.escapeHtml(entry.message)}</div>
${entry.context ? `<div class="log-context">${this.escapeHtml(entry.context)}</div>` : ''}
</div>
`;
});
}
});
logContent.innerHTML = html;
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
// Initialize log viewer when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
new LogViewer();
});
</script>
</body>
</html>

View File

@@ -1,11 +1,46 @@
<layout src="admin-main"/>
<div>Routes Page</div>
<div class="admin-content">
<h1>{{ title }}</h1>
<div class="admin-tools">
<input type="text" id="routeFilter" placeholder="Routen filtern..." class="search-input">
</div>
<p>{{ name }}</p>
<table class="admin-table" id="routesTable">
<thead>
<tr>
<th>Pfad</th>
<th>Methode</th>
<th>Controller</th>
<th>Handler</th>
<th>Name</th>
<th>Middleware</th>
</tr>
</thead>
<tbody>
<for var="route" in="routes">
<tr>
<td>{{ route.path }}</td>
<td><span class="method-badge method-{{ route.method | lower }}">{{ route.method }}</span></td>
<td>{{ route.controller }}</td>
<td>{{ route.handler }}</td>
<td>{{ route.name | default('-') }}</td>
<td>{{ route.middleware | join(', ') | default('-') }}</td>
</tr>
</for>
</tbody>
</table>
</div>
<ul>
<for var="route" in="routes">
<li><a href="{{ route.path }}">{{ route.path }} </a> <i> ({{ route.class }}) {{ route.attributes.0 }} </i> </li>
</for>
</ul>
<script>
document.getElementById('routeFilter').addEventListener('input', function() {
const filterValue = this.value.toLowerCase();
const rows = document.querySelectorAll('#routesTable tbody tr');
rows.forEach(row => {
const text = row.textContent.toLowerCase();
row.style.display = text.includes(filterValue) ? '' : 'none';
});
});
</script>

View File

@@ -82,7 +82,7 @@
<div class="admin-section">
<h2>PHP Erweiterungen</h2>
<div class="extensions-list">
<?php foreach($stats['loadedExtensions'] as $extension): ?>
<?php foreach ($stats['loadedExtensions'] as $extension): ?>
<span class="extension-badge"><?= $extension ?></span>
<?php endforeach; ?>
</div>

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $title ?></title>
<title>{{ title }}</title>
<link rel="stylesheet" href="/css/admin.css">
</head>
<body class="admin-page">
@@ -42,18 +42,18 @@
</tr>
</thead>
<tbody>
<?php foreach($env as $key => $value): ?>
<tr>
<td><?= $key ?></td>
<td><?= is_array($value) ? json_encode($value) : $value ?></td>
</tr>
<?php endforeach; ?>
<for var="envVar" in="env">
<tr>
<td>{{ envVar.key }}</td>
<td>{{ envVar.value }}</td>
</tr>
</for>
</tbody>
</table>
</div>
<div class="admin-footer">
<p>&copy; <?= date('Y') ?> Framework Admin</p>
<p>&copy; {{ current_year }} Framework Admin</p>
</div>
<script>

View File

@@ -0,0 +1,250 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Image Manager</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container-fluid">
<h1>Image Manager</h1>
<div class="row">
<!-- Image Slots Section -->
<div class="col-md-6">
<h2>Image Slots</h2>
<div id="image-slots" class="list-group">
<for var="slot" in="slots">
<div class="list-group-item slot-item" data-slot-id="{{ slot.id }}">
<div class="d-flex justify-content-between align-items-center">
<div>
<h5>{{ slot.slotName }}</h5>
<small class="text-muted">ID: {{ slot.id }}</small>
</div>
<div class="slot-image-container" style="width: 100px; height: 100px;">
<div class="border border-dashed d-flex align-items-center justify-content-center h-100"
ondrop="handleDrop(event, '{{ slot.id }}')"
ondragover="handleDragOver(event)"
ondragleave="handleDragLeave(event)">
<span class="text-muted">Drop image here or click to select</span>
</div>
</div>
</div>
</div>
</for>
</div>
</div>
<!-- Available Images Section -->
<div class="col-md-6">
<h2>Available Images</h2>
<!-- Search Bar -->
<div class="mb-3">
<input type="text"
id="image-search"
class="form-control"
placeholder="Search images..."
onkeyup="searchImages()">
</div>
<!-- Images Grid -->
<div id="images-grid" class="row g-2">
<for var="image" in="images">
<div class="col-md-4 image-item"
data-filename="{{ image.originalFilename }}"
data-alt="{{ image.altText }}">
<div class="card">
<img src="/media/images/{{ image.path }}"
alt="{{ image.altText }}"
class="card-img-top"
style="height: 150px; object-fit: cover; cursor: move;"
draggable="true"
ondragstart="handleDragStart(event, '{{ image.ulid }}')"
onclick="selectImage('{{ image.ulid }}')">
<div class="card-body p-2">
<small class="text-truncate d-block">
{{ image.originalFilename }}
</small>
<small class="text-muted">
{{ image.width }}x{{ image.height }} {{ image.fileSize }}KB
</small>
</div>
</div>
</div>
</for>
</div>
</div>
</div>
</div>
<!-- Image Selection Modal -->
<div class="modal fade" id="imageSelectModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Select Image for <span id="modal-slot-name"></span></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="modal-images" class="row g-2">
<!-- Images will be loaded here -->
</div>
</div>
</div>
</div>
</div>
<script>
// Current dragging image
let draggedImageUlid = null;
let selectedSlotId = null;
// Handle drag start
function handleDragStart(event, imageUlid) {
draggedImageUlid = imageUlid;
event.dataTransfer.effectAllowed = 'copy';
}
// Handle drag over
function handleDragOver(event) {
event.preventDefault();
event.currentTarget.classList.add('bg-light');
}
// Handle drag leave
function handleDragLeave(event) {
event.currentTarget.classList.remove('bg-light');
}
// Handle drop
async function handleDrop(event, slotId) {
event.preventDefault();
event.currentTarget.classList.remove('bg-light');
if (draggedImageUlid) {
await assignImageToSlot(slotId, draggedImageUlid);
}
}
// Assign image to slot
async function assignImageToSlot(slotId, imageUlid) {
try {
const response = await fetch(`/api/image-slots/${slotId}/image`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ image_ulid: imageUlid })
});
if (response.ok) {
location.reload(); // Simple reload for now
} else {
alert('Failed to assign image');
}
} catch (error) {
console.error('Error:', error);
alert('Error assigning image');
}
}
// Remove image from slot
async function removeImage(slotId) {
if (!confirm('Remove image from this slot?')) return;
try {
const response = await fetch(`/api/image-slots/${slotId}/image`, {
method: 'DELETE'
});
if (response.ok) {
location.reload();
} else {
alert('Failed to remove image');
}
} catch (error) {
console.error('Error:', error);
alert('Error removing image');
}
}
// Select image (click handler)
function selectImage(imageUlid) {
// Find which slot was clicked if any
const clickedSlot = document.querySelector('.slot-item.selecting');
if (clickedSlot) {
const slotId = clickedSlot.dataset.slotId;
assignImageToSlot(slotId, imageUlid);
clickedSlot.classList.remove('selecting');
}
}
// Search images
function searchImages() {
const searchTerm = document.getElementById('image-search').value.toLowerCase();
const imageItems = document.querySelectorAll('.image-item');
imageItems.forEach(item => {
const filename = item.dataset.filename.toLowerCase();
const alt = item.dataset.alt.toLowerCase();
if (filename.includes(searchTerm) || alt.includes(searchTerm)) {
item.style.display = '';
} else {
item.style.display = 'none';
}
});
}
// Add click handler to slots for selection mode
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.slot-item').forEach(slot => {
const container = slot.querySelector('.slot-image-container');
if (container && !container.querySelector('img')) {
container.style.cursor = 'pointer';
container.addEventListener('click', function() {
// Remove previous selection
document.querySelectorAll('.slot-item').forEach(s => s.classList.remove('selecting'));
// Mark as selecting
slot.classList.add('selecting');
// Highlight available images
document.getElementById('images-grid').classList.add('selecting-mode');
});
}
});
});
// Add some CSS
const style = document.createElement('style');
style.textContent = `
.slot-item.selecting {
border: 2px solid #0d6efd;
background-color: #e7f1ff;
}
.selecting-mode .card {
cursor: pointer;
}
.selecting-mode .card:hover {
border-color: #0d6efd;
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25);
}
.border-dashed {
border-style: dashed !important;
}
.object-fit-cover {
object-fit: cover;
}
`;
document.head.appendChild(style);
</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $title ?></title>
<title>{{ title }}</title>
<link rel="stylesheet" href="/css/admin.css">
</head>
<body class="admin-page">
@@ -25,68 +25,68 @@
<div class="dashboard-stats">
<div class="stat-box">
<h3>Aktueller Speicherverbrauch</h3>
<div class="stat-value"><?= $performance['currentMemoryUsage'] ?></div>
<div class="stat-value">{{ performance.currentMemoryUsage }}</div>
</div>
<div class="stat-box">
<h3>Maximaler Speicherverbrauch</h3>
<div class="stat-value"><?= $performance['peakMemoryUsage'] ?></div>
<div class="stat-value">{{ performance.peakMemoryUsage }}</div>
</div>
<div class="stat-box">
<h3>Speicherlimit</h3>
<div class="stat-value"><?= $performance['memoryLimit'] ?></div>
<div class="stat-value">{{ performance.memoryLimit }}</div>
</div>
<div class="stat-box">
<h3>Speicherauslastung</h3>
<div class="stat-value">
<div class="progress-bar">
<div class="progress" style="width: <?= $performance['memoryUsagePercentage'] ?>%"></div>
<div class="progress" style="width: {{ performance.memoryUsagePercentage }}%"></div>
</div>
<div class="progress-value"><?= $performance['memoryUsagePercentage'] ?>%</div>
<div class="progress-value">{{ performance.memoryUsagePercentage }}%</div>
</div>
</div>
<div class="stat-box">
<h3>Systemlast (1/5/15 min)</h3>
<div class="stat-value">
<?= $performance['loadAverage'][0] ?> /
<?= $performance['loadAverage'][1] ?> /
<?= $performance['loadAverage'][2] ?>
{{ performance.loadAverage.0 }} /
{{ performance.loadAverage.1 }} /
{{ performance.loadAverage.2 }}
</div>
</div>
<div class="stat-box">
<h3>OPCache aktiviert</h3>
<div class="stat-value"><?= $performance['opcacheEnabled'] ?></div>
<div class="stat-value">{{ performance.opcacheEnabled }}</div>
</div>
<?php if (isset($performance['opcacheMemoryUsage'])): ?>
<div class="stat-box">
<h3>OPCache Speicherverbrauch</h3>
<div class="stat-value"><?= $performance['opcacheMemoryUsage'] ?></div>
</div>
<div if="performance.opcacheMemoryUsage">
<div class="stat-box">
<h3>OPCache Speicherverbrauch</h3>
<div class="stat-value">{{ performance.opcacheMemoryUsage }}</div>
</div>
<div class="stat-box">
<h3>OPCache Cache Hits</h3>
<div class="stat-value"><?= $performance['opcacheCacheHits'] ?></div>
</div>
<div class="stat-box">
<h3>OPCache Cache Hits</h3>
<div class="stat-value">{{ performance.opcacheCacheHits }}</div>
</div>
<div class="stat-box">
<h3>OPCache Miss Rate</h3>
<div class="stat-value"><?= $performance['opcacheMissRate'] ?></div>
<div class="stat-box">
<h3>OPCache Miss Rate</h3>
<div class="stat-value">{{ performance.opcacheMissRate }}</div>
</div>
</div>
<?php endif; ?>
<div class="stat-box">
<h3>Ausführungszeit</h3>
<div class="stat-value"><?= $performance['executionTime'] ?></div>
<div class="stat-value">{{ performance.executionTime }}</div>
</div>
<div class="stat-box">
<h3>Geladene Dateien</h3>
<div class="stat-value"><?= $performance['includedFiles'] ?></div>
<div class="stat-value">{{ performance.includedFiles }}</div>
</div>
</div>
@@ -97,17 +97,17 @@
</div>
<div class="file-list" id="fileList">
<?php foreach(get_included_files() as $file): ?>
<div class="file-item">
<?= $file ?>
</div>
<?php endforeach; ?>
<for var="file" in="performance.files">
<div class="file-item">
{{ file }}
</div>
</for>
</div>
</div>
</div>
<div class="admin-footer">
<p>&copy; <?= date('Y') ?> Framework Admin</p>
<p>&copy; {{ current_year }} Framework Admin</p>
</div>
<script>

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $title ?></title>
<title>{{ title }}</title>
<link rel="stylesheet" href="/css/admin.css">
</head>
<body class="admin-page">
@@ -22,72 +22,61 @@
</div>
<div class="admin-content">
<?php if (isset($redis['status']) && $redis['status'] === 'Verbunden'): ?>
<div class="dashboard-stats">
<div class="stat-box">
<h3>Status</h3>
<div class="stat-value status-connected"><?= $redis['status'] ?></div>
</div>
<div class="stat-box">
<h3>Version</h3>
<div class="stat-value"><?= $redis['version'] ?></div>
</div>
<div class="stat-box">
<h3>Uptime</h3>
<div class="stat-value"><?= $redis['uptime'] ?></div>
</div>
<div class="stat-box">
<h3>Speicherverbrauch</h3>
<div class="stat-value"><?= $redis['memory'] ?></div>
</div>
<div class="stat-box">
<h3>Max. Speicherverbrauch</h3>
<div class="stat-value"><?= $redis['peak_memory'] ?></div>
</div>
<div class="stat-box">
<h3>Verbundene Clients</h3>
<div class="stat-value"><?= $redis['clients'] ?></div>
</div>
<div class="stat-box">
<h3>Anzahl Schlüssel</h3>
<div class="stat-value"><?= $redis['keys'] ?></div>
</div>
<div class="dashboard-stats">
<div class="stat-box">
<h3>Status</h3>
<div class="stat-value status-connected">{{ redis.status }}</div>
</div>
<div class="admin-section">
<h2>Schlüssel (max. 50 angezeigt)</h2>
<div class="admin-tools">
<input type="text" id="keyFilter" placeholder="Schlüssel filtern..." class="search-input">
</div>
<div class="stat-box">
<h3>Version</h3>
<div class="stat-value">{{ redis.version }}</div>
</div>
<div class="key-list" id="keyList">
<?php if (empty($redis['key_sample'])): ?>
<div class="empty-message">Keine Schlüssel vorhanden</div>
<?php else: ?>
<?php foreach($redis['key_sample'] as $key): ?>
<div class="key-item">
<?= $key ?>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
<div class="stat-box">
<h3>Uptime</h3>
<div class="stat-value">{{ redis.uptime }}</div>
</div>
<?php else: ?>
<div class="error-message">
<h2>Redis-Verbindung fehlgeschlagen</h2>
<p><?= $redis['status'] ?? 'Unbekannter Fehler' ?></p>
<div class="stat-box">
<h3>Speicherverbrauch</h3>
<div class="stat-value">{{ redis.memory }}</div>
</div>
<?php endif; ?>
<div class="stat-box">
<h3>Max. Speicherverbrauch</h3>
<div class="stat-value">{{ redis.peak_memory }}</div>
</div>
<div class="stat-box">
<h3>Verbundene Clients</h3>
<div class="stat-value">{{ redis.clients }}</div>
</div>
<div class="stat-box">
<h3>Anzahl Schlüssel</h3>
<div class="stat-value">{{ redis.keys }}</div>
</div>
</div>
<div class="admin-section">
<h2>Schlüssel (max. 50 angezeigt)</h2>
<div class="admin-tools">
<input type="text" id="keyFilter" placeholder="Schlüssel filtern..." class="search-input">
</div>
<div class="key-list" id="keyList">
<for var="key" in="redis.key_sample">
<div class="key-item">
{{ key }}
</div>
</for>
</div>
</div>
</div>
<div class="admin-footer">
<p>&copy; <?= date('Y') ?> Framework Admin</p>
<p>&copy; {{ current_year }} Framework Admin</p>
</div>
<script>

View File

@@ -37,7 +37,7 @@
</tr>
</thead>
<tbody>
<?php foreach($routes as $route): ?>
<?php foreach ($routes as $route): ?>
<tr>
<td><?= $route->path ?></td>
<td><?= $route->method ?></td>

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $title ?></title>
<title>{{ title }}</title>
<link rel="stylesheet" href="/css/admin.css">
</head>
<body class="admin-page">
@@ -24,31 +24,26 @@
<div class="admin-content">
<div class="admin-tools">
<input type="text" id="serviceFilter" placeholder="Dienste filtern..." class="search-input">
<span class="services-count"><?= count($services) ?> Dienste insgesamt</span>
<span class="services-count">{{ servicesCount }} Dienste insgesamt</span>
</div>
<div class="service-list" id="serviceList">
<?php foreach($services as $service): ?>
<for var="service" in="services">
<div class="service-item">
<div class="service-name"><?= $service ?></div>
<?php
$parts = explode('\\', $service);
$category = $parts[1] ?? 'Unknown';
$subCategory = $parts[2] ?? '';
?>
<div class="service-name">{{ service.name }}</div>
<div class="service-category">
<span class="category-badge"><?= $category ?></span>
<?php if ($subCategory): ?>
<span class="subcategory-badge"><?= $subCategory ?></span>
<?php endif; ?>
<span class="category-badge">{{ service.category }}</span>
<if condition="service.subCategory">
<span class="subcategory-badge">{{ service.subCategory }}</span>
</if>
</div>
</div>
<?php endforeach; ?>
</for>
</div>
</div>
<div class="admin-footer">
<p>&copy; <?= date('Y') ?> Framework Admin</p>
<p>&copy; {{ date('Y') }} Framework Admin</p>
</div>
<script>
@@ -65,7 +60,7 @@
});
document.querySelector('.services-count').textContent =
visibleCount + ' von ' + <?= count($services) ?> + ' Diensten';
visibleCount + ' von ' + {{ servicesCount }} + ' Diensten';
});
</script>
</body>

View File

@@ -0,0 +1,353 @@
<?php
declare(strict_types=1);
namespace App\Application\Analytics;
use App\Application\Analytics\Service\AnalyticsDashboardService;
use App\Application\Analytics\Service\AnalyticsRealTimeService;
use App\Application\Analytics\Service\AnalyticsReportService;
use App\Framework\Analytics\AnalyticsCategory;
use App\Framework\Analytics\AnalyticsCollector;
use App\Framework\Analytics\Storage\AnalyticsStorage;
use App\Framework\Attributes\Route;
use App\Framework\Http\Method;
use App\Framework\Http\Status;
use App\Framework\Meta\StaticPageMetaResolver;
use App\Framework\Router\Result\JsonResult;
use App\Framework\Router\Result\ViewResult;
final class AnalyticsController
{
public function __construct(
private AnalyticsCollector $analyticsCollector,
private AnalyticsStorage $storage,
private AnalyticsDashboardService $dashboardService,
private AnalyticsReportService $reportService,
private AnalyticsRealTimeService $realTimeService,
) {
}
#[Route(path: '/admin/analytics', method: Method::GET)]
public function dashboard(): ViewResult
{
return new ViewResult(
'analytics-dashboard',
new StaticPageMetaResolver(
'Analytics Dashboard',
'View website analytics and user behavior data'
)(),
[
'title' => 'Analytics Dashboard',
'description' => 'Monitor your website performance and user engagement',
]
);
}
#[Route(path: '/admin/analytics/api/overview', method: Method::GET)]
public function getOverview(): JsonResult
{
try {
$endDate = $_GET['end_date'] ?? date('Y-m-d');
$startDate = $_GET['start_date'] ?? date('Y-m-d', strtotime('-30 days'));
// Calculate days between dates
$days = max(1, (strtotime($endDate) - strtotime($startDate)) / 86400 + 1);
$overview = $this->dashboardService->getOverview($startDate, $endDate);
return new JsonResult([
'success' => true,
'data' => $overview,
'period' => [
'start' => $startDate,
'end' => $endDate,
'days' => (int)$days,
],
]);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => $e->getMessage(),
], Status::from(500));
}
}
#[Route(path: '/admin/analytics/api/timeseries', method: Method::GET)]
public function getTimeSeries(): JsonResult
{
try {
$metric = $_GET['metric'] ?? 'page_views';
$period = $_GET['period'] ?? 'day';
$days = (int) ($_GET['days'] ?? 30);
$endDate = date('Y-m-d');
$startDate = date('Y-m-d', strtotime("-{$days} days"));
$timeSeries = $this->storage->getTimeSeries($metric, $startDate, $endDate, $period);
return new JsonResult([
'success' => true,
'data' => $timeSeries,
'metric' => $metric,
'period' => $period,
'range' => [
'start' => $startDate,
'end' => $endDate,
'days' => $days,
],
]);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => $e->getMessage(),
], Status::from(500));
}
}
#[Route(path: '/admin/analytics/api/top-pages', method: Method::GET)]
public function getTopPages(): JsonResult
{
try {
$limit = (int) ($_GET['limit'] ?? 10);
$days = (int) ($_GET['days'] ?? 30);
$endDate = date('Y-m-d');
$startDate = date('Y-m-d', strtotime("-{$days} days"));
$topPages = $this->dashboardService->getTopPages($startDate, $endDate, $limit);
return new JsonResult([
'success' => true,
'data' => $topPages,
'limit' => $limit,
'period' => [
'start' => $startDate,
'end' => $endDate,
'days' => $days,
],
]);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => $e->getMessage(),
], Status::from(500));
}
}
#[Route(path: '/admin/analytics/api/traffic-sources', method: Method::GET)]
public function getTrafficSources(): JsonResult
{
try {
$days = (int) ($_GET['days'] ?? 30);
$endDate = date('Y-m-d');
$startDate = date('Y-m-d', strtotime("-{$days} days"));
$sources = $this->dashboardService->getTrafficSources($startDate, $endDate);
return new JsonResult([
'success' => true,
'data' => $sources,
'period' => [
'start' => $startDate,
'end' => $endDate,
'days' => $days,
],
]);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => $e->getMessage(),
], Status::from(500));
}
}
#[Route(path: '/admin/analytics/api/user-behavior', method: Method::GET)]
public function getUserBehavior(): JsonResult
{
try {
$days = (int) ($_GET['days'] ?? 30);
$endDate = date('Y-m-d');
$startDate = date('Y-m-d', strtotime("-{$days} days"));
$behavior = $this->reportService->getUserBehavior($startDate, $endDate);
return new JsonResult([
'success' => true,
'data' => $behavior,
'period' => [
'start' => $startDate,
'end' => $endDate,
'days' => $days,
],
]);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => $e->getMessage(),
], Status::from(500));
}
}
#[Route(path: '/admin/analytics/api/business-metrics', method: Method::GET)]
public function getBusinessMetrics(): JsonResult
{
try {
$days = (int) ($_GET['days'] ?? 30);
$endDate = date('Y-m-d');
$startDate = date('Y-m-d', strtotime("-{$days} days"));
$metrics = $this->reportService->getBusinessMetrics($startDate, $endDate);
return new JsonResult([
'success' => true,
'data' => $metrics,
'period' => [
'start' => $startDate,
'end' => $endDate,
'days' => $days,
],
]);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => $e->getMessage(),
], Status::from(500));
}
}
#[Route(path: '/admin/analytics/api/real-time', method: Method::GET)]
public function getRealTimeData(): JsonResult
{
try {
$realTime = $this->realTimeService->getRealTimeData();
return new JsonResult([
'success' => true,
'data' => $realTime,
'timestamp' => time(),
'updated_at' => date('Y-m-d H:i:s'),
]);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => $e->getMessage(),
], Status::from(500));
}
}
#[Route(path: '/admin/analytics/api/events', method: Method::GET)]
public function getEvents(): JsonResult
{
try {
$category = $_GET['category'] ?? null;
$limit = (int) ($_GET['limit'] ?? 100);
$offset = (int) ($_GET['offset'] ?? 0);
$days = (int) ($_GET['days'] ?? 7);
$endDate = date('Y-m-d');
$startDate = date('Y-m-d', strtotime("-{$days} days"));
$categoryFilter = $category ? AnalyticsCategory::tryFrom($category) : null;
$events = $this->reportService->getEvents($startDate, $endDate, $categoryFilter, $limit, $offset);
$total = $this->reportService->getEventsCount($startDate, $endDate, $categoryFilter);
return new JsonResult([
'success' => true,
'data' => $events,
'pagination' => [
'limit' => $limit,
'offset' => $offset,
'total' => $total,
'has_more' => ($offset + $limit) < $total,
],
'filter' => [
'category' => $category,
'start' => $startDate,
'end' => $endDate,
],
]);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => $e->getMessage(),
], Status::from(500));
}
}
#[Route(path: '/admin/analytics/api/export', method: Method::GET)]
public function exportData(): JsonResult
{
try {
$format = $_GET['format'] ?? 'json';
$category = $_GET['category'] ?? null;
$days = (int) ($_GET['days'] ?? 30);
$endDate = date('Y-m-d');
$startDate = date('Y-m-d', strtotime("-{$days} days"));
$categoryFilter = $category ? AnalyticsCategory::tryFrom($category) : null;
$data = $this->reportService->exportData($startDate, $endDate, $categoryFilter, $format);
return new JsonResult([
'success' => true,
'data' => $data,
'format' => $format,
'exported_at' => date('Y-m-d H:i:s'),
'period' => [
'start' => $startDate,
'end' => $endDate,
'days' => $days,
],
]);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => $e->getMessage(),
], Status::from(500));
}
}
#[Route(path: '/admin/analytics/api/track', method: Method::POST)]
public function trackEvent(): JsonResult
{
try {
$action = $_POST['action'] ?? null;
$category = $_POST['category'] ?? 'user_behavior';
$properties = json_decode($_POST['properties'] ?? '{}', true);
if (! $action) {
return new JsonResult([
'success' => false,
'error' => 'Action is required',
], Status::from(400));
}
$categoryEnum = AnalyticsCategory::tryFrom($category);
if (! $categoryEnum) {
return new JsonResult([
'success' => false,
'error' => 'Invalid category',
], Status::from(400));
}
$this->analyticsCollector->trackAction($action, $categoryEnum, $properties);
return new JsonResult([
'success' => true,
'message' => 'Event tracked successfully',
'tracked_at' => date('Y-m-d H:i:s'),
]);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => $e->getMessage(),
], Status::from(500));
}
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Application\Analytics\Contracts;
/**
* Interface for Value Objects that can be converted to legacy array format
*/
interface LegacyArrayConvertible
{
/**
* Convert to legacy array format for backward compatibility
* @return array<string, mixed>
*/
public function toArray(): array;
/**
* Convert to enhanced analytics array format
* @return array<string, mixed>
*/
public function toAnalyticsArray(): array;
}

View File

@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\Application\Analytics\Service;
use App\Framework\Analytics\Storage\AnalyticsStorage;
/**
* Service für Analytics Dashboard-Daten
* Verantwortlich für: Zusammenfassung und Aufbereitung von Dashboard-Metriken
*/
final readonly class AnalyticsDashboardService
{
public function __construct(
private AnalyticsStorage $storage
) {
}
/**
* @return array<string, int|float>
*/
public function getOverview(string $startDate, string $endDate): array
{
$timeSeries = $this->storage->getTimeSeries('page_views_total', $startDate, $endDate, 'hour');
$aggregatedData = $this->storage->getAggregated($startDate, $endDate, 'hour');
$totalPageViews = array_sum(array_column($timeSeries, 'value'));
// Falls keine Daten gefunden, schaue direkt in aggregierten Daten
if ($totalPageViews === 0 && ! empty($aggregatedData)) {
foreach ($aggregatedData as $period => $data) {
if (isset($data['page_views_total'])) {
$totalPageViews += $data['page_views_total'];
}
}
}
// Berechne geschätzte Metriken basierend auf verfügbaren Daten
$estimatedVisitors = (int) ($totalPageViews * 0.7); // 70% unique visitors
$bounceRate = 0.35; // Geschätzte Absprungrate
$conversionRate = 0.02; // Geschätzte Conversion Rate
return [
'total_page_views' => $totalPageViews,
'unique_visitors' => $estimatedVisitors,
'bounce_rate' => $bounceRate,
'avg_session_duration' => 180.5,
'conversion_rate' => $conversionRate,
'error_rate' => 0.01,
];
}
/**
* @return array<string, int>
*/
public function getTopPages(string $startDate, string $endDate, int $limit = 10): array
{
// Hole alle aggregierten Daten und suche nach page_views_* Mustern
$aggregatedData = $this->storage->getAggregated($startDate, $endDate, 'hour');
$pageViews = [];
foreach ($aggregatedData as $period => $data) {
foreach ($data as $key => $value) {
// Suche nach page_views_/path Pattern
if (str_starts_with($key, 'page_views_/')) {
$path = substr($key, 11); // Remove 'page_views_' prefix
if (! isset($pageViews[$path])) {
$pageViews[$path] = 0;
}
$pageViews[$path] += (int)$value;
}
}
}
// Sortiere nach Views
arsort($pageViews);
// Formatiere für Ausgabe
$topPages = [];
foreach (array_slice($pageViews, 0, $limit, true) as $path => $views) {
// Verwende deterministischen Wert basierend auf dem Pfad für konsistente Bounce-Rate
$pathHash = crc32($path);
$consistentBounceRate = 0.3 + (($pathHash % 21) / 100); // 0.30 bis 0.50
$topPages[] = [
'path' => $path,
'views' => $views,
'unique_visitors' => (int)($views * 0.7), // Schätzung
'bounce_rate' => round($consistentBounceRate, 2),
];
}
return $topPages;
}
/**
* @return array<string, int>
*/
public function getTrafficSources(string $startDate, string $endDate): array
{
// Simuliere Traffic-Quellen
return [
'Direct' => 45,
'Google' => 35,
'Social Media' => 12,
'Referral' => 8,
];
}
}

View File

@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace App\Application\Analytics\Service;
use App\Framework\Analytics\Storage\AnalyticsStorage;
/**
* Service für Real-Time Analytics
* Verantwortlich für: Live-Daten, aktuelle Benutzer, Real-Time Events
*/
final readonly class AnalyticsRealTimeService
{
public function __construct(
private AnalyticsStorage $storage
) {
}
/**
* @return array{active_users: int, current_page_views: array<string, mixed>, recent_actions: array<int, mixed>, live_traffic: array<string, mixed>}
*/
public function getRealTimeData(): array
{
return [
'active_users' => $this->getActiveUsers(),
'current_page_views' => $this->getCurrentPageViews(),
'recent_actions' => $this->getRecentActions(50),
'live_traffic' => $this->getLiveTraffic(),
];
}
public function getActiveUsers(): int
{
// Simuliere aktive Benutzer
return rand(15, 45);
}
/**
* @return array<string, int>
*/
public function getCurrentPageViews(): array
{
return [
'/' => 12,
'/products' => 8,
'/about' => 3,
'/contact' => 2,
'/blog' => 5,
];
}
/**
* @return array<int, array{timestamp: string, action: string, page: string, user: string}>
*/
public function getRecentActions(int $limit = 50): array
{
$actions = [];
$now = time();
for ($i = 0; $i < $limit; $i++) {
$actions[] = [
'timestamp' => date('H:i:s', $now - ($i * 30)), // Alle 30 Sekunden
'action' => $this->getRandomAction(),
'page' => $this->getRandomPage(),
'user_id' => 'user_' . rand(1000, 9999),
'country' => $this->getRandomCountry(),
];
}
return $actions;
}
/**
* @return array{visitors_last_minute: int, visitors_last_5_minutes: int, visitors_last_30_minutes: int, peak_concurrent_users: int, current_bounce_rate: float}
*/
public function getLiveTraffic(): array
{
return [
'visitors_last_minute' => rand(5, 15),
'visitors_last_5_minutes' => rand(25, 75),
'visitors_last_30_minutes' => rand(150, 450),
'peak_concurrent_users' => rand(50, 120),
'current_bounce_rate' => round(rand(25, 45) / 100, 2),
];
}
private function getRandomAction(): string
{
$actions = ['page_view', 'button_click', 'form_submit', 'download', 'search'];
return $actions[array_rand($actions)];
}
private function getRandomPage(): string
{
$pages = ['/', '/products', '/about', '/contact', '/blog', '/impressum', '/datenschutz'];
return $pages[array_rand($pages)];
}
private function getRandomCountry(): string
{
$countries = ['Germany', 'Austria', 'Switzerland', 'Netherlands', 'France'];
return $countries[array_rand($countries)];
}
}

View File

@@ -0,0 +1,237 @@
<?php
declare(strict_types=1);
namespace App\Application\Analytics\Service;
use App\Application\Analytics\ValueObject\ActionBreakdown;
use App\Application\Analytics\ValueObject\BrowserBreakdown;
use App\Application\Analytics\ValueObject\BusinessMetricsReport;
use App\Application\Analytics\ValueObject\CountryBreakdown;
use App\Application\Analytics\ValueObject\DeviceBreakdown;
use App\Application\Analytics\ValueObject\UserBehaviorReport;
use App\Framework\Analytics\AnalyticsCategory;
use App\Framework\Analytics\Storage\AnalyticsStorage;
/**
* Service für Analytics-Reports und Datenexport
* Verantwortlich für: Report-Generierung, Datenexport, Filterung
*/
final readonly class AnalyticsReportService
{
public function __construct(
private AnalyticsStorage $storage
) {
}
public function getUserBehavior(string $startDate, string $endDate): UserBehaviorReport
{
$actions = ActionBreakdown::fromArray($this->getTopActions($startDate, $endDate, 20));
$devices = DeviceBreakdown::fromArray($this->getDeviceBreakdown($startDate, $endDate));
$browsers = BrowserBreakdown::fromArray($this->getBrowserBreakdown($startDate, $endDate));
$countries = CountryBreakdown::fromArray($this->getCountryBreakdown($startDate, $endDate));
return new UserBehaviorReport($actions, $devices, $browsers, $countries);
}
public function getBusinessMetrics(string $startDate, string $endDate): BusinessMetricsReport
{
return new BusinessMetricsReport(
conversions: $this->getConversions($startDate, $endDate),
revenue: $this->getRevenue($startDate, $endDate),
goalCompletions: $this->getGoalCompletions($startDate, $endDate),
funnelData: $this->getFunnelData($startDate, $endDate)
);
}
// Backward compatibility methods (deprecated)
/**
* @deprecated Use getUserBehavior() which returns UserBehaviorReport instead
* @return array<string, array<int|string, mixed>>
*/
public function getUserBehaviorArray(string $startDate, string $endDate): array
{
return $this->getUserBehavior($startDate, $endDate)->toArray();
}
/**
* @deprecated Use getBusinessMetrics() which returns BusinessMetricsReport instead
* @return array<string, array<int|string, mixed>>
*/
public function getBusinessMetricsArray(string $startDate, string $endDate): array
{
return $this->getBusinessMetrics($startDate, $endDate)->toArray();
}
/**
* @return array<int, array<string, mixed>>
*/
public function getEvents(string $startDate, string $endDate, ?AnalyticsCategory $category = null, int $limit = 100, int $offset = 0): array
{
// Simuliere Events basierend auf verfügbaren Daten
$events = [];
$totalEvents = 50; // Simulation
for ($i = $offset; $i < min($offset + $limit, $totalEvents); $i++) {
$events[] = [
'id' => $i + 1,
'timestamp' => date('Y-m-d H:i:s', strtotime($startDate) + ($i * 3600)),
'action' => 'page_view',
'category' => $category?->value ?? 'page_views',
'properties' => [
'path' => '/',
'user_agent' => 'Mozilla/5.0...',
],
];
}
return $events;
}
public function getEventsCount(string $startDate, string $endDate, ?AnalyticsCategory $category = null): int
{
// Simuliere Event-Count
return 150;
}
/**
* @return array<string, mixed>
*/
public function exportData(string $startDate, string $endDate, ?AnalyticsCategory $category = null, string $format = 'json'): array
{
$data = [
'overview' => $this->storage->getAggregated($startDate, $endDate, 'day'),
'timeseries' => $this->storage->getTimeSeries('page_views', $startDate, $endDate, 'day'),
'top_pages' => $this->storage->getTopList('page_views', $startDate, $endDate, 20),
];
if ($format === 'csv') {
// Convert to CSV format
return $this->convertToCsv($data);
}
return $data;
}
/**
* @return array<int, array{action: string, count: int}>
*/
private function getTopActions(string $startDate, string $endDate, int $limit): array
{
return [
['action' => 'page_view', 'count' => 1250],
['action' => 'button_click', 'count' => 340],
['action' => 'form_submit', 'count' => 89],
['action' => 'download', 'count' => 45],
];
}
/**
* @return array<string, int>
*/
private function getDeviceBreakdown(string $startDate, string $endDate): array
{
return [
'Desktop' => 60,
'Mobile' => 35,
'Tablet' => 5,
];
}
/**
* @return array<string, int>
*/
private function getBrowserBreakdown(string $startDate, string $endDate): array
{
return [
'Chrome' => 65,
'Firefox' => 20,
'Safari' => 10,
'Edge' => 5,
];
}
/**
* @return array<string, int>
*/
private function getCountryBreakdown(string $startDate, string $endDate): array
{
return [
'Germany' => 70,
'Austria' => 15,
'Switzerland' => 10,
'Others' => 5,
];
}
/**
* @return array<string, int>
*/
private function getConversions(string $startDate, string $endDate): array
{
return [
'signup' => 45,
'purchase' => 12,
'download' => 89,
'contact' => 23,
];
}
/**
* @return array<string, string|int|float>
*/
private function getRevenue(string $startDate, string $endDate): array
{
return [
'total' => 2345.67,
'currency' => 'EUR',
'transactions' => 12,
'average_order_value' => 195.47,
];
}
/**
* @return array<string, int>
*/
private function getGoalCompletions(string $startDate, string $endDate): array
{
return [
'newsletter_signup' => 234,
'contact_form' => 45,
'product_view' => 1234,
'checkout_start' => 67,
];
}
/**
* @return array<string, mixed>
*/
private function getFunnelData(string $startDate, string $endDate): array
{
return [
'steps' => [
['name' => 'Landing Page', 'visitors' => 1000, 'conversion' => 1.0],
['name' => 'Product View', 'visitors' => 650, 'conversion' => 0.65],
['name' => 'Add to Cart', 'visitors' => 130, 'conversion' => 0.20],
['name' => 'Checkout', 'visitors' => 65, 'conversion' => 0.50],
['name' => 'Purchase', 'visitors' => 32, 'conversion' => 0.49],
],
'overall_conversion' => 0.032,
];
}
/**
* @param array<string, mixed> $data
* @return array<string, string>
*/
private function convertToCsv(array $data): array
{
// Vereinfachte CSV-Konvertierung
return [
'format' => 'csv',
'data' => 'Date,Page Views,Unique Visitors\n' .
implode('\n', array_map(fn ($date, $views) => "$date,$views,0", array_keys($data['timeseries'] ?? []), array_values($data['timeseries'] ?? []))),
];
}
}

View File

@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\Application\Analytics\ValueObject;
use App\Framework\Core\ValueObjects\Percentage;
/**
* User action breakdown for analytics reporting
*/
final readonly class ActionBreakdown
{
/**
* @param array<int, array{action: string, count: int}> $actions
*/
public function __construct(
public array $actions
) {
foreach ($actions as $action) {
if ($action['count'] < 0) {
throw new \InvalidArgumentException('Action counts cannot be negative');
}
}
}
/**
* @param array<int, array{action: string, count: int}> $actions
*/
public static function fromArray(array $actions): self
{
return new self($actions);
}
public function getTotal(): int
{
return array_sum(array_column($this->actions, 'count'));
}
/**
* @return array{action: string, count: int}|null
*/
public function getTopAction(): ?array
{
if (empty($this->actions)) {
return null;
}
$sorted = $this->actions;
usort($sorted, fn ($a, $b) => $b['count'] <=> $a['count']);
return $sorted[0];
}
/**
* @param int $limit
* @return array<int, array{action: string, count: int, percentage: string}>
*/
public function getTopActions(int $limit = 10): array
{
$sorted = $this->actions;
usort($sorted, fn ($a, $b) => $b['count'] <=> $a['count']);
$total = $this->getTotal();
$topActions = array_slice($sorted, 0, $limit);
return array_map(function ($action) use ($total) {
return [
'action' => $action['action'],
'count' => $action['count'],
'percentage' => Percentage::fromRatio($action['count'], $total)->format(),
];
}, $topActions);
}
public function getActionCount(string $actionName): int
{
foreach ($this->actions as $action) {
if ($action['action'] === $actionName) {
return $action['count'];
}
}
return 0;
}
public function getActionPercentage(string $actionName): Percentage
{
$count = $this->getActionCount($actionName);
return Percentage::fromRatio($count, $this->getTotal());
}
/**
* @return array<int, array{action: string, count: int}>
*/
public function toArray(): array
{
return $this->actions;
}
/**
* Legacy format for backward compatibility
* @return array<int, array{action: string, count: int}>
*/
public function toAnalyticsArray(): array
{
return $this->actions;
}
}

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Application\Analytics\ValueObject;
use App\Framework\Core\ValueObjects\Percentage;
/**
* Browser usage breakdown for analytics reporting
*/
final readonly class BrowserBreakdown
{
public function __construct(
public int $chrome,
public int $firefox,
public int $safari,
public int $edge,
) {
if ($chrome < 0 || $firefox < 0 || $safari < 0 || $edge < 0) {
throw new \InvalidArgumentException('Browser counts cannot be negative');
}
}
/**
* Create from legacy array format: ['Chrome' => 65, 'Firefox' => 20, ...]
* @param array<string, int> $data
*/
public static function fromArray(array $data): self
{
return new self(
chrome: $data['Chrome'] ?? 0,
firefox: $data['Firefox'] ?? 0,
safari: $data['Safari'] ?? 0,
edge: $data['Edge'] ?? 0,
);
}
public function getTotal(): int
{
return $this->chrome + $this->firefox + $this->safari + $this->edge;
}
public function getChromePercentage(): Percentage
{
return Percentage::fromRatio($this->chrome, $this->getTotal());
}
public function getFirefoxPercentage(): Percentage
{
return Percentage::fromRatio($this->firefox, $this->getTotal());
}
public function getSafariPercentage(): Percentage
{
return Percentage::fromRatio($this->safari, $this->getTotal());
}
public function getEdgePercentage(): Percentage
{
return Percentage::fromRatio($this->edge, $this->getTotal());
}
/**
* @return array<string, int>
*/
public function toArray(): array
{
return [
'Chrome' => $this->chrome,
'Firefox' => $this->firefox,
'Safari' => $this->safari,
'Edge' => $this->edge,
];
}
/**
* Legacy format for backward compatibility
* @return array<string, mixed>
*/
public function toAnalyticsArray(): array
{
return [
'Chrome' => $this->chrome,
'Firefox' => $this->firefox,
'Safari' => $this->safari,
'Edge' => $this->edge,
'chrome_percentage' => $this->getChromePercentage()->format(),
'firefox_percentage' => $this->getFirefoxPercentage()->format(),
'safari_percentage' => $this->getSafariPercentage()->format(),
'edge_percentage' => $this->getEdgePercentage()->format(),
'total' => $this->getTotal(),
];
}
}

View File

@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
namespace App\Application\Analytics\ValueObject;
/**
* Business metrics analytics report containing conversion and revenue data
*/
final readonly class BusinessMetricsReport
{
/**
* @param array<string, int> $conversions
* @param array<string, string|int|float> $revenue
* @param array<string, int> $goalCompletions
* @param array<string, mixed> $funnelData
*/
public function __construct(
public array $conversions,
public array $revenue,
public array $goalCompletions,
public array $funnelData,
) {
// Validate conversions - only check business logic
foreach ($conversions as $count) {
if ($count < 0) {
throw new \InvalidArgumentException('Conversion counts cannot be negative');
}
}
// Validate goal completions - only check business logic
foreach ($goalCompletions as $completions) {
if ($completions < 0) {
throw new \InvalidArgumentException('Goal completion counts cannot be negative');
}
}
// Validate required revenue fields
$requiredRevenueFields = ['total', 'currency', 'transactions', 'average_order_value'];
foreach ($requiredRevenueFields as $field) {
if (! array_key_exists($field, $revenue)) {
throw new \InvalidArgumentException("Revenue must contain field: {$field}");
}
}
}
public function getTotalConversions(): int
{
return array_sum($this->conversions);
}
public function getTotalRevenue(): float
{
return (float) $this->revenue['total'];
}
public function getCurrency(): string
{
return (string) $this->revenue['currency'];
}
public function getTransactionCount(): int
{
return (int) $this->revenue['transactions'];
}
public function getAverageOrderValue(): float
{
return (float) $this->revenue['average_order_value'];
}
public function getTotalGoalCompletions(): int
{
return array_sum($this->goalCompletions);
}
public function getConversionRate(): float
{
if (! isset($this->funnelData['overall_conversion'])) {
return 0.0;
}
return (float) $this->funnelData['overall_conversion'];
}
public function getTopConversionType(): ?string
{
if (empty($this->conversions)) {
return null;
}
return array_key_first(
array_slice($this->conversions, 0, 1, true)
);
}
public function getTopGoal(): ?string
{
if (empty($this->goalCompletions)) {
return null;
}
$sortedGoals = $this->goalCompletions;
arsort($sortedGoals);
return array_key_first($sortedGoals);
}
/**
* Calculate revenue per conversion
*/
public function getRevenuePerConversion(): float
{
$totalConversions = $this->getTotalConversions();
if ($totalConversions === 0) {
return 0.0;
}
return $this->getTotalRevenue() / $totalConversions;
}
/**
* Get business insights and KPIs
* @return array<string, mixed>
*/
public function getInsights(): array
{
return [
'total_revenue' => $this->getTotalRevenue(),
'currency' => $this->getCurrency(),
'total_conversions' => $this->getTotalConversions(),
'total_transactions' => $this->getTransactionCount(),
'average_order_value' => $this->getAverageOrderValue(),
'revenue_per_conversion' => $this->getRevenuePerConversion(),
'overall_conversion_rate' => $this->getConversionRate() * 100, // as percentage
'top_conversion_type' => $this->getTopConversionType(),
'top_goal' => $this->getTopGoal(),
'is_profitable' => $this->getTotalRevenue() > 0,
'has_transactions' => $this->getTransactionCount() > 0,
];
}
/**
* Legacy array format for backward compatibility
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'conversions' => $this->conversions,
'revenue' => $this->revenue,
'goals' => $this->goalCompletions,
'funnel' => $this->funnelData,
];
}
/**
* Enhanced analytics array with insights
* @return array<string, mixed>
*/
public function toAnalyticsArray(): array
{
return [
'conversions' => $this->conversions,
'revenue' => $this->revenue,
'goals' => $this->goalCompletions,
'funnel' => $this->funnelData,
'insights' => $this->getInsights(),
];
}
}

View File

@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App\Application\Analytics\ValueObject;
use App\Framework\Core\ValueObjects\Percentage;
/**
* Country usage breakdown for analytics reporting
*/
final readonly class CountryBreakdown
{
public function __construct(
public int $germany,
public int $austria,
public int $switzerland,
public int $others,
) {
if ($germany < 0 || $austria < 0 || $switzerland < 0 || $others < 0) {
throw new \InvalidArgumentException('Country counts cannot be negative');
}
}
/**
* Create from legacy array format: ['Germany' => 70, 'Austria' => 15, ...]
* @param array<string, int> $data
*/
public static function fromArray(array $data): self
{
return new self(
germany: $data['Germany'] ?? 0,
austria: $data['Austria'] ?? 0,
switzerland: $data['Switzerland'] ?? 0,
others: $data['Others'] ?? 0,
);
}
public function getTotal(): int
{
return $this->germany + $this->austria + $this->switzerland + $this->others;
}
public function getGermanyPercentage(): Percentage
{
return Percentage::fromRatio($this->germany, $this->getTotal());
}
public function getAustriaPercentage(): Percentage
{
return Percentage::fromRatio($this->austria, $this->getTotal());
}
public function getSwitzerlandPercentage(): Percentage
{
return Percentage::fromRatio($this->switzerland, $this->getTotal());
}
public function getOthersPercentage(): Percentage
{
return Percentage::fromRatio($this->others, $this->getTotal());
}
public function getDachRegionTotal(): int
{
return $this->germany + $this->austria + $this->switzerland;
}
public function getDachRegionPercentage(): Percentage
{
return Percentage::fromRatio($this->getDachRegionTotal(), $this->getTotal());
}
/**
* @return array<string, int>
*/
public function toArray(): array
{
return [
'Germany' => $this->germany,
'Austria' => $this->austria,
'Switzerland' => $this->switzerland,
'Others' => $this->others,
];
}
/**
* Legacy format for backward compatibility
* @return array<string, mixed>
*/
public function toAnalyticsArray(): array
{
return [
'Germany' => $this->germany,
'Austria' => $this->austria,
'Switzerland' => $this->switzerland,
'Others' => $this->others,
'germany_percentage' => $this->getGermanyPercentage()->format(),
'austria_percentage' => $this->getAustriaPercentage()->format(),
'switzerland_percentage' => $this->getSwitzerlandPercentage()->format(),
'others_percentage' => $this->getOthersPercentage()->format(),
'dach_total' => $this->getDachRegionTotal(),
'dach_percentage' => $this->getDachRegionPercentage()->format(),
'total' => $this->getTotal(),
];
}
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Application\Analytics\ValueObject;
use App\Framework\Core\ValueObjects\Percentage;
/**
* Device usage breakdown for analytics reporting
*/
final readonly class DeviceBreakdown
{
public function __construct(
public int $desktop,
public int $mobile,
public int $tablet,
) {
if ($desktop < 0 || $mobile < 0 || $tablet < 0) {
throw new \InvalidArgumentException('Device counts cannot be negative');
}
}
/**
* Create from legacy array format: ['Desktop' => 60, 'Mobile' => 35, 'Tablet' => 5]
* @param array<string, int> $data
*/
public static function fromArray(array $data): self
{
return new self(
desktop: $data['Desktop'] ?? 0,
mobile: $data['Mobile'] ?? 0,
tablet: $data['Tablet'] ?? 0,
);
}
public function getTotal(): int
{
return $this->desktop + $this->mobile + $this->tablet;
}
public function getDesktopPercentage(): Percentage
{
return Percentage::fromRatio($this->desktop, $this->getTotal());
}
public function getMobilePercentage(): Percentage
{
return Percentage::fromRatio($this->mobile, $this->getTotal());
}
public function getTabletPercentage(): Percentage
{
return Percentage::fromRatio($this->tablet, $this->getTotal());
}
/**
* @return array<string, int>
*/
public function toArray(): array
{
return [
'Desktop' => $this->desktop,
'Mobile' => $this->mobile,
'Tablet' => $this->tablet,
];
}
/**
* @return array<string, mixed>
*/
public function toAnalyticsArray(): array
{
return [
'Desktop' => $this->desktop,
'Mobile' => $this->mobile,
'Tablet' => $this->tablet,
'desktop_percentage' => $this->getDesktopPercentage()->format(),
'mobile_percentage' => $this->getMobilePercentage()->format(),
'tablet_percentage' => $this->getTabletPercentage()->format(),
'total' => $this->getTotal(),
];
}
}

View File

@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace App\Application\Analytics\ValueObject;
/**
* User behavior analytics report containing all user interaction breakdowns
*/
final readonly class UserBehaviorReport
{
public function __construct(
public ActionBreakdown $actions,
public DeviceBreakdown $devices,
public BrowserBreakdown $browsers,
public CountryBreakdown $countries,
) {
}
public function getTotalInteractions(): int
{
return $this->actions->getTotal();
}
public function getTotalUniqueUsers(): int
{
// In a real implementation, this would be calculated differently
// For now, we'll estimate based on device usage
return $this->devices->getTotal();
}
/**
* @return array{action: string, count: int}|null
*/
public function getMostPopularAction(): ?array
{
return $this->actions->getTopAction();
}
public function getDominantDevice(): string
{
$devices = $this->devices;
$max = max($devices->desktop, $devices->mobile, $devices->tablet);
return match ($max) {
$devices->desktop => 'Desktop',
$devices->mobile => 'Mobile',
$devices->tablet => 'Tablet',
default => 'Unknown'
};
}
public function getDominantBrowser(): string
{
$browsers = $this->browsers;
$max = max($browsers->chrome, $browsers->firefox, $browsers->safari, $browsers->edge);
return match ($max) {
$browsers->chrome => 'Chrome',
$browsers->firefox => 'Firefox',
$browsers->safari => 'Safari',
$browsers->edge => 'Edge',
default => 'Unknown'
};
}
public function isDachRegionDominant(): bool
{
return $this->countries->getDachRegionPercentage()->getValue() > 80.0;
}
/**
* Get insights about user behavior patterns
* @return array<string, mixed>
*/
public function getInsights(): array
{
return [
'total_interactions' => $this->getTotalInteractions(),
'estimated_unique_users' => $this->getTotalUniqueUsers(),
'most_popular_action' => $this->getMostPopularAction(),
'dominant_device' => $this->getDominantDevice(),
'dominant_browser' => $this->getDominantBrowser(),
'dach_region_focus' => $this->isDachRegionDominant(),
'mobile_first_audience' => $this->devices->getMobilePercentage()->getValue() > 50.0,
'chrome_dominance' => $this->browsers->getChromePercentage()->getValue() > 60.0,
];
}
/**
* Legacy array format for backward compatibility
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'actions' => $this->actions->toArray(),
'devices' => $this->devices->toArray(),
'browsers' => $this->browsers->toArray(),
'countries' => $this->countries->toArray(),
];
}
/**
* Enhanced analytics array with percentages and insights
* @return array<string, mixed>
*/
public function toAnalyticsArray(): array
{
return [
'actions' => $this->actions->toAnalyticsArray(),
'devices' => $this->devices->toAnalyticsArray(),
'browsers' => $this->browsers->toAnalyticsArray(),
'countries' => $this->countries->toAnalyticsArray(),
'insights' => $this->getInsights(),
];
}
}

View File

@@ -0,0 +1,472 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Analytics Dashboard</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f7fa;
color: #2d3748;
}
.header {
background: white;
padding: 1rem 2rem;
border-bottom: 1px solid #e2e8f0;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.header h1 {
font-size: 1.5rem;
font-weight: 600;
color: #1a202c;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: white;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
border: 1px solid #e2e8f0;
}
.stat-card h3 {
font-size: 0.875rem;
font-weight: 500;
color: #718096;
text-transform: uppercase;
letter-spacing: 0.025em;
margin-bottom: 0.5rem;
}
.stat-value {
font-size: 2rem;
font-weight: 700;
color: #1a202c;
margin-bottom: 0.25rem;
}
.stat-change {
font-size: 0.875rem;
font-weight: 500;
}
.stat-change.positive {
color: #38a169;
}
.stat-change.negative {
color: #e53e3e;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.metrics-card {
background: white;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
border: 1px solid #e2e8f0;
}
.metrics-title {
font-size: 1.125rem;
font-weight: 600;
color: #1a202c;
margin-bottom: 1rem;
}
.metric-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 0;
border-bottom: 1px solid #f1f5f9;
}
.metric-item:last-child {
border-bottom: none;
}
.metric-label {
font-weight: 500;
color: #4a5568;
}
.metric-value {
font-weight: 600;
color: #1a202c;
}
.table-container {
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
border: 1px solid #e2e8f0;
}
.table-header {
background: #f7fafc;
padding: 1rem 1.5rem;
border-bottom: 1px solid #e2e8f0;
}
.table-title {
font-size: 1.125rem;
font-weight: 600;
color: #1a202c;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 0.75rem 1.5rem;
text-align: left;
border-bottom: 1px solid #e2e8f0;
}
th {
background: #f7fafc;
font-weight: 600;
color: #4a5568;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.loading {
text-align: center;
padding: 2rem;
color: #718096;
}
.error {
background: #fed7d7;
color: #c53030;
padding: 1rem;
border-radius: 4px;
margin: 1rem 0;
}
.period-selector {
margin-bottom: 1.5rem;
}
.period-buttons {
display: flex;
gap: 0.5rem;
}
.period-btn {
padding: 0.5rem 1rem;
background: white;
border: 1px solid #e2e8f0;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s;
}
.period-btn:hover {
background: #f7fafc;
}
.period-btn.active {
background: #3182ce;
color: white;
border-color: #3182ce;
}
@media (max-width: 768px) {
.metrics-grid {
grid-template-columns: 1fr;
}
.container {
padding: 1rem;
}
.header {
padding: 1rem;
}
}
</style>
</head>
<body>
<div class="header">
<h1>📊 Analytics Dashboard</h1>
</div>
<div class="container">
<div class="period-selector">
<div class="period-buttons">
<button class="period-btn active" data-days="7">7 Tage</button>
<button class="period-btn" data-days="30">30 Tage</button>
<button class="period-btn" data-days="90">90 Tage</button>
</div>
</div>
<div class="stats-grid">
<div class="stat-card">
<h3>Seitenaufrufe</h3>
<div class="stat-value" id="pageViews">-</div>
<div class="stat-change positive" id="pageViewsChange">+0%</div>
</div>
<div class="stat-card">
<h3>Eindeutige Besucher</h3>
<div class="stat-value" id="uniqueVisitors">-</div>
<div class="stat-change positive" id="visitorsChange">+0%</div>
</div>
<div class="stat-card">
<h3>Absprungrate</h3>
<div class="stat-value" id="bounceRate">-</div>
<div class="stat-change negative" id="bounceChange">0%</div>
</div>
<div class="stat-card">
<h3>Conversion Rate</h3>
<div class="stat-value" id="conversionRate">-</div>
<div class="stat-change positive" id="conversionChange">+0%</div>
</div>
</div>
<div class="metrics-grid">
<div class="metrics-card">
<div class="metrics-title">Traffic-Quellen</div>
<div id="trafficSourcesList">
<div class="loading">Lade Daten...</div>
</div>
</div>
<div class="metrics-card">
<div class="metrics-title">Performance-Metriken</div>
<div class="metric-item">
<span class="metric-label">Durchschnittliche Sitzungsdauer</span>
<span class="metric-value" id="avgSessionDuration">-</span>
</div>
<div class="metric-item">
<span class="metric-label">Fehlerrate</span>
<span class="metric-value" id="errorRate">-</span>
</div>
<div class="metric-item">
<span class="metric-label">Conversion Rate</span>
<span class="metric-value" id="performanceConversionRate">-</span>
</div>
</div>
</div>
<div class="table-container">
<div class="table-header">
<div class="table-title">Top-Seiten</div>
</div>
<table>
<thead>
<tr>
<th>Seite</th>
<th>Aufrufe</th>
<th>Eindeutige Besucher</th>
<th>Absprungrate</th>
</tr>
</thead>
<tbody id="topPagesTable">
<tr>
<td colspan="4" class="loading">Lade Daten...</td>
</tr>
</tbody>
</table>
</div>
</div>
<script>
class AnalyticsDashboard {
constructor() {
this.currentPeriod = 30;
this.init();
}
async init() {
this.setupEventListeners();
await this.loadData();
}
setupEventListeners() {
document.querySelectorAll('.period-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
document.querySelectorAll('.period-btn').forEach(b => b.classList.remove('active'));
e.target.classList.add('active');
this.currentPeriod = parseInt(e.target.dataset.days);
this.loadData();
});
});
}
async loadData() {
try {
await Promise.all([
this.loadOverview(),
this.loadTopPages(),
this.loadTrafficSources()
]);
} catch (error) {
console.error('Error loading data:', error);
this.showError('Fehler beim Laden der Analytics-Daten');
}
}
async loadOverview() {
try {
const response = await fetch(`/admin/analytics/api/overview?days=${this.currentPeriod}`);
const result = await response.json();
if (result.success) {
this.updateOverviewStats(result.data);
}
} catch (error) {
console.error('Error loading overview:', error);
}
}
updateOverviewStats(data) {
document.getElementById('pageViews').textContent = this.formatNumber(data.total_page_views || 0);
document.getElementById('uniqueVisitors').textContent = this.formatNumber(data.unique_visitors || 0);
document.getElementById('bounceRate').textContent = this.formatPercent(data.bounce_rate || 0);
document.getElementById('conversionRate').textContent = this.formatPercent(data.conversion_rate || 0);
// Update performance metrics
document.getElementById('avgSessionDuration').textContent = this.formatDuration(data.avg_session_duration || 0);
document.getElementById('errorRate').textContent = this.formatPercent(data.error_rate || 0);
document.getElementById('performanceConversionRate').textContent = this.formatPercent(data.conversion_rate || 0);
}
async loadTrafficSources() {
try {
const response = await fetch(`/admin/analytics/api/traffic-sources?days=${this.currentPeriod}`);
const result = await response.json();
if (result.success) {
this.updateTrafficSources(result.data);
}
} catch (error) {
console.error('Error loading traffic sources:', error);
}
}
updateTrafficSources(data) {
const container = document.getElementById('trafficSourcesList');
if (!data || Object.keys(data).length === 0) {
container.innerHTML = '<div class="loading">Keine Traffic-Daten verfügbar</div>';
return;
}
const total = Object.values(data).reduce((sum, val) => sum + val, 0);
container.innerHTML = Object.entries(data).map(([source, count]) => {
const percentage = total > 0 ? ((count / total) * 100).toFixed(1) : 0;
return `
<div class="metric-item">
<span class="metric-label">${source}</span>
<span class="metric-value">${count} (${percentage}%)</span>
</div>
`;
}).join('');
}
async loadTopPages() {
try {
const response = await fetch(`/admin/analytics/api/top-pages?days=${this.currentPeriod}&limit=10`);
const result = await response.json();
if (result.success) {
this.updateTopPagesTable(result.data);
}
} catch (error) {
console.error('Error loading top pages:', error);
}
}
updateTopPagesTable(data) {
const tbody = document.getElementById('topPagesTable');
if (!data || data.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="loading">Keine Daten verfügbar</td></tr>';
return;
}
tbody.innerHTML = data.map(page => `
<tr>
<td>${page.path || '-'}</td>
<td>${this.formatNumber(page.views || 0)}</td>
<td>${this.formatNumber(page.unique_visitors || 0)}</td>
<td>${this.formatPercent(page.bounce_rate || 0)}</td>
</tr>
`).join('');
}
formatNumber(num) {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K';
}
return num.toLocaleString();
}
formatPercent(num) {
return (num * 100).toFixed(1) + '%';
}
formatDuration(seconds) {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
}
showError(message) {
const container = document.querySelector('.container');
const errorDiv = document.createElement('div');
errorDiv.className = 'error';
errorDiv.textContent = message;
container.insertBefore(errorDiv, container.firstChild);
}
}
// Initialize dashboard when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
new AnalyticsDashboard();
});
</script>
</body>
</html>

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Application\Api;
use App\Framework\Attributes\Route;
use App\Framework\Http\Method;
use App\Framework\Http\Status;
use App\Framework\Meta\StaticPageMetaResolver;
use App\Framework\OpenApi\OpenApiContact;
use App\Framework\OpenApi\OpenApiGenerator;
use App\Framework\OpenApi\OpenApiInfo;
use App\Framework\OpenApi\OpenApiLicense;
use App\Framework\Router\Result\JsonResult;
use App\Framework\Router\Result\ViewResult;
/**
* Controller for serving API documentation
*/
final readonly class ApiDocsController
{
public function __construct(
private OpenApiGenerator $generator,
) {
}
#[Route(path: '/api/docs', method: Method::GET, name: 'api_docs')]
public function showDocs(): ViewResult
{
return new ViewResult(
'api/docs',
new StaticPageMetaResolver(
'API Documentation',
'Interactive API documentation with Swagger UI'
)(),
[
'title' => 'API Documentation',
]
);
}
#[Route(path: '/api/docs/selfhosted', method: Method::GET, name: 'api_docs_selfhosted')]
public function showDocsSelhosted(): ViewResult
{
return new ViewResult(
'api/docs-selfhosted',
new StaticPageMetaResolver(
'API Documentation (Self-hosted)',
'Self-hosted API documentation with zero external dependencies'
)(),
[
'title' => 'API Documentation (Self-hosted)',
]
);
}
#[Route(path: '/api/openapi.json', method: Method::GET, name: 'api_openapi_spec')]
public function getOpenApiSpec(): JsonResult
{
$info = new OpenApiInfo(
title: 'Michael Schiemer API',
version: '1.0.0',
description: 'API documentation for Michael Schiemer\'s custom PHP framework',
contact: new OpenApiContact(
name: 'Michael Schiemer',
email: 'contact@michaelschiemer.dev',
),
license: new OpenApiLicense(
name: 'MIT',
url: 'https://opensource.org/licenses/MIT',
),
);
$protocol = (! empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$servers = [
[
'url' => "{$protocol}://{$host}",
'description' => 'Current server',
],
];
$spec = $this->generator->generate($info, $servers);
return new JsonResult($spec->toArray(), Status::OK);
}
}

View File

@@ -0,0 +1,216 @@
<?php
declare(strict_types=1);
namespace App\Application\Api;
use App\Framework\Attributes\Route;
use App\Framework\Http\Method;
use App\Framework\Http\Status;
use App\Framework\OpenApi\Attributes\ApiEndpoint;
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;
/**
* Example API controller demonstrating OpenAPI documentation
*/
#[ApiSecurity('bearerAuth')]
final readonly class ExampleApiController
{
#[Route(path: '/api/users', method: Method::GET, name: 'api_users_list')]
#[ApiEndpoint(
summary: 'List all users',
description: 'Retrieve a paginated list of all users in the system',
tags: ['Users'],
)]
#[ApiParameter(
name: 'page',
in: 'query',
description: 'Page number for pagination',
required: false,
type: 'integer',
example: 1,
)]
#[ApiParameter(
name: 'limit',
in: 'query',
description: 'Number of items per page',
required: false,
type: 'integer',
example: 20,
)]
#[ApiParameter(
name: 'search',
in: 'query',
description: 'Search term to filter users',
required: false,
type: 'string',
example: 'john',
)]
#[ApiResponse(
statusCode: 200,
description: 'List of users retrieved successfully',
example: [
'data' => [
[
'id' => 1,
'name' => 'John Doe',
'email' => 'john@example.com',
'created_at' => '2024-01-01T00:00:00Z',
],
],
'pagination' => [
'current_page' => 1,
'total_pages' => 5,
'total_items' => 100,
],
],
)]
#[ApiResponse(
statusCode: 401,
description: 'Unauthorized - Invalid or missing authentication token',
)]
public function listUsers(): JsonResult
{
// Mock data for demonstration
return new JsonResult([
'data' => [
[
'id' => 1,
'name' => 'John Doe',
'email' => 'john@example.com',
'created_at' => '2024-01-01T00:00:00Z',
],
[
'id' => 2,
'name' => 'Jane Smith',
'email' => 'jane@example.com',
'created_at' => '2024-01-02T00:00:00Z',
],
],
'pagination' => [
'current_page' => 1,
'total_pages' => 1,
'total_items' => 2,
],
]);
}
#[Route(path: '/api/users/{id}', method: Method::GET, name: 'api_users_show')]
#[ApiEndpoint(
summary: 'Get user by ID',
description: 'Retrieve detailed information about a specific user',
tags: ['Users'],
)]
#[ApiParameter(
name: 'id',
in: 'path',
description: 'User ID',
required: true,
type: 'integer',
example: 1,
)]
#[ApiResponse(
statusCode: 200,
description: 'User details retrieved successfully',
example: [
'id' => 1,
'name' => 'John Doe',
'email' => 'john@example.com',
'bio' => 'Software developer',
'created_at' => '2024-01-01T00:00:00Z',
'updated_at' => '2024-01-01T00:00:00Z',
],
)]
#[ApiResponse(
statusCode: 404,
description: 'User not found',
)]
public function showUser(int $id): JsonResult
{
if ($id === 1) {
return new JsonResult([
'id' => 1,
'name' => 'John Doe',
'email' => 'john@example.com',
'bio' => 'Software developer',
'created_at' => '2024-01-01T00:00:00Z',
'updated_at' => '2024-01-01T00:00:00Z',
]);
}
return new JsonResult(['error' => 'User not found'], Status::NOT_FOUND);
}
#[Route(path: '/api/users', method: Method::POST, name: 'api_users_create')]
#[ApiEndpoint(
summary: 'Create a new user',
description: 'Create a new user account in the system',
tags: ['Users'],
)]
#[ApiRequestBody(
description: 'User data for creation',
required: true,
example: [
'name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'secure_password',
],
)]
#[ApiResponse(
statusCode: 201,
description: 'User created successfully',
example: [
'id' => 3,
'name' => 'John Doe',
'email' => 'john@example.com',
'created_at' => '2024-01-01T00:00:00Z',
],
)]
#[ApiResponse(
statusCode: 400,
description: 'Validation error - Invalid input data',
)]
#[ApiResponse(
statusCode: 409,
description: 'Conflict - Email already exists',
)]
public function createUser(): JsonResult
{
// Mock creation for demonstration
return new JsonResult([
'id' => 3,
'name' => 'New User',
'email' => 'new@example.com',
'created_at' => date('c'),
], Status::CREATED);
}
#[Route(path: '/api/health', method: Method::GET, name: 'api_health')]
#[ApiEndpoint(
summary: 'Health check',
description: 'Check the health status of the API',
tags: ['System'],
)]
#[ApiResponse(
statusCode: 200,
description: 'API is healthy',
example: [
'status' => 'healthy',
'timestamp' => '2024-01-01T00:00:00Z',
'version' => '1.0.0',
],
)]
#[ApiSecurity('apiKey')]
public function healthCheck(): JsonResult
{
return new JsonResult([
'status' => 'healthy',
'timestamp' => date('c'),
'version' => '1.0.0',
]);
}
}

View File

@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace App\Application\Api\Images;
use App\Domain\Media\ImageRepository;
use App\Framework\Attributes\Route;
use App\Framework\Http\Exception\NotFound;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\Method;
use App\Framework\Http\Responses\JsonResponse;
final readonly class ImageApiController
{
public function __construct(
private ImageRepository $imageRepository
) {
}
#[Route(path: '/api/images', method: Method::GET)]
public function getImages(HttpRequest $request): JsonResponse
{
$limit = (int) ($request->queryParams['limit'] ?? 50);
$offset = (int) ($request->queryParams['offset'] ?? 0);
$search = $request->queryParams['search'] ?? null;
$images = $this->imageRepository->findAll($limit, $offset, $search);
$total = $this->imageRepository->count($search);
return new JsonResponse([
'images' => array_map(fn ($image) => [
'ulid' => $image->ulid,
'filename' => $image->filename,
'original_filename' => $image->originalFilename,
'url' => '/media/images/' . $image->path,
'thumbnail_url' => '/media/images/thumbnails/' . $image->path,
'alt_text' => $image->altText,
'width' => $image->width,
'height' => $image->height,
'mime_type' => $image->mimeType,
'file_size' => $image->fileSize,
], $images),
'pagination' => [
'total' => $total,
'limit' => $limit,
'offset' => $offset,
'has_more' => ($offset + $limit) < $total,
],
]);
}
#[Route(path: '/api/images/{ulid}', method: Method::GET)]
public function getImage(string $ulid): JsonResponse
{
$image = $this->imageRepository->findByUlid($ulid);
if (! $image) {
throw new NotFound("Image with ULID {$ulid} not found");
}
return new JsonResponse([
'ulid' => $image->ulid,
'filename' => $image->filename,
'original_filename' => $image->originalFilename,
'url' => '/media/images/' . $image->path,
'alt_text' => $image->altText,
'width' => $image->width,
'height' => $image->height,
'mime_type' => $image->mimeType,
'file_size' => $image->fileSize,
'hash' => $image->hash,
'variants' => array_map(fn ($variant) => [
'type' => $variant->type,
'width' => $variant->width,
'height' => $variant->height,
'path' => $variant->path,
'url' => '/media/images/' . $variant->path,
], $image->variants ?? []),
]);
}
#[Route(path: '/api/images/{ulid}', method: Method::PUT)]
public function updateImage(string $ulid, HttpRequest $request): JsonResponse
{
$image = $this->imageRepository->findByUlid($ulid);
if (! $image) {
throw new NotFound("Image with ULID {$ulid} not found");
}
$data = $request->parsedBody->toArray();
// Update alt text if provided
if (isset($data['alt_text'])) {
$this->imageRepository->updateAltText($ulid, $data['alt_text']);
}
// Update filename if provided
if (isset($data['filename'])) {
$this->imageRepository->updateFilename($ulid, $data['filename']);
}
return new JsonResponse([
'success' => true,
'message' => 'Image updated successfully',
]);
}
#[Route(path: '/api/images/search', method: Method::GET)]
public function searchImages(HttpRequest $request): JsonResponse
{
$query = $request->queryParams['q'] ?? '';
$type = $request->queryParams['type'] ?? null; // jpeg, png, webp
$minWidth = (int) ($request->queryParams['min_width'] ?? 0);
$minHeight = (int) ($request->queryParams['min_height'] ?? 0);
$images = $this->imageRepository->search($query, $type, $minWidth, $minHeight);
return new JsonResponse([
'results' => array_map(fn ($image) => [
'ulid' => $image->ulid,
'filename' => $image->filename,
'url' => '/media/images/' . $image->path,
'thumbnail_url' => '/media/images/thumbnails/' . $image->path,
'alt_text' => $image->altText,
'width' => $image->width,
'height' => $image->height,
'mime_type' => $image->mimeType,
], $images),
'count' => count($images),
]);
}
}

View File

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace App\Application\Api\Images;
use App\Domain\Media\ImageRepository;
use App\Domain\Media\ImageSlotRepository;
use App\Framework\Attributes\Route;
use App\Framework\Http\Exception\NotFound;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\Method;
use App\Framework\Http\Responses\JsonResponse;
final readonly class ImageSlotController
{
public function __construct(
private ImageSlotRepository $slotRepository,
private ImageRepository $imageRepository
) {
}
#[Route(path: '/api/image-slots', method: Method::GET)]
public function getSlots(): JsonResponse
{
$slots = $this->slotRepository->findAllWithImages();
return new JsonResponse([
'slots' => array_map(fn ($slot) => [
'id' => $slot->id,
'slot_name' => $slot->slotName,
'image' => $slot->image ? [
'ulid' => $slot->image->ulid,
'filename' => $slot->image->filename,
'url' => '/media/images/' . $slot->image->path,
'alt_text' => $slot->image->altText,
'width' => $slot->image->width,
'height' => $slot->image->height,
'mime_type' => $slot->image->mimeType,
] : null,
], $slots),
]);
}
#[Route(path: '/api/image-slots/{id}', method: Method::GET)]
public function getSlot(int $id): JsonResponse
{
try {
$slot = $this->slotRepository->findByIdWithImage($id);
} catch (\RuntimeException $e) {
throw new NotFound($e->getMessage());
}
return new JsonResponse([
'id' => $slot->id,
'slot_name' => $slot->slotName,
'image' => $slot->image ? [
'ulid' => $slot->image->ulid,
'filename' => $slot->image->filename,
'url' => '/media/images/' . $slot->image->path,
'alt_text' => $slot->image->altText,
'width' => $slot->image->width,
'height' => $slot->image->height,
'mime_type' => $slot->image->mimeType,
] : null,
]);
}
#[Route(path: '/api/image-slots/{id}/image', method: Method::PUT)]
public function assignImage(int $id, HttpRequest $request): JsonResponse
{
try {
$slot = $this->slotRepository->findById($id);
} catch (\RuntimeException $e) {
throw new NotFound($e->getMessage());
}
$data = $request->parsedBody->toArray();
$imageUlid = $data['image_ulid'] ?? null;
if (! $imageUlid) {
return new JsonResponse(['error' => 'image_ulid is required'], 400);
}
$image = $this->imageRepository->findByUlid($imageUlid);
if (! $image) {
throw new NotFound("Image with ULID {$imageUlid} not found");
}
// Update slot with new image
$this->slotRepository->updateImageId($id, $imageUlid);
return new JsonResponse([
'success' => true,
'slot' => [
'id' => $slot->id,
'slot_name' => $slot->slotName,
'image_ulid' => $imageUlid,
],
]);
}
#[Route(path: '/api/image-slots/{id}/image', method: Method::DELETE)]
public function removeImage(int $id): JsonResponse
{
try {
$slot = $this->slotRepository->findById($id);
} catch (\RuntimeException $e) {
throw new NotFound($e->getMessage());
}
// Remove image from slot
$this->slotRepository->updateImageId($id, '');
return new JsonResponse([
'success' => true,
'message' => 'Image removed from slot',
]);
}
}

View File

@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace App\Application\Api;
use App\Framework\Attributes\Route;
use App\Framework\Http\Method;
use App\Framework\Http\Status;
use App\Framework\Markdown\MarkdownRenderer;
use App\Framework\Meta\StaticPageMetaResolver;
use App\Framework\OpenApi\MarkdownGenerator;
use App\Framework\OpenApi\OpenApiContact;
use App\Framework\OpenApi\OpenApiGenerator;
use App\Framework\OpenApi\OpenApiInfo;
use App\Framework\OpenApi\OpenApiLicense;
use App\Framework\Router\Result\JsonResult;
use App\Framework\Router\Result\ViewResult;
/**
* Controller for serving Markdown-rendered API documentation
*/
final readonly class MarkdownDocsController
{
public function __construct(
private OpenApiGenerator $generator,
private MarkdownGenerator $markdownGenerator,
private MarkdownRenderer $markdownRenderer,
) {
}
#[Route(path: '/api/docs/markdown', method: Method::GET, name: 'api_docs_markdown')]
public function showMarkdownDocs(): ViewResult
{
try {
$info = new OpenApiInfo(
title: 'Michael Schiemer API',
version: '1.0.0',
description: 'API documentation for Michael Schiemer\'s custom PHP framework',
contact: new OpenApiContact(
name: 'Michael Schiemer',
email: 'contact@michaelschiemer.dev',
),
license: new OpenApiLicense(
name: 'MIT',
url: 'https://opensource.org/licenses/MIT',
),
);
$spec = $this->generator->generate($info);
$markdown = $this->markdownGenerator->generate($spec);
$html = $this->markdownRenderer->render($markdown, 'api', [
'title' => 'API Documentation',
'syntaxHighlighting' => true,
]);
return new ViewResult(
'markdown',
new StaticPageMetaResolver(
'API Documentation',
'Interactive API documentation rendered from Markdown'
)(),
[
'content' => $html,
'debug' => [
'markdown_length' => strlen($markdown),
'html_length' => strlen($html),
'spec_paths_count' => count($spec->paths),
],
]
);
} catch (\Exception $e) {
return new ViewResult(
'markdown',
new StaticPageMetaResolver(
'Error',
'Error generating documentation'
)(),
[
'content' => '<h1>Error</h1><p>' . htmlspecialchars($e->getMessage()) . '</p><pre>' . htmlspecialchars($e->getTraceAsString()) . '</pre>',
]
);
}
}
#[Route(path: '/api/docs/markdown/{theme}', method: Method::GET, name: 'api_docs_markdown_themed')]
public function showMarkdownDocsWithTheme(string $theme): ViewResult
{
$validThemes = ['default', 'github', 'docs', 'api'];
if (! in_array($theme, $validThemes)) {
$theme = 'default';
}
$info = new OpenApiInfo(
title: 'Michael Schiemer API',
version: '1.0.0',
description: 'API documentation for Michael Schiemer\'s custom PHP framework',
);
$spec = $this->generator->generate($info);
$markdown = $this->markdownGenerator->generate($spec);
$html = $this->markdownRenderer->render($markdown, $theme, [
'title' => "API Documentation ({$theme} theme)",
'syntaxHighlighting' => true,
]);
return new ViewResult(
'markdown',
new StaticPageMetaResolver(
"API Documentation ({$theme} theme)",
"API documentation with {$theme} styling"
)(),
[
'content' => $html,
]
);
}
#[Route(path: '/api/docs/test', method: Method::GET, name: 'api_docs_test')]
public function testMarkdown(): ViewResult
{
$testMarkdown = "# Test\n\nThis is a **test** of the markdown renderer.\n\n- Item 1\n- Item 2\n\n```php\necho 'Hello World';\n```";
$html = $this->markdownRenderer->render($testMarkdown, 'default', [
'title' => 'Test Page',
]);
return new ViewResult(
'markdown',
new StaticPageMetaResolver(
'Test',
'Test page'
)(),
[
'content' => $html,
'test_markdown' => $testMarkdown,
]
);
}
#[Route(path: '/api/docs/themes', method: Method::GET, name: 'api_docs_themes')]
public function listThemes(): JsonResult
{
return new JsonResult([
'available_themes' => [
'default' => '/api/docs/markdown/default',
'github' => '/api/docs/markdown/github',
'docs' => '/api/docs/markdown/docs',
'api' => '/api/docs/markdown/api',
],
'current_endpoints' => [
'swagger_ui' => '/api/docs',
'selfhosted' => '/api/docs/selfhosted',
'markdown' => '/api/docs/markdown',
'test' => '/api/docs/test',
'json' => '/api/openapi.json',
],
], Status::OK);
}
}

View File

@@ -0,0 +1,217 @@
<?php
declare(strict_types=1);
namespace App\Application\Api;
use App\Framework\Attributes\Route;
use App\Framework\Http\HttpResponse;
use App\Framework\Http\Method;
use App\Framework\Meta\StaticPageMetaResolver;
use App\Framework\Router\Result\ViewResult;
/**
* Simple controller to test Markdown rendering without complex dependencies
*/
final readonly class SimpleMarkdownController
{
#[Route(path: '/api/docs/simple', method: Method::GET, name: 'api_docs_simple')]
public function simpleTest(): ViewResult
{
return new ViewResult(
'simple-test',
new StaticPageMetaResolver(
'Simple Test',
'Testing the template system'
)(),
[
'title' => 'Simple Test',
'heading' => 'Template System Test',
'message' => 'This tests if the template system works correctly with variables.',
'currentTime' => date('Y-m-d H:i:s'),
]
);
}
#[Route(path: '/api/docs/converter-test', method: Method::GET, name: 'api_docs_converter_test')]
public function converterTest(): ViewResult
{
try {
$converter = new \App\Framework\Markdown\MarkdownConverter();
$markdown = "# Test\n\nThis is **bold** and *italic*.\n\n- Item 1\n- Item 2";
$html = $converter->toHtml($markdown);
return new ViewResult(
'simple-test',
new StaticPageMetaResolver(
'Converter Test',
'Test the markdown converter'
)(),
[
'title' => 'Converter Test',
'heading' => 'Markdown Converter Test',
'message' => 'Original: ' . $markdown . ' | Converted: ' . $html,
'currentTime' => date('Y-m-d H:i:s'),
]
);
} catch (\Exception $e) {
return new ViewResult(
'simple-test',
new StaticPageMetaResolver(
'Error',
'Error testing converter'
)(),
[
'title' => 'Error',
'heading' => 'Error',
'message' => $e->getMessage(),
'currentTime' => date('Y-m-d H:i:s'),
]
);
}
}
#[Route(path: '/api/docs/markdown-simple', method: Method::GET, name: 'api_docs_markdown_simple')]
public function markdownSimple(): ViewResult
{
try {
// Teste nur den Converter erst
$converter = new \App\Framework\Markdown\MarkdownConverter();
$testMarkdown = "# API Documentation\n\nThis is a **test**.";
$html = $converter->toHtml($testMarkdown);
return new ViewResult(
'simple-test',
new StaticPageMetaResolver(
'Markdown Debug',
'Debug markdown step by step'
)(),
[
'title' => 'Markdown Debug',
'heading' => 'Step 1: Converter only',
'message' => 'Markdown: ' . $testMarkdown . ' | HTML: ' . $html,
'currentTime' => date('Y-m-d H:i:s'),
]
);
} catch (\Exception $e) {
return new ViewResult(
'simple-test',
new StaticPageMetaResolver(
'Error',
'Error in markdown rendering'
)(),
[
'title' => 'Error',
'heading' => 'Markdown Rendering Error',
'message' => $e->getMessage() . ' | Trace: ' . $e->getTraceAsString(),
'currentTime' => date('Y-m-d H:i:s'),
]
);
}
}
#[Route(path: '/api/docs/markdown-renderer', method: Method::GET, name: 'api_docs_markdown_renderer')]
public function markdownRenderer(): ViewResult
{
try {
// Teste den Renderer
$converter = new \App\Framework\Markdown\MarkdownConverter();
$renderer = new \App\Framework\Markdown\MarkdownRenderer($converter);
$testMarkdown = "# API Documentation\n\nThis is a **test**.";
$html = $renderer->render($testMarkdown, 'api', [
'title' => 'Test',
'syntaxHighlighting' => false,
]);
return new ViewResult(
'simple-test',
new StaticPageMetaResolver(
'Renderer Debug',
'Debug renderer step by step'
)(),
[
'title' => 'Renderer Debug',
'heading' => 'Step 2: Renderer test',
'message' => 'HTML length: ' . strlen($html) . ' | First 200 chars: ' . substr($html, 0, 200),
'currentTime' => date('Y-m-d H:i:s'),
]
);
} catch (\Exception $e) {
return new ViewResult(
'simple-test',
new StaticPageMetaResolver(
'Error',
'Error in renderer'
)(),
[
'title' => 'Error',
'heading' => 'Renderer Error',
'message' => $e->getMessage() . ' | File: ' . $e->getFile() . ' | Line: ' . $e->getLine(),
'currentTime' => date('Y-m-d H:i:s'),
]
);
}
}
#[Route(path: '/api/docs/markdown-full', method: Method::GET, name: 'api_docs_markdown_full')]
public function markdownFull(): ViewResult
{
try {
// Teste mit HttpResponse statt ViewResult
$converter = new \App\Framework\Markdown\MarkdownConverter();
$renderer = new \App\Framework\Markdown\MarkdownRenderer($converter);
$testMarkdown = "# API Documentation\n\n## Test Endpoint\n\n**GET** `/api/test`\n\nThis is a test endpoint.\n\n### Parameters\n\n- `id` (required) - The item ID\n- `format` (optional) - Response format";
$html = $renderer->render($testMarkdown, 'api', [
'title' => 'Full API Documentation',
'syntaxHighlighting' => false,
]);
return new ViewResult(
'markdown-test',
new StaticPageMetaResolver(
'Full API Documentation',
'Test complete markdown rendering'
)(),
[
'content' => $html,
]
);
} catch (\Exception $e) {
return new ViewResult(
'simple-test',
new StaticPageMetaResolver(
'Error',
'Error in full markdown'
)(),
[
'title' => 'Error',
'heading' => 'Full Markdown Error',
'message' => $e->getMessage() . ' | File: ' . $e->getFile() . ' | Line: ' . $e->getLine(),
'currentTime' => date('Y-m-d H:i:s'),
]
);
}
}
#[Route(path: '/api/docs/viewresult-debug', method: Method::GET, name: 'api_docs_viewresult_debug')]
public function viewResultDebug(): ViewResult
{
// Teste das ViewResult ohne Markdown
return new ViewResult(
'simple-test',
new StaticPageMetaResolver(
'ViewResult Debug',
'Test ViewResult without markdown'
)(),
[
'title' => 'ViewResult Debug',
'heading' => 'Testing ViewResult',
'message' => 'This should work if ViewResult is OK',
'currentTime' => date('Y-m-d H:i:s'),
]
);
}
}

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Application\Api\V1;
use App\Framework\Attributes\ApiVersionAttribute;
use App\Framework\Attributes\Route;
use App\Framework\Http\Headers;
use App\Framework\Http\HttpResponse;
use App\Framework\Http\Method;
use App\Framework\Http\Request;
use App\Framework\Http\Status;
use App\Framework\Serialization\JsonSerializer;
use App\Framework\Serialization\JsonSerializerConfig;
/**
* Users API Controller - Version 1.0
*/
#[ApiVersionAttribute('1.0.0', introducedIn: '1.0.0')]
final readonly class UsersController
{
public function __construct(
private JsonSerializer $jsonSerializer
) {
}
#[Route(path: '/api/v1/users', method: Method::GET)]
public function index(Request $request): HttpResponse
{
$users = [
['id' => 1, 'name' => 'John Doe', 'email' => 'john@example.com'],
['id' => 2, 'name' => 'Jane Smith', 'email' => 'jane@example.com'],
];
return new HttpResponse(
status: Status::OK,
headers: new Headers(['Content-Type' => 'application/json']),
body: $this->jsonSerializer->serialize($users, JsonSerializerConfig::pretty())
);
}
#[Route(path: '/api/v1/users/{id}', method: Method::GET)]
public function show(Request $request): HttpResponse
{
$userId = (int) ($request->queryParams['id'] ?? 1);
$user = [
'id' => $userId,
'name' => "User {$userId}",
'email' => "user{$userId}@example.com",
'created_at' => '2024-01-01T00:00:00Z',
];
return new HttpResponse(
status: Status::OK,
headers: new Headers(['Content-Type' => 'application/json']),
body: $this->jsonSerializer->serialize($user, JsonSerializerConfig::pretty())
);
}
#[Route(path: '/api/v1/users', method: Method::POST)]
public function create(Request $request): HttpResponse
{
$data = $this->jsonSerializer->deserialize($request->body);
if (! is_array($data) || empty($data['name']) || empty($data['email'])) {
return new HttpResponse(
status: Status::BAD_REQUEST,
headers: new Headers(['Content-Type' => 'application/json']),
body: $this->jsonSerializer->serialize([
'error' => 'Name and email are required',
])
);
}
$user = [
'id' => random_int(1000, 9999),
'name' => $data['name'],
'email' => $data['email'],
'created_at' => date('Y-m-d\TH:i:s\Z'),
];
return new HttpResponse(
status: Status::CREATED,
headers: new Headers(['Content-Type' => 'application/json']),
body: $this->jsonSerializer->serialize($user, JsonSerializerConfig::pretty())
);
}
/**
* This endpoint was deprecated in v1.5.0 and will be removed in v2.0.0
*/
#[Route(path: '/api/v1/users/{id}/profile', method: Method::GET)]
#[ApiVersionAttribute('1.0.0', introducedIn: '1.0.0', deprecatedIn: '1.5.0', removedIn: '2.0.0')]
public function getProfile(Request $request): HttpResponse
{
$userId = (int) ($request->queryParams['id'] ?? 1);
// This is the old profile format - deprecated
$profile = [
'user_id' => $userId,
'bio' => "This is the bio for user {$userId}",
'website' => "https://user{$userId}.example.com",
];
return new HttpResponse(
status: Status::OK,
headers: new Headers([
'Content-Type' => 'application/json',
'Warning' => '299 - "This endpoint is deprecated and will be removed in v2.0.0"',
]),
body: $this->jsonSerializer->serialize($profile, JsonSerializerConfig::pretty())
);
}
}

View File

@@ -0,0 +1,224 @@
<?php
declare(strict_types=1);
namespace App\Application\Api\V2;
use App\Framework\Attributes\ApiVersionAttribute;
use App\Framework\Attributes\Route;
use App\Framework\Http\Headers;
use App\Framework\Http\HttpResponse;
use App\Framework\Http\Method;
use App\Framework\Http\Request;
use App\Framework\Http\Status;
use App\Framework\Serialization\JsonSerializer;
use App\Framework\Serialization\JsonSerializerConfig;
/**
* Users API Controller - Version 2.0
*/
#[ApiVersionAttribute('2.0.0', introducedIn: '2.0.0')]
final readonly class UsersController
{
public function __construct(
private JsonSerializer $jsonSerializer
) {
}
#[Route(path: '/api/v2/users', method: Method::GET)]
public function index(Request $request): HttpResponse
{
// V2 includes additional metadata and pagination
$users = [
[
'id' => 1,
'name' => 'John Doe',
'email' => 'john@example.com',
'profile' => [
'bio' => 'Software Developer',
'avatar_url' => 'https://example.com/avatars/1.jpg',
'verified' => true,
],
'created_at' => '2024-01-01T00:00:00Z',
'updated_at' => '2024-01-15T10:30:00Z',
],
[
'id' => 2,
'name' => 'Jane Smith',
'email' => 'jane@example.com',
'profile' => [
'bio' => 'Product Manager',
'avatar_url' => 'https://example.com/avatars/2.jpg',
'verified' => false,
],
'created_at' => '2024-01-02T00:00:00Z',
'updated_at' => '2024-01-16T11:45:00Z',
],
];
$response = [
'data' => $users,
'pagination' => [
'page' => 1,
'per_page' => 10,
'total' => 2,
'total_pages' => 1,
],
'meta' => [
'api_version' => '2.0.0',
'response_time_ms' => random_int(50, 200),
],
];
return new HttpResponse(
status: Status::OK,
headers: new Headers(['Content-Type' => 'application/json']),
body: $this->jsonSerializer->serialize($response, JsonSerializerConfig::pretty())
);
}
#[Route(path: '/api/v2/users/{id}', method: Method::GET)]
public function show(Request $request): HttpResponse
{
$userId = (int) ($request->queryParams['id'] ?? 1);
// V2 has richer user data structure
$user = [
'id' => $userId,
'name' => "User {$userId}",
'email' => "user{$userId}@example.com",
'profile' => [
'bio' => "This is the bio for user {$userId}",
'avatar_url' => "https://example.com/avatars/{$userId}.jpg",
'website' => "https://user{$userId}.example.com",
'location' => 'San Francisco, CA',
'verified' => $userId % 2 === 0,
],
'settings' => [
'notifications' => true,
'privacy_level' => 'public',
'theme' => 'dark',
],
'created_at' => '2024-01-01T00:00:00Z',
'updated_at' => '2024-01-15T10:30:00Z',
];
$response = [
'data' => $user,
'meta' => [
'api_version' => '2.0.0',
'response_time_ms' => random_int(30, 150),
],
];
return new HttpResponse(
status: Status::OK,
headers: new Headers(['Content-Type' => 'application/json']),
body: $this->jsonSerializer->serialize($response, JsonSerializerConfig::pretty())
);
}
#[Route(path: '/api/v2/users', method: Method::POST)]
public function create(Request $request): HttpResponse
{
$data = $this->jsonSerializer->deserialize($request->body);
if (! is_array($data) || empty($data['name']) || empty($data['email'])) {
return new HttpResponse(
status: Status::BAD_REQUEST,
headers: new Headers(['Content-Type' => 'application/json']),
body: $this->jsonSerializer->serialize([
'error' => 'Validation failed',
'details' => [
'name' => 'Name is required',
'email' => 'Email is required',
],
'meta' => [
'api_version' => '2.0.0',
],
])
);
}
// V2 creates users with richer default data
$user = [
'id' => random_int(1000, 9999),
'name' => $data['name'],
'email' => $data['email'],
'profile' => [
'bio' => $data['bio'] ?? null,
'avatar_url' => null,
'website' => $data['website'] ?? null,
'location' => $data['location'] ?? null,
'verified' => false,
],
'settings' => [
'notifications' => true,
'privacy_level' => 'public',
'theme' => 'light',
],
'created_at' => date('Y-m-d\TH:i:s\Z'),
'updated_at' => date('Y-m-d\TH:i:s\Z'),
];
$response = [
'data' => $user,
'meta' => [
'api_version' => '2.0.0',
'response_time_ms' => random_int(80, 250),
],
];
return new HttpResponse(
status: Status::CREATED,
headers: new Headers(['Content-Type' => 'application/json']),
body: $this->jsonSerializer->serialize($response, JsonSerializerConfig::pretty())
);
}
/**
* New endpoint in V2 - replaces the deprecated profile endpoint
*/
#[Route(path: '/api/v2/users/{id}/profile', method: Method::GET)]
#[ApiVersionAttribute('2.0.0', introducedIn: '2.0.0')]
public function getProfile(Request $request): HttpResponse
{
$userId = (int) ($request->queryParams['id'] ?? 1);
// New profile format in V2
$profile = [
'id' => $userId,
'bio' => "This is the comprehensive bio for user {$userId}",
'avatar_url' => "https://example.com/avatars/{$userId}.jpg",
'website' => "https://user{$userId}.example.com",
'location' => 'San Francisco, CA',
'social_links' => [
'twitter' => "@user{$userId}",
'github' => "github.com/user{$userId}",
'linkedin' => "linkedin.com/in/user{$userId}",
],
'stats' => [
'followers' => random_int(100, 10000),
'following' => random_int(50, 1000),
'posts' => random_int(10, 500),
],
'verified' => $userId % 2 === 0,
'badges' => ['early_adopter', 'contributor'],
'updated_at' => '2024-01-15T10:30:00Z',
];
$response = [
'data' => $profile,
'meta' => [
'api_version' => '2.0.0',
'response_time_ms' => random_int(40, 180),
],
];
return new HttpResponse(
status: Status::OK,
headers: new Headers(['Content-Type' => 'application/json']),
body: $this->jsonSerializer->serialize($response, JsonSerializerConfig::pretty())
);
}
}

View File

@@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
namespace App\Application\Api;
use App\Framework\Attributes\Route;
use App\Framework\Http\Headers;
use App\Framework\Http\HttpResponse;
use App\Framework\Http\Method;
use App\Framework\Http\Request;
use App\Framework\Http\Status;
use App\Framework\Http\Versioning\ApiVersion;
use App\Framework\Http\Versioning\VersioningConfig;
use App\Framework\Serialization\JsonSerializer;
use App\Framework\Serialization\JsonSerializerConfig;
/**
* API Version information controller
*/
final readonly class VersionController
{
public function __construct(
private VersioningConfig $versioningConfig,
private JsonSerializer $jsonSerializer
) {
}
#[Route(path: '/api/version', method: Method::GET)]
public function getCurrentVersion(Request $request): HttpResponse
{
// Get version from request state (set by ApiVersioningMiddleware)
$currentVersion = $this->extractVersionFromRequest($request);
$versionInfo = [
'current_version' => $currentVersion->toString(),
'current_version_numeric' => $currentVersion->toNumericString(),
'latest_version' => $this->versioningConfig->getLatestVersion()->toString(),
'default_version' => $this->versioningConfig->defaultVersion->toString(),
'is_latest' => $currentVersion->equals($this->versioningConfig->getLatestVersion()),
'is_deprecated' => $this->isVersionDeprecated($currentVersion),
'supported_strategies' => array_map(
fn ($strategy) => [
'name' => $strategy->value,
'description' => $strategy->getDescription(),
'header_name' => $strategy->getDefaultHeaderName(),
],
$this->versioningConfig->strategies
),
];
return new HttpResponse(
status: Status::OK,
headers: new Headers(['Content-Type' => 'application/json']),
body: $this->jsonSerializer->serialize($versionInfo, JsonSerializerConfig::pretty())
);
}
#[Route(path: '/api/versions', method: Method::GET)]
public function getSupportedVersions(Request $request): HttpResponse
{
$versions = [];
foreach ($this->versioningConfig->supportedVersions as $version) {
$versions[] = [
'version' => $version->toString(),
'version_numeric' => $version->toNumericString(),
'is_default' => $version->equals($this->versioningConfig->defaultVersion),
'is_latest' => $version->equals($this->versioningConfig->getLatestVersion()),
'is_deprecated' => $this->isVersionDeprecated($version),
'major' => $version->major,
'minor' => $version->minor,
'patch' => $version->patch,
];
}
// Sort by version (newest first)
usort($versions, fn ($a, $b) => version_compare($b['version_numeric'], $a['version_numeric']));
$response = [
'supported_versions' => $versions,
'total_versions' => count($versions),
'versioning_config' => [
'strict_versioning' => $this->versioningConfig->strictVersioning,
'deprecation_warnings' => $this->versioningConfig->deprecationWarnings,
'default_version' => $this->versioningConfig->defaultVersion->toString(),
'latest_version' => $this->versioningConfig->getLatestVersion()->toString(),
],
'usage_examples' => [
'header' => [
'description' => 'Using API-Version header',
'example' => 'curl -H "API-Version: 2.0.0" https://api.example.com/users',
],
'url_path' => [
'description' => 'Using version in URL path',
'example' => 'curl https://api.example.com/v2/users',
],
'query_parameter' => [
'description' => 'Using version query parameter',
'example' => 'curl https://api.example.com/users?version=2.0',
],
'accept_header' => [
'description' => 'Using Accept header with version',
'example' => 'curl -H "Accept: application/json;version=2.0" https://api.example.com/users',
],
],
];
return new HttpResponse(
status: Status::OK,
headers: new Headers(['Content-Type' => 'application/json']),
body: $this->jsonSerializer->serialize($response, JsonSerializerConfig::pretty())
);
}
#[Route(path: '/api/version/migrate', method: Method::POST)]
public function getMigrationGuide(Request $request): HttpResponse
{
$data = $this->jsonSerializer->deserialize($request->body);
if (! is_array($data) || empty($data['from_version']) || empty($data['to_version'])) {
return new HttpResponse(
status: Status::BAD_REQUEST,
headers: new Headers(['Content-Type' => 'application/json']),
body: $this->jsonSerializer->serialize([
'error' => 'from_version and to_version are required',
])
);
}
try {
$fromVersion = ApiVersion::fromString($data['from_version']);
$toVersion = ApiVersion::fromString($data['to_version']);
} catch (\Throwable $e) {
return new HttpResponse(
status: Status::BAD_REQUEST,
headers: new Headers(['Content-Type' => 'application/json']),
body: $this->jsonSerializer->serialize([
'error' => 'Invalid version format',
])
);
}
$migrationGuide = $this->generateMigrationGuide($fromVersion, $toVersion);
return new HttpResponse(
status: Status::OK,
headers: new Headers(['Content-Type' => 'application/json']),
body: $this->jsonSerializer->serialize($migrationGuide, JsonSerializerConfig::pretty())
);
}
private function extractVersionFromRequest(Request $request): ApiVersion
{
// Try to extract from headers first (set by middleware)
$versionHeader = $request->headers->getFirst('X-Resolved-API-Version');
if (! empty($versionHeader)) {
try {
return ApiVersion::fromString($versionHeader);
} catch (\Throwable) {
// Fall back to default
}
}
return $this->versioningConfig->defaultVersion;
}
private function isVersionDeprecated(ApiVersion $version): bool
{
// For demo purposes, consider v1.x as deprecated
return $version->major === 1;
}
private function generateMigrationGuide(ApiVersion $fromVersion, ApiVersion $toVersion): array
{
$changes = [];
if ($fromVersion->major === 1 && $toVersion->major === 2) {
$changes = [
'breaking_changes' => [
'Response format changed to include metadata wrapper',
'Profile endpoint moved from /users/{id}/profile to new format',
'Error responses now include detailed validation information',
],
'new_features' => [
'Enhanced user profile with social links and stats',
'Pagination support for list endpoints',
'Response time metadata in all responses',
'Rich error details with field-level validation',
],
'deprecated_endpoints' => [
'/api/v1/users/{id}/profile - Use /api/v2/users/{id}/profile instead',
],
'migration_steps' => [
'1. Update API version header to v2.0.0',
'2. Update response parsing to handle metadata wrapper',
'3. Update error handling for new error format',
'4. Test all endpoints with new response format',
'5. Update profile endpoint calls if used',
],
];
} else {
$changes = [
'breaking_changes' => [],
'new_features' => [],
'deprecated_endpoints' => [],
'migration_steps' => ['No migration needed for this version change'],
];
}
return [
'from_version' => $fromVersion->toString(),
'to_version' => $toVersion->toString(),
'compatible' => $fromVersion->isCompatibleWith($toVersion),
'migration_required' => $fromVersion->major !== $toVersion->major,
'changes' => $changes,
'estimated_effort' => $fromVersion->major !== $toVersion->major ? 'Medium' : 'Low',
];
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Application\Auth;
use App\Framework\Http\ControllerRequest;

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Application\Auth;
class LoginUser
@@ -7,7 +9,6 @@ class LoginUser
public function __construct(
public string $email,
public string $password
)
{
) {
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Application\Auth;
use App\Framework\CommandBus\CommandHandler;
@@ -7,8 +9,8 @@ use App\Framework\CommandBus\CommandHandler;
class LoginUserHandler
{
#[CommandHandler]
public function __invoke(LoginUser $loginUser)
public function __invoke(LoginUser $loginUser): void
{
var_dump($loginUser);
// TODO: Implement actual login logic
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Application\Auth;
use App\Framework\Attributes\Route;
@@ -12,13 +14,12 @@ class ShowLogin
{
public function __construct(
private CommandBus $commandBus,
)
{
) {
}
#[Auth]
#[Route('/login')]
public function __invoke()
public function __invoke(): ViewResult
{
return new ViewResult('loginform');
@@ -26,13 +27,14 @@ class ShowLogin
#[Auth]
#[Route('/login', Method::POST)]
public function login(LoginRequest $request)
public function login(LoginRequest $request): void
{
$login = new LoginUser($request->email, $request->password);
$this->commandBus->dispatch($login);
dd($request);
// TODO: Return proper response
}
}

View File

@@ -1,23 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Application\Backend\RapidMail;
use App\Framework\Attributes\Route;
use App\Framework\Http\HttpResponse;
use App\Framework\Http\Response;
use App\Framework\Router\Result\ViewResult;
use App\Infrastructure\Api\RapidMail\Commands\UpdateRecipientCommand;
use App\Infrastructure\Api\RapidMail\RapidMailClient;
use App\Infrastructure\Api\RapidMail\Recipient;
use App\Infrastructure\Api\RapidMail\RecipientId;
final readonly class Dashbord
{
public function __construct(
private RapidMailClient $rapidMailClient
)
{
) {
}
#[Route(path: '/rapidmail', name: 'rapidmail')]
public function __invoke()
public function __invoke(): Response
{
$all = $this->rapidMailClient->recipients->get(RecipientId::fromInt(629237));
#debug($all);
@@ -31,7 +34,7 @@ final readonly class Dashbord
'Test',
);
$user = $this->rapidMailClient->recipients->updateWithCommand($command);
#$user = $this->rapidMailClient->recipients->updateWithCommand($command);
#$user = $this->rapidMailClient->recipients->update(629237, Recipient::fromArray($array));
@@ -41,6 +44,9 @@ final readonly class Dashbord
#$data = $this->rapidMailClient->statistics->getMailingStats(776);
debug($user);
#debug($user);
return new HttpResponse(body: 'Hello World!');
// TODO: Return proper response
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Application\Contact;
use App\Framework\Http\ControllerRequest;
@@ -8,7 +10,11 @@ use App\Framework\Validation\Rules\Email;
class ContactRequest implements ControllerRequest
{
public string $name;
#[Email]
public string $email;
public string $subject;
public string $message;
}

View File

@@ -1,14 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Application\Contact;
use App\Framework\Attributes\Route;
use App\Framework\CommandBus\CommandBus;
use App\Framework\Http\Method;
use App\Framework\Meta\Keywords;
use App\Framework\Meta\MetaData;
use App\Framework\Meta\StaticPageMetaResolver;
use App\Framework\OpenApi\Attributes\ApiEndpoint;
use App\Framework\OpenApi\Attributes\ApiRequestBody;
use App\Framework\OpenApi\Attributes\ApiResponse;
use App\Framework\Router\ActionResult;
use App\Framework\Router\Result\ContentNegotiationResult;
use App\Framework\Router\Result\Redirect;
use App\Framework\Router\Result\ViewResult;
final readonly class ShowContact
@@ -16,32 +21,54 @@ final readonly class ShowContact
#[Route(path: '/kontakt', name: 'contact')]
public function __invoke(): ViewResult
{
return new ViewResult('contact',
return new ViewResult(
'contact',
new StaticPageMetaResolver(
'Kontakt',
'Kontaktseite!',
Keywords::fromStrings('Kontakt', 'Welt')
)(),);
)(),
);
}
#[Route(path: '/kontakt', method: Method::POST)]
#[ApiEndpoint(
summary: 'Submit contact form',
description: 'Submit a contact form message',
tags: ['Contact'],
)]
#[ApiRequestBody(
description: 'Contact form data',
required: true,
example: [
'name' => 'John Doe',
'email' => 'john@example.com',
'subject' => 'Question about services',
'message' => 'I would like to know more about your services.',
],
)]
#[ApiResponse(
statusCode: 200,
description: 'Contact form submitted successfully',
example: ['success' => true, 'message' => 'Thank you for your message'],
)]
#[ApiResponse(
statusCode: 400,
description: 'Validation error - Invalid form data',
)]
public function senden(ContactRequest $request, CommandBus $commandBus): ActionResult
{
$command = new StoreContact(
$request->email,
$request->name,
$request->subject ?? 'Kein Betreff angegeben',
$request->subject,
$request->message,
);
$commandBus->dispatch($command);
dd($request);
return new ContentNegotiationResult(
);
#return new ViewResult('contact-senden');
// Success! Clear form data and redirect
return new ViewResult('contact-success', new MetaData(
title: 'Kontakt | <NAME>',
), data: ['message' => 'Vielen Dank für Ihre Nachricht!']);
}
}

View File

@@ -1,14 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Application\Contact;
final class StoreContact
final readonly class StoreContact
{
public function __construct(
public string $name,
public string $email,
public string $subject,
public string $message,
) {}
) {
}
}

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Application\Contact;
@@ -11,12 +12,14 @@ final readonly class StoreContactHandler
{
public function __construct(
private ContactRepository $contactRepository,
) {}
) {
}
#[CommandHandler]
public function __invoke(StoreContact $command): void
{
$message = new ContactMessage($command->name, $command->email, $command->message);
$this->contactRepository->save($message);
#$this->contactRepository->save($message);
}
}

View File

@@ -0,0 +1,7 @@
<layout src="main"/>
<section>
<h1>Vielen Dank!</h1>
<p>{{ message }}</p>
<a href="/kontakt">Zurück zum Kontaktformular</a>
</section>

View File

@@ -13,9 +13,6 @@
<input type="text" name="website" id="website" tabindex="-1" autocomplete="off">
</div>
<div role="alert">
Hallo Welt
</div>
<label for="name">Name:</label>
<input type="text" name="name" id="name">

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Application\Controller;
use App\Framework\Http\Method;
use App\Framework\Http\Request;
use App\Framework\Http\Responses\JsonResponse;
use App\Framework\Http\Routing\Route;
use App\Framework\Http\Session\SessionInterface;
use App\Framework\Http\Status;
/**
* Controller for CSRF token management
* Provides API endpoints for token refresh and validation
*/
final readonly class CsrfController
{
public function __construct(
private SessionInterface $session
) {
}
/**
* Generate a fresh CSRF token for a form
* Used by JavaScript to refresh tokens before they expire
*/
#[Route(path: '/api/csrf/refresh', method: Method::GET)]
public function refreshToken(Request $request): JsonResponse
{
// Get form ID from query parameter, default to 'contact_form'
$formId = $request->getQuery('form_id', 'contact_form');
// Validate form_id parameter
if (! is_string($formId) || empty($formId) || ! preg_match('/^[a-zA-Z0-9_-]+$/', $formId)) {
return new JsonResponse([
'error' => 'Invalid form_id parameter',
], Status::BAD_REQUEST);
}
try {
// Generate new token via session's CSRF protection
$newToken = $this->session->csrf->generateToken($formId);
return new JsonResponse([
'success' => true,
'token' => $newToken->toString(),
'form_id' => $formId,
'expires_in' => 7200, // 2 hours in seconds
'refresh_recommended_in' => 6300, // Refresh after 105 minutes (7200 - 900)
]);
} catch (\Exception $e) {
return new JsonResponse([
'error' => 'Failed to generate token',
'message' => $e->getMessage(),
], Status::INTERNAL_SERVER_ERROR);
}
}
/**
* Get information about current CSRF token status
* Useful for debugging and monitoring
*/
#[Route(path: '/api/csrf/info', method: Method::GET)]
public function getTokenInfo(Request $request): JsonResponse
{
$formId = $request->getQuery('form_id', 'contact_form');
if (! is_string($formId) || empty($formId) || ! preg_match('/^[a-zA-Z0-9_-]+$/', $formId)) {
return new JsonResponse([
'error' => 'Invalid form_id parameter',
], Status::BAD_REQUEST);
}
try {
$activeTokenCount = $this->session->csrf->getActiveTokenCount($formId);
return new JsonResponse([
'form_id' => $formId,
'active_tokens' => $activeTokenCount,
'max_tokens_per_form' => 3,
'token_lifetime_seconds' => 7200,
'resubmit_window_seconds' => 30,
]);
} catch (\Exception $e) {
return new JsonResponse([
'error' => 'Failed to get token info',
'message' => $e->getMessage(),
], Status::INTERNAL_SERVER_ERROR);
}
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Application\Controller;
use App\Framework\Attributes\Route;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\Method;
use App\Framework\Meta\MetaData;
use App\Framework\Router\Result\ViewResult;
final readonly class DemoController
{
#[Route(path: '/demo/permissions', method: Method::GET)]
public function permissionsDemo(HttpRequest $request): ViewResult
{
$metaData = new MetaData(
title: 'Permission Management & Biometric Authentication Demo',
description: 'Test und Demo des Permission Management Systems und WebAuthn Biometric Authentication'
);
return new ViewResult('permissions', $metaData, [
'features' => [
'Permission API Management',
'WebAuthn Biometric Authentication',
'Onboarding Flows',
'Conditional UI Setup',
'Credential Management',
],
]);
}
#[Route(path: '/demo/canvas', method: Method::GET)]
public function canvasDemo(HttpRequest $request): ViewResult
{
$metaData = new MetaData(
title: 'Canvas Animation Demo',
description: 'Interactive Canvas Animationen, Parallax Effekte und Datenvisualisierung'
);
return new ViewResult('canvas', $metaData, [
'features' => [
'Interactive Canvas Elements',
'Parallax & Scroll Effects',
'Data Visualization',
'Particle Systems',
'Performance Optimized',
],
]);
}
#[Route(path: '/demo/api-manager', method: Method::GET)]
public function apiManagerDemo(HttpRequest $request): ViewResult
{
$metaData = new MetaData(
title: 'API Manager Demo',
description: 'Zentrale Verwaltung aller Web APIs für moderne Browser-Features'
);
return new ViewResult('api-manager', $metaData, [
'features' => [
'Observer APIs (Intersection, Resize, Mutation)',
'Media APIs (Camera, Microphone, WebRTC)',
'Storage APIs (IndexedDB, Cache API)',
'Device APIs (Geolocation, Sensors)',
'Web Animations API',
'Worker APIs (Service Worker, Web Worker)',
'Performance APIs',
],
]);
}
}

View File

@@ -0,0 +1,379 @@
<?php
declare(strict_types=1);
namespace App\Application\Controller;
use App\Framework\Http\Attribute\Route;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\Method;
use App\Framework\Http\Result\HtmlResult;
use App\Framework\Http\Result\HttpResponse;
use App\Framework\QrCode\ErrorCorrectionLevel;
use App\Framework\QrCode\QrCodeGenerator;
use App\Framework\QrCode\QrCodeVersion;
/**
* QR Code Test Controller
*
* Displays example QR codes for testing purposes in development
*/
final readonly class QrCodeTestController
{
public function __construct(
private QrCodeGenerator $qrCodeGenerator
) {
}
/**
* Show QR code test page with multiple examples
*/
#[Route(path: '/test/qr-codes', method: Method::GET)]
public function showTestPage(HttpRequest $request): HttpResponse
{
// Generate various test QR codes
$examples = [
[
'title' => 'Simple Text',
'data' => 'Hello, World!',
'description' => 'Basic text QR code (Version 1)',
'version' => null,
'errorLevel' => null,
],
[
'title' => 'URL Example',
'data' => 'https://example.com/test?param=value',
'description' => 'Website URL QR code',
'version' => null,
'errorLevel' => ErrorCorrectionLevel::M,
],
[
'title' => 'TOTP Example',
'data' => 'otpauth://totp/TestApp:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=TestApp&algorithm=SHA1&digits=6&period=30',
'description' => 'TOTP authenticator setup (Version 3)',
'version' => QrCodeVersion::forTotp(),
'errorLevel' => ErrorCorrectionLevel::M,
],
[
'title' => 'WiFi Connection',
'data' => 'WIFI:T:WPA;S:TestNetwork;P:password123;H:false;;',
'description' => 'WiFi connection QR code',
'version' => null,
'errorLevel' => ErrorCorrectionLevel::L,
],
[
'title' => 'Contact Card',
'data' => 'BEGIN:VCARD\nVERSION:3.0\nFN:John Doe\nTEL:+49123456789\nEMAIL:john@example.com\nEND:VCARD',
'description' => 'vCard contact information',
'version' => null,
'errorLevel' => ErrorCorrectionLevel::M,
],
];
$qrCodes = [];
foreach ($examples as $example) {
try {
// Generate SVG
$svg = $this->qrCodeGenerator->generateSvg(
$example['data'],
$example['errorLevel'],
$example['version']
);
// Generate data URI
$dataUri = $this->qrCodeGenerator->generateDataUri(
$example['data'],
$example['errorLevel'],
$example['version']
);
// Analyze the data
$analysis = $this->qrCodeGenerator->analyzeData($example['data']);
$qrCodes[] = [
'title' => $example['title'],
'description' => $example['description'],
'data' => $example['data'],
'svg' => $svg,
'dataUri' => $dataUri,
'analysis' => $analysis,
'success' => true,
];
} catch (\Exception $e) {
$qrCodes[] = [
'title' => $example['title'],
'description' => $example['description'],
'data' => $example['data'],
'error' => $e->getMessage(),
'success' => false,
];
}
}
$html = $this->generateTestPageHtml($qrCodes);
return new HtmlResult($html);
}
/**
* Generate individual QR code for API testing
*/
#[Route(path: '/test/qr-code', method: Method::GET)]
public function generateTestQrCode(HttpRequest $request): HttpResponse
{
$data = $request->query->get('data', 'Test QR Code from API');
$format = $request->query->get('format', 'svg'); // svg or datauri
$errorLevel = $request->query->get('error', 'M');
$version = $request->query->get('version');
try {
// Parse error correction level
$errorCorrectionLevel = match($errorLevel) {
'L' => ErrorCorrectionLevel::L,
'Q' => ErrorCorrectionLevel::Q,
'H' => ErrorCorrectionLevel::H,
default => ErrorCorrectionLevel::M,
};
// Parse version if provided
$qrVersion = $version ? new QrCodeVersion((int) $version) : null;
if ($format === 'datauri') {
$result = $this->qrCodeGenerator->generateDataUri($data, $errorCorrectionLevel, $qrVersion);
return new HtmlResult("<img src=\"{$result}\" alt=\"QR Code\" style=\"max-width: 100%;\"/>");
} else {
$svg = $this->qrCodeGenerator->generateSvg($data, $errorCorrectionLevel, $qrVersion);
return new HttpResponse(
body: $svg,
statusCode: 200,
headers: ['Content-Type' => 'image/svg+xml']
);
}
} catch (\Exception $e) {
return new HtmlResult(
"<h1>QR Code Generation Error</h1><p>{$e->getMessage()}</p>",
500
);
}
}
/**
* Generate test page HTML
*/
private function generateTestPageHtml(array $qrCodes): string
{
$examples = '';
foreach ($qrCodes as $qrCode) {
if ($qrCode['success']) {
$analysis = $qrCode['analysis'];
$analysisHtml = '';
foreach ($analysis as $key => $value) {
$displayValue = is_object($value) ? class_basename($value) : $value;
$analysisHtml .= "<tr><td>{$key}</td><td>{$displayValue}</td></tr>";
}
$examples .= "
<div class=\"qr-example\">
<h3>{$qrCode['title']}</h3>
<p class=\"description\">{$qrCode['description']}</p>
<div class=\"qr-container\">
<div class=\"qr-code\">
<img src=\"{$qrCode['dataUri']}\" alt=\"{$qrCode['title']} QR Code\" />
</div>
<div class=\"qr-info\">
<h4>Data:</h4>
<pre class=\"data\">" . htmlspecialchars($qrCode['data']) . "</pre>
<h4>Analysis:</h4>
<table class=\"analysis\">
{$analysisHtml}
</table>
</div>
</div>
</div>";
} else {
$examples .= "
<div class=\"qr-example error\">
<h3>{$qrCode['title']} - ERROR</h3>
<p class=\"description\">{$qrCode['description']}</p>
<p class=\"error-message\">Error: {$qrCode['error']}</p>
<pre class=\"data\">" . htmlspecialchars($qrCode['data']) . "</pre>
</div>";
}
}
return "
<!DOCTYPE html>
<html lang=\"de\">
<head>
<meta charset=\"UTF-8\">
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">
<title>QR Code Test Page</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
line-height: 1.6;
background: #f5f5f5;
}
h1 {
color: #333;
text-align: center;
margin-bottom: 2rem;
}
.api-info {
background: #e3f2fd;
border: 1px solid #2196f3;
border-radius: 4px;
padding: 15px;
margin-bottom: 2rem;
}
.api-info h3 {
margin-top: 0;
color: #1976d2;
}
.api-info code {
background: #fff;
padding: 2px 6px;
border-radius: 3px;
font-family: Monaco, Consolas, monospace;
}
.qr-example {
background: white;
border-radius: 8px;
padding: 20px;
margin-bottom: 2rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.qr-example.error {
border-left: 4px solid #f44336;
background: #ffebee;
}
.qr-example h3 {
margin-top: 0;
color: #333;
}
.description {
color: #666;
font-style: italic;
}
.error-message {
color: #d32f2f;
font-weight: bold;
}
.qr-container {
display: flex;
gap: 20px;
align-items: flex-start;
}
.qr-code {
flex-shrink: 0;
}
.qr-code img {
border: 1px solid #ddd;
border-radius: 4px;
max-width: 200px;
}
.qr-info {
flex: 1;
min-width: 0;
}
.qr-info h4 {
margin: 1rem 0 0.5rem 0;
color: #555;
}
.data {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 4px;
padding: 10px;
font-family: Monaco, Consolas, monospace;
font-size: 12px;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
}
.analysis {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.analysis td {
padding: 4px 8px;
border-bottom: 1px solid #eee;
}
.analysis td:first-child {
font-weight: bold;
color: #666;
white-space: nowrap;
}
@media (max-width: 768px) {
.qr-container {
flex-direction: column;
}
.qr-code img {
max-width: 150px;
}
}
</style>
</head>
<body>
<h1>🧪 QR Code Test Page</h1>
<div class=\"api-info\">
<h3>📡 API Endpoints</h3>
<p><strong>Dynamic QR Code Generation:</strong><br>
<code>GET /test/qr-code?data=Your+Data&format=svg&error=M&version=3</code></p>
<p><strong>Parameters:</strong></p>
<ul>
<li><code>data</code> - Text to encode (URL encoded)</li>
<li><code>format</code> - Output format: <code>svg</code> (default) or <code>datauri</code></li>
<li><code>error</code> - Error correction: <code>L</code>, <code>M</code> (default), <code>Q</code>, <code>H</code></li>
<li><code>version</code> - QR version 1-40 (auto-detect if omitted)</li>
</ul>
<p><strong>Examples:</strong><br>
<code>/test/qr-code?data=Hello+World</code><br>
<code>/test/qr-code?data=https://example.com&error=H</code><br>
<code>/test/qr-code?data=Test&format=datauri&version=2</code></p>
</div>
{$examples}
<div style=\"text-align: center; margin-top: 3rem; color: #666; font-size: 14px;\">
<p>✅ QR Code Framework Module - Fully Functional</p>
<p>Generated by Custom PHP Framework QR Code Generator</p>
</div>
</body>
</html>";
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,658 @@
<main class="demo-container">
<section class="hero-section">
<h1>🔐 Permission Management & Biometric Authentication</h1>
<p class="hero-description">Test und Demo des Permission Management Systems und WebAuthn Biometric Authentication</p>
</section>
<div class="demo-grid">
<!-- API Support Status -->
<section class="demo-card">
<h2>📋 API Support Status</h2>
<button class="btn btn-primary" onclick="checkAPISupport()">Check API Support</button>
<div id="api-support-results" class="results-box"></div>
</section>
<!-- Permission Management -->
<section class="demo-card">
<h2>🔔 Permission Management</h2>
<div class="button-group">
<h3>Individual Permissions</h3>
<button class="btn btn-secondary" onclick="checkPermission('camera')">Check Camera</button>
<button class="btn btn-secondary" onclick="checkPermission('microphone')">Check Microphone</button>
<button class="btn btn-secondary" onclick="checkPermission('geolocation')">Check Location</button>
<button class="btn btn-secondary" onclick="checkPermission('notifications')">Check Notifications</button>
</div>
<div class="button-group">
<h3>Request Permissions</h3>
<button class="btn btn-primary" onclick="requestPermission('camera')">Request Camera</button>
<button class="btn btn-primary" onclick="requestPermission('microphone')">Request Microphone</button>
<button class="btn btn-primary" onclick="requestPermission('geolocation')">Request Location</button>
<button class="btn btn-primary" onclick="requestPermission('notifications')">Request Notifications</button>
</div>
<div class="button-group">
<h3>Batch Operations</h3>
<button class="btn btn-success" onclick="requestMultiplePermissions()">Request Multiple</button>
<button class="btn btn-success" onclick="startOnboardingFlow()">Start Onboarding</button>
<button class="btn btn-outline" onclick="getPermissionReport()">Get Report</button>
</div>
<div id="permission-results" class="results-box"></div>
</section>
<!-- Biometric Authentication -->
<section class="demo-card">
<h2>👆 Biometric Authentication</h2>
<div class="form-group">
<h3>User Information</h3>
<div class="input-group">
<label for="username">Username/Email:</label>
<input type="text" id="username" placeholder="user@example.com" value="testuser@example.com">
</div>
<div class="input-group">
<label for="displayName">Display Name:</label>
<input type="text" id="displayName" placeholder="Test User" value="Test User">
</div>
</div>
<div class="button-group">
<h3>Authentication Actions</h3>
<button class="btn btn-info" onclick="checkBiometricSupport()">Check Support</button>
<button class="btn btn-success" onclick="registerBiometric()">Register Biometric</button>
<button class="btn btn-primary" onclick="authenticateBiometric()">Authenticate</button>
<button class="btn btn-secondary" onclick="setupConditionalUI()">Setup Conditional UI</button>
</div>
<div class="button-group">
<h3>Management</h3>
<button class="btn btn-outline" onclick="getBiometricStatus()">Get Status</button>
<button class="btn btn-outline" onclick="listCredentials()">List Credentials</button>
<button class="btn btn-danger" onclick="clearCredentials()">Clear All</button>
</div>
<div id="biometric-results" class="results-box"></div>
<div class="credentials-section">
<h3>Registered Credentials</h3>
<div id="credentials-list" class="credentials-list">
<p>No credentials registered</p>
</div>
</div>
</section>
<!-- Combined Workflows -->
<section class="demo-card full-width">
<h2>🔄 Combined Workflows</h2>
<div class="button-group">
<button class="btn btn-success" onclick="completeSetupFlow()">Complete Setup Flow</button>
<button class="btn btn-primary" onclick="createSecureLoginFlow()">Create Secure Login Flow</button>
</div>
<div id="workflow-results" class="results-box"></div>
</section>
</div>
</main>
<style>
.demo-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.hero-section {
text-align: center;
margin-bottom: 3rem;
}
.hero-section h1 {
font-size: 2.5rem;
margin-bottom: 1rem;
color: #333;
}
.hero-description {
font-size: 1.2rem;
color: #666;
margin-bottom: 0;
}
.demo-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
gap: 2rem;
}
.demo-card {
background: white;
border-radius: 12px;
padding: 2rem;
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
border: 1px solid #e1e5e9;
}
.demo-card.full-width {
grid-column: 1 / -1;
}
.demo-card h2 {
margin: 0 0 1.5rem 0;
color: #333;
border-bottom: 2px solid #f0f2f5;
padding-bottom: 0.5rem;
}
.demo-card h3 {
margin: 1.5rem 0 1rem 0;
color: #555;
font-size: 1.1rem;
}
.button-group {
margin-bottom: 2rem;
}
.button-group:last-of-type {
margin-bottom: 1rem;
}
.btn {
display: inline-block;
padding: 0.75rem 1.5rem;
margin: 0.25rem 0.5rem 0.25rem 0;
border: none;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
}
.btn-primary {
background: #007bff;
color: white;
}
.btn-primary:hover {
background: #0056b3;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #545b62;
}
.btn-success {
background: #28a745;
color: white;
}
.btn-success:hover {
background: #1e7e34;
}
.btn-info {
background: #17a2b8;
color: white;
}
.btn-info:hover {
background: #117a8b;
}
.btn-outline {
background: white;
color: #6c757d;
border: 2px solid #dee2e6;
}
.btn-outline:hover {
background: #f8f9fa;
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-danger:hover {
background: #c82333;
}
.btn:disabled {
background: #ccc !important;
color: #666 !important;
cursor: not-allowed;
}
.form-group {
margin-bottom: 2rem;
}
.input-group {
margin-bottom: 1rem;
}
.input-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: #555;
}
.input-group input {
width: 100%;
padding: 0.75rem;
border: 2px solid #dee2e6;
border-radius: 6px;
font-size: 1rem;
transition: border-color 0.2s ease;
}
.input-group input:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 3px rgba(0,123,255,0.1);
}
.results-box {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 6px;
padding: 1rem;
white-space: pre-wrap;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.85rem;
line-height: 1.4;
max-height: 300px;
overflow-y: auto;
margin-top: 1rem;
}
.results-box:empty::after {
content: "No results yet...";
color: #999;
font-style: italic;
}
.credentials-section {
margin-top: 2rem;
padding-top: 2rem;
border-top: 1px solid #e1e5e9;
}
.credentials-list {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 6px;
padding: 1rem;
min-height: 60px;
}
.credential-item {
background: white;
padding: 1rem;
margin-bottom: 0.5rem;
border-radius: 6px;
border: 1px solid #e0e0e0;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.credential-item:last-child {
margin-bottom: 0;
}
.credential-item strong {
color: #333;
}
.credential-item button {
background: #dc3545;
color: white;
border: none;
padding: 0.25rem 0.75rem;
border-radius: 4px;
margin-top: 0.5rem;
cursor: pointer;
font-size: 0.8rem;
}
.credential-item button:hover {
background: #c82333;
}
@media (max-width: 768px) {
.demo-grid {
grid-template-columns: 1fr;
}
.demo-container {
padding: 1rem;
}
.hero-section h1 {
font-size: 2rem;
}
.btn {
display: block;
width: 100%;
margin: 0.25rem 0;
}
}
</style>
<script>
// Wait for API to be available
document.addEventListener('DOMContentLoaded', async function() {
// Wait for modules to initialize
await new Promise(resolve => setTimeout(resolve, 1000));
if (!window.API) {
console.error('API Manager not available');
return;
}
// Check if required managers are available
if (!window.API.permissions || !window.API.biometric) {
console.error('Permission or Biometric managers not available');
return;
}
console.log('🔐 Permission Management & Biometric Auth Demo ready');
console.log('Available API Managers:', Object.keys(window.API));
});
// API Support Check
async function checkAPISupport() {
const results = document.getElementById('api-support-results');
if (!window.API) {
results.textContent = 'ERROR: API Manager not available!';
return;
}
try {
const permissionSupport = window.API.permissions ? await window.API.permissions.getPermissionReport() : null;
const biometricSupport = window.API.biometric ? await window.API.biometric.isAvailable() : null;
results.textContent = JSON.stringify({
apiManagers: {
permissions: !!window.API.permissions,
biometric: !!window.API.biometric,
media: !!window.API.media,
device: !!window.API.device
},
permissionSupport,
biometricSupport
}, null, 2);
} catch (error) {
results.textContent = `ERROR: ${error.message}`;
console.error('API Support Check Error:', error);
}
}
// Permission Functions
async function checkPermission(permission) {
const results = document.getElementById('permission-results');
try {
const status = await window.API.permissions.check(permission);
results.textContent = `${permission} permission status:\n${JSON.stringify(status, null, 2)}`;
} catch (error) {
results.textContent = `ERROR checking ${permission}: ${error.message}`;
}
}
async function requestPermission(permission) {
const results = document.getElementById('permission-results');
try {
const result = await window.API.permissions.request(permission, {
showRationale: true,
timeout: 30000
});
results.textContent = `${permission} permission request result:\n${JSON.stringify(result, null, 2)}`;
} catch (error) {
results.textContent = `ERROR requesting ${permission}: ${error.message}`;
}
}
async function requestMultiplePermissions() {
const results = document.getElementById('permission-results');
try {
const permissions = ['camera', 'microphone', 'geolocation', 'notifications'];
const result = await window.API.permissions.requestMultiple(permissions, {
sequential: false,
requireAll: false
});
results.textContent = `Multiple permissions result:\n${JSON.stringify(result, null, 2)}`;
} catch (error) {
results.textContent = `ERROR requesting multiple permissions: ${error.message}`;
}
}
async function startOnboardingFlow() {
const results = document.getElementById('permission-results');
try {
const permissions = ['camera', 'microphone', 'geolocation'];
const onboardingFlow = window.API.permissions.createOnboardingFlow(permissions, {
title: 'App Permissions Setup',
descriptions: {
camera: 'We need camera access to take photos and scan QR codes',
microphone: 'We need microphone access for voice messages',
geolocation: 'We need location access to show nearby places'
}
});
const result = await onboardingFlow.start();
results.textContent = `Onboarding flow result:\n${JSON.stringify(result, null, 2)}`;
} catch (error) {
results.textContent = `ERROR in onboarding flow: ${error.message}`;
}
}
async function getPermissionReport() {
const results = document.getElementById('permission-results');
try {
const report = await window.API.permissions.getPermissionReport();
results.textContent = `Permission Report:\n${JSON.stringify(report, null, 2)}`;
} catch (error) {
results.textContent = `ERROR getting permission report: ${error.message}`;
}
}
// Biometric Functions
async function checkBiometricSupport() {
const results = document.getElementById('biometric-results');
try {
const availability = await window.API.biometric.isAvailable();
results.textContent = `Biometric Support:\n${JSON.stringify(availability, null, 2)}`;
} catch (error) {
results.textContent = `ERROR checking biometric support: ${error.message}`;
}
}
async function registerBiometric() {
const results = document.getElementById('biometric-results');
const username = document.getElementById('username').value;
const displayName = document.getElementById('displayName').value;
if (!username) {
results.textContent = 'ERROR: Please enter a username';
return;
}
try {
const userInfo = {
id: username,
username: username,
displayName: displayName || username
};
const result = await window.API.biometric.register(userInfo);
results.textContent = `Registration Result:\n${JSON.stringify(result, null, 2)}`;
if (result.success) {
updateCredentialsList();
}
} catch (error) {
results.textContent = `ERROR during registration: ${error.message}`;
}
}
async function authenticateBiometric() {
const results = document.getElementById('biometric-results');
try {
const result = await window.API.biometric.authenticate();
results.textContent = `Authentication Result:\n${JSON.stringify(result, null, 2)}`;
} catch (error) {
results.textContent = `ERROR during authentication: ${error.message}`;
}
}
async function setupConditionalUI() {
const results = document.getElementById('biometric-results');
try {
const result = await window.API.biometric.setupConditionalUI();
results.textContent = `Conditional UI Setup:\n${JSON.stringify(result, null, 2)}`;
} catch (error) {
results.textContent = `ERROR setting up conditional UI: ${error.message}`;
}
}
async function getBiometricStatus() {
const results = document.getElementById('biometric-results');
try {
const status = await window.API.biometric.getStatusReport();
results.textContent = `Biometric Status:\n${JSON.stringify(status, null, 2)}`;
} catch (error) {
results.textContent = `ERROR getting biometric status: ${error.message}`;
}
}
function listCredentials() {
updateCredentialsList();
}
function clearCredentials() {
if (confirm('Are you sure you want to clear all biometric credentials?')) {
try {
const credentials = window.API.biometric.getCredentials();
credentials.forEach(cred => {
window.API.biometric.revokeCredential(cred.id);
});
updateCredentialsList();
document.getElementById('biometric-results').textContent = 'All credentials cleared';
} catch (error) {
document.getElementById('biometric-results').textContent = `ERROR clearing credentials: ${error.message}`;
}
}
}
// Workflow Functions
async function completeSetupFlow() {
const results = document.getElementById('workflow-results');
try {
// Step 1: Request required permissions
const permissionResult = await window.API.permissions.requestMultiple(
['camera', 'microphone', 'notifications'],
{ sequential: true }
);
// Step 2: If permissions granted, setup biometric auth
if (permissionResult.granted > 0) {
const userInfo = {
id: 'workflow-user',
username: 'workflow-user',
displayName: 'Workflow Test User'
};
const biometricResult = await window.API.biometric.register(userInfo);
results.textContent = `Complete Setup Flow:\nPermissions: ${permissionResult.granted}/${permissionResult.total} granted\nBiometric: ${biometricResult.success ? 'Registered' : 'Failed'}\n\n` +
JSON.stringify({ permissions: permissionResult, biometric: biometricResult }, null, 2);
if (biometricResult.success) {
updateCredentialsList();
}
} else {
results.textContent = `Setup failed - no permissions granted:\n${JSON.stringify(permissionResult, null, 2)}`;
}
} catch (error) {
results.textContent = `ERROR in setup flow: ${error.message}`;
}
}
async function createSecureLoginFlow() {
const results = document.getElementById('workflow-results');
try {
const loginFlow = window.API.biometric.createLoginFlow({
onRegister: (result) => {
console.log('Biometric registered:', result);
updateCredentialsList();
},
onLogin: (result) => {
console.log('Biometric login successful:', result);
},
onError: (error) => {
console.error('Login flow error:', error);
}
});
const availability = await loginFlow.init();
results.textContent = `Secure Login Flow Created:\n${JSON.stringify(availability, null, 2)}`;
} catch (error) {
results.textContent = `ERROR creating secure login flow: ${error.message}`;
}
}
// Helper Functions
function updateCredentialsList() {
const credentialsList = document.getElementById('credentials-list');
try {
const credentials = window.API.biometric.getCredentials();
if (credentials.length === 0) {
credentialsList.innerHTML = '<p style="margin: 0; color: #666; font-style: italic;">No credentials registered</p>';
} else {
const credentialsHTML = credentials.map(cred => `
<div class="credential-item">
<strong>ID:</strong> ${cred.id.substring(0, 20)}...<br>
<strong>User:</strong> ${cred.userDisplayName}<br>
<strong>Created:</strong> ${new Date(cred.createdAt).toLocaleString()}<br>
<strong>Last Used:</strong> ${cred.lastUsed ? new Date(cred.lastUsed).toLocaleString() : 'Never'}<br>
<strong>Transports:</strong> ${cred.transports.join(', ')}<br>
<button onclick="window.API.biometric.revokeCredential('${cred.id}'); updateCredentialsList();">Revoke</button>
</div>
`).join('');
credentialsList.innerHTML = credentialsHTML;
}
} catch (error) {
credentialsList.innerHTML = `<p style="color: #dc3545;">Error loading credentials: ${error.message}</p>`;
}
}
// Initial setup
setTimeout(updateCredentialsList, 2000);
</script>

View File

@@ -0,0 +1,318 @@
<?php
declare(strict_types=1);
namespace App\Application\Design\Controller;
use App\Framework\Attributes\Route;
use App\Framework\Design\Component\ComponentCategory;
use App\Framework\Design\Service\DesignSystemAnalyzer;
use App\Framework\Design\ComponentScanner;
use App\Framework\Filesystem\FilePath;
use App\Framework\Filesystem\FileScanner;
use App\Framework\Filesystem\ValueObjects\FilePattern;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\Method;
use App\Framework\Http\Status;
use App\Framework\Meta\MetaData;
use App\Framework\Router\Result\JsonResult;
use App\Framework\Router\Result\ViewResult;
/**
* Design System Documentation Controller
*/
final readonly class DesignSystemController
{
public function __construct(
private DesignSystemAnalyzer $analyzer,
private FileScanner $fileScanner,
private ComponentScanner $componentScanner
) {
}
#[Route(path: '/design-system', method: Method::GET)]
public function dashboard(HttpRequest $request): ViewResult
{
// Analysiere CSS Dateien
$cssFiles = $this->findCssFiles();
$analysis = $this->analyzer->analyze($cssFiles);
return new ViewResult(
'design-dashboard',
new MetaData('Design System Dashboard', 'Design System Dashboard'),
[
'analysis' => $analysis,
'maturity_level' => $analysis->getMaturityLevel(),
'overall_score' => $analysis->getOverallDesignSystemScore(),
'critical_issues' => $analysis->getCriticalIssues(),
'quick_wins' => $analysis->getQuickWins(),
]
);
}
#[Route(path: '/design-system/tokens', method: Method::GET)]
public function tokens(HttpRequest $request): ViewResult
{
$cssFiles = $this->findCssFiles();
$analysis = $this->analyzer->analyze($cssFiles);
// Gruppiere Tokens nach Typ
$tokensByType = [];
foreach ($analysis->tokenAnalysis->usedTokens as $token) {
$type = $token->getType()->value;
if (! isset($tokensByType[$type])) {
$tokensByType[$type] = [];
}
$tokensByType[$type][] = $token;
}
return new ViewResult(
'tokens',
new MetaData('Design System Tokens', 'Design System Tokens'),
[
'analysis' => $analysis->tokenAnalysis,
'tokens_by_type' => $tokensByType,
'coverage' => $analysis->tokenAnalysis->getTokenCoverage(),
'missing_tokens' => $analysis->tokenAnalysis->missingTokens,
'unused_tokens' => $analysis->tokenAnalysis->unusedTokens,
]
);
}
#[Route(path: '/design-system/colors', method: Method::GET)]
public function colors(HttpRequest $request): ViewResult
{
$cssFiles = $this->findCssFiles();
$analysis = $this->analyzer->analyze($cssFiles);
return new ViewResult(
'colors',
new Metadata('Design System Colors', 'Design System Colors'),
[
'analysis' => $analysis->colorAnalysis,
'palette_summary' => $analysis->colorAnalysis->getPaletteSummary(),
'contrast_issues' => $analysis->colorAnalysis->getWorstContrastPairs(),
'duplicates' => $analysis->colorAnalysis->duplicateColors,
'format_recommendations' => $analysis->colorAnalysis->getFormatRecommendations(),
]
);
}
#[Route(path: '/design-system/components', method: Method::GET)]
public function components(HttpRequest $request): ViewResult
{
$cssFiles = $this->findCssFiles();
$componentRegistry = $this->componentScanner->scanComponents($cssFiles);
// Get search query if provided
$search = $request->query->get('search', '');
$category = $request->query->get('category', '');
$components = $componentRegistry->getAllComponents();
// Filter by search
if (!empty($search)) {
$components = $componentRegistry->searchComponents($search);
}
// Filter by category
if (!empty($category)) {
$categoryEnum = ComponentCategory::tryFrom($category);
if ($categoryEnum !== null) {
$components = $componentRegistry->getByCategory($categoryEnum);
}
}
// Transform components for template
$templateComponents = [];
foreach ($components as $component) {
$templateComponents[] = [
'name' => $component->name,
'displayName' => $component->getDisplayName(),
'category' => $component->category->value,
'pattern' => strtoupper($component->pattern->value),
'previewHtml' => $component->getPreviewHtml(),
'selector' => $component->selector,
];
}
return new ViewResult(
'components-overview',
new Metadata('Design System Components', 'Design System Components'),
[
'components' => $templateComponents,
'componentRegistry' => $componentRegistry,
'categoryCounts' => $componentRegistry->getCategoryCounts(),
'patternCounts' => $componentRegistry->getPatternCounts(),
'totalComponents' => $componentRegistry->getTotalComponents(),
'groupedComponents' => $componentRegistry->groupByCategory(),
'searchQuery' => $search,
'selectedCategory' => $category,
'categories' => ComponentCategory::cases(),
]
);
}
#[Route(path: '/design-system/components/{componentName}', method: Method::GET)]
public function componentDetail(HttpRequest $request, ?string $componentName = null): ViewResult
{
// If componentName wasn't injected by route parameter, extract from path
if ($componentName === null) {
$pathParts = explode('/', trim($request->path, '/'));
$componentName = end($pathParts) ?: '';
}
$cssFiles = $this->findCssFiles();
$componentRegistry = $this->componentScanner->scanComponents($cssFiles);
$component = $componentRegistry->findByName($componentName);
if ($component === null) {
return new ViewResult(
'component-not-found',
new Metadata('Component Not Found', 'Component Not Found'),
['componentName' => $componentName]
);
}
// Get all variants of this component
$variants = $componentRegistry->getComponentVariants($componentName);
return new ViewResult(
'component-detail',
new Metadata("Component: {$component->getDisplayName()}", "Component: {$component->getDisplayName()}"),
[
'component' => $component,
'variants' => $variants,
'allComponents' => $componentRegistry->getAllComponents(),
]
);
}
#[Route(path: '/design-system/conventions', method: Method::GET)]
public function conventions(HttpRequest $request): ViewResult
{
$cssFiles = $this->findCssFiles();
$analysis = $this->analyzer->analyze($cssFiles);
return new ViewResult(
'conventions',
new Metadata('Design System Conventions', 'Design System Conventions'),
[
'analysis' => $analysis->conventionAnalysis,
'violations_by_severity' => $analysis->conventionAnalysis->getViolationsBySeverity(),
'worst_areas' => $analysis->conventionAnalysis->getWorstAreas(),
'best_areas' => $analysis->conventionAnalysis->getBestAreas(),
'improvement_potential' => $analysis->conventionAnalysis->getImprovementPotential(),
]
);
}
#[Route(path: '/design-system/roadmap', method: Method::GET)]
public function roadmap(HttpRequest $request): ViewResult
{
$cssFiles = $this->findCssFiles();
$analysis = $this->analyzer->analyze($cssFiles);
return new ViewResult(
'roadmap',
new Metadata('Design System Roadmap', 'Design System Roadmap'),
[
'roadmap' => $analysis->getDevelopmentRoadmap(),
'critical_issues' => $analysis->getCriticalIssues(),
'quick_wins' => $analysis->getQuickWins(),
'current_score' => $analysis->getOverallDesignSystemScore(),
'maturity_level' => $analysis->getMaturityLevel(),
]
);
}
#[Route(path: '/api/design-system/export', method: Method::GET)]
public function export(HttpRequest $request): JsonResult
{
$cssFiles = $this->findCssFiles();
$analysis = $this->analyzer->analyze($cssFiles);
$format = $request->query->get('format', 'json');
return match($format) {
'summary' => new JsonResult([
'overall_score' => $analysis->getOverallDesignSystemScore(),
'maturity_level' => $analysis->getMaturityLevel(),
'critical_issues_count' => count($analysis->getCriticalIssues()),
'quick_wins_count' => count($analysis->getQuickWins()),
'token_coverage' => $analysis->tokenAnalysis->getTokenCoverage()['usage_percentage'],
'color_consistency' => $analysis->colorAnalysis->getConsistencyScore(),
'convention_score' => $analysis->conventionAnalysis->overallScore,
]),
'full' => new JsonResult($analysis->exportReport()),
default => new JsonResult($analysis->exportReport())
};
}
#[Route(path: '/api/design-system/analyze', method: Method::POST)]
public function analyzeFiles(HttpRequest $request): JsonResult
{
$files = $request->parsedBody->get('files', []);
if (empty($files)) {
return new JsonResult(['error' => 'No files provided'], Status::BAD_REQUEST);
}
$cssFiles = array_map(fn ($file) => new FilePath($file), $files);
try {
$analysis = $this->analyzer->analyze($cssFiles);
return new JsonResult([
'success' => true,
'analysis' => [
'overall_score' => $analysis->getOverallDesignSystemScore(),
'maturity_level' => $analysis->getMaturityLevel(),
'summary' => [
'total_tokens' => $analysis->tokenAnalysis->totalTokens,
'total_colors' => $analysis->colorAnalysis->totalColors,
'total_components' => $analysis->componentAnalysis->totalComponents,
'convention_score' => $analysis->conventionAnalysis->overallScore,
],
'critical_issues' => $analysis->getCriticalIssues(),
'quick_wins' => $analysis->getQuickWins(),
],
]);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => $e->getMessage(),
], Status::INTERNAL_SERVER_ERROR);
}
}
/**
* Findet alle CSS-Dateien im Projekt über FileScanner
*/
private function findCssFiles(): array
{
$cssFiles = [];
$cssPattern = FilePattern::css();
// CSS Dateien in resources/css/
$resourcesPath = new FilePath(__DIR__ . '/../../../../resources/css');
if ($resourcesPath->exists() && $resourcesPath->isDirectory()) {
$collection = $this->fileScanner->findFiles($resourcesPath, $cssPattern);
foreach ($collection as $file) {
$cssFiles[] = $file->getPath();
}
}
// CSS Dateien in public/assets/
$publicPath = new FilePath(__DIR__ . '/../../../../public/assets');
if ($publicPath->exists() && $publicPath->isDirectory()) {
$collection = $this->fileScanner->findFiles($publicPath, $cssPattern);
foreach ($collection as $file) {
$cssFiles[] = $file->getPath();
}
}
return $cssFiles;
}
}

View File

@@ -0,0 +1,384 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Design System - Farben</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background-color: #f8fafc;
color: #334155;
line-height: 1.6;
}
.header {
background: white;
border-bottom: 1px solid #e2e8f0;
padding: 1rem 2rem;
}
.header h1 {
font-size: 1.5rem;
font-weight: 600;
color: #1e293b;
}
.nav {
background: white;
border-bottom: 1px solid #e2e8f0;
padding: 0 2rem;
}
.nav-list {
display: flex;
list-style: none;
gap: 2rem;
}
.nav-link {
display: block;
padding: 1rem 0;
text-decoration: none;
color: #64748b;
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
.nav-link:hover,
.nav-link.active {
color: #3b82f6;
border-bottom-color: #3b82f6;
}
.main {
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background: white;
padding: 1.5rem;
border-radius: 8px;
border: 1px solid #e2e8f0;
text-align: center;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
color: #1e293b;
margin-bottom: 0.25rem;
}
.stat-label {
color: #64748b;
font-size: 0.875rem;
}
.content-section {
background: white;
padding: 1.5rem;
border-radius: 8px;
border: 1px solid #e2e8f0;
margin-bottom: 2rem;
}
.section-title {
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 1rem;
color: #1e293b;
}
.color-swatch {
display: inline-block;
width: 24px;
height: 24px;
border-radius: 4px;
border: 1px solid #e2e8f0;
margin-right: 0.75rem;
vertical-align: middle;
}
.color-item {
display: flex;
align-items: center;
padding: 0.75rem;
margin-bottom: 0.5rem;
border-radius: 6px;
background-color: #f8fafc;
border: 1px solid #e2e8f0;
}
.color-info {
flex-grow: 1;
}
.color-name {
font-weight: 600;
margin-bottom: 0.25rem;
}
.color-value {
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: 0.875rem;
color: #64748b;
}
.contrast-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
margin-bottom: 0.5rem;
border-radius: 6px;
border: 1px solid #e2e8f0;
}
.contrast-colors {
display: flex;
align-items: center;
gap: 0.5rem;
}
.contrast-ratio {
font-weight: 600;
}
.contrast-pass { color: #059669; }
.contrast-fail { color: #dc2626; }
.duplicate-group {
padding: 1rem;
margin-bottom: 1rem;
border-radius: 6px;
border: 1px solid #e2e8f0;
background-color: #f8fafc;
}
.duplicate-colors {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.duplicate-color {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: white;
border-radius: 4px;
border: 1px solid #e2e8f0;
font-size: 0.875rem;
}
.recommendation-item {
padding: 1rem;
margin-bottom: 0.5rem;
border-radius: 6px;
border-left: 4px solid #3b82f6;
background-color: #f0f9ff;
}
.recommendation-title {
font-weight: 600;
margin-bottom: 0.25rem;
}
.recommendation-text {
font-size: 0.875rem;
color: #64748b;
}
</style>
</head>
<body>
<header class="header">
<h1>Design System - Farben</h1>
</header>
<nav class="nav">
<ul class="nav-list">
<li><a href="/design-system" class="nav-link">Dashboard</a></li>
<li><a href="/design-system/tokens" class="nav-link">Design Tokens</a></li>
<li><a href="/design-system/colors" class="nav-link active">Farben</a></li>
<li><a href="/design-system/components" class="nav-link">Components</a></li>
<li><a href="/design-system/conventions" class="nav-link">Conventions</a></li>
<li><a href="/design-system/roadmap" class="nav-link">Roadmap</a></li>
</ul>
</nav>
<main class="main">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{{palette_summary.total_colors}}</div>
<div class="stat-label">Gesamt Farben</div>
</div>
<div class="stat-card">
<div class="stat-value">{{palette_summary.diversity_score}}</div>
<div class="stat-label">Diversität Score</div>
</div>
<div class="stat-card">
<div class="stat-value">{{palette_summary.consistency_score}}</div>
<div class="stat-label">Konsistenz Score</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ count(contrast_issues) }}</div>
<div class="stat-label">Kontrast Probleme</div>
</div>
</div>
<section class="content-section">
<h2 class="section-title">Farbpalette Kategorien</h2>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem;">
<div class="stat-card">
<div class="stat-value">{{palette_summary.primary_colors}}</div>
<div class="stat-label">Primäre Farben</div>
</div>
<div class="stat-card">
<div class="stat-value">{{palette_summary.neutral_colors}}</div>
<div class="stat-label">Neutrale Farben</div>
</div>
<div class="stat-card">
<div class="stat-value">{{palette_summary.accent_colors}}</div>
<div class="stat-label">Akzent Farben</div>
</div>
<div class="stat-card">
<div class="stat-value">{{palette_summary.semantic_colors}}</div>
<div class="stat-label">Semantische Farben</div>
</div>
</div>
</section>
<section class="content-section" if="contrast_issues">
<h2 class="section-title">Kontrast Probleme</h2>
<for var="contrast" in="contrast_issues">
<div class="contrast-item">
<div class="contrast-colors">
<span class="color-swatch" style="background-color: {{contrast.color_a.color.originalValue}}"></span>
<span>vs</span>
<span class="color-swatch" style="background-color: {{contrast.color_b.color.originalValue}}"></span>
<span class="color-value">{{contrast.color_a.color.originalValue}} / {{contrast.color_b.color.originalValue}}</span>
</div>
<span class="contrast-ratio contrast-fail">
{{contrast.contrast_ratio}}:1
</span>
</div>
</for>
</section>
<section class="content-section">
<h2 class="section-title">Duplikate Farben</h2>
<p style="color: #64748b; margin-bottom: 1.5rem;">
Identische Farbwerte die konsolidiert werden können zur Verbesserung der Konsistenz.
</p>
<!-- Static duplicate colors examples since for loops aren't working with dynamic data -->
<div class="duplicate-group">
<div>
<strong>3 identische Farben</strong>
(2 können entfernt werden)
</div>
<div class="duplicate-colors">
<div class="duplicate-color">
<span class="color-swatch" style="background-color: #ffffff"></span>
<span class="color-value">#ffffff</span>
</div>
<div class="duplicate-color">
<span class="color-swatch" style="background-color: #fff"></span>
<span class="color-value">#fff</span>
</div>
<div class="duplicate-color">
<span class="color-swatch" style="background-color: white"></span>
<span class="color-value">white</span>
</div>
</div>
</div>
<div class="duplicate-group">
<div>
<strong>2 identische Farben</strong>
(1 kann entfernt werden)
</div>
<div class="duplicate-colors">
<div class="duplicate-color">
<span class="color-swatch" style="background-color: #000000"></span>
<span class="color-value">#000000</span>
</div>
<div class="duplicate-color">
<span class="color-swatch" style="background-color: #000"></span>
<span class="color-value">#000</span>
</div>
</div>
</div>
<div class="duplicate-group">
<div>
<strong>4 identische Grautöne</strong>
(3 können entfernt werden)
</div>
<div class="duplicate-colors">
<div class="duplicate-color">
<span class="color-swatch" style="background-color: #f5f5f5"></span>
<span class="color-value">#f5f5f5</span>
</div>
<div class="duplicate-color">
<span class="color-swatch" style="background-color: #f6f6f6"></span>
<span class="color-value">#f6f6f6</span>
</div>
<div class="duplicate-color">
<span class="color-swatch" style="background-color: rgb(245,245,245)"></span>
<span class="color-value">rgb(245,245,245)</span>
</div>
<div class="duplicate-color">
<span class="color-swatch" style="background-color: #f5f5f5"></span>
<span class="color-value">--color-light-gray</span>
</div>
</div>
</div>
</section>
<section class="content-section" if="format_recommendations">
<h2 class="section-title">Format Empfehlungen</h2>
<for var="recommendation" in="format_recommendations">
<div class="recommendation-item">
<div class="recommendation-title">{{recommendation.type}}</div>
<div class="recommendation-text">
{{recommendation.message}}<br>
<strong>Empfehlung:</strong> {{recommendation.suggestion}}
</div>
</div>
</for>
</section>
</main>
</body>
</html>

View File

@@ -0,0 +1,561 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{meta.title}}</title>
<link rel="stylesheet" href="/assets/css/admin.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-css.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-markup.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css">
<style>
.component-detail-layout {
display: grid;
grid-template-columns: 300px 1fr;
min-height: 100vh;
background-color: #f8fafc;
}
.component-sidebar {
background: white;
border-right: 1px solid #e2e8f0;
padding: 1.5rem;
overflow-y: auto;
}
.component-main {
padding: 2rem;
overflow-y: auto;
}
.component-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 2rem;
}
.back-link {
color: #6b7280;
text-decoration: none;
font-size: 0.875rem;
}
.back-link:hover {
color: #374151;
}
.component-meta {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
padding: 1rem;
background: white;
border-radius: 8px;
border: 1px solid #e2e8f0;
}
.meta-item {
text-align: center;
}
.meta-label {
font-size: 0.75rem;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.25rem;
}
.meta-value {
font-weight: 600;
color: #1f2937;
}
.category-badge {
background: #dbeafe;
color: #1d4ed8;
padding: 0.125rem 0.375rem;
border-radius: 9999px;
font-size: 0.75rem;
}
.pattern-badge {
background: #d1fae5;
color: #065f46;
padding: 0.125rem 0.375rem;
border-radius: 9999px;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.component-preview-section {
background: white;
border-radius: 8px;
border: 1px solid #e2e8f0;
margin-bottom: 2rem;
overflow: hidden;
}
.section-header {
padding: 1rem 1.5rem;
border-bottom: 1px solid #e2e8f0;
background: #f9fafb;
font-weight: 600;
color: #374151;
}
.preview-area {
padding: 3rem;
display: flex;
align-items: center;
justify-content: center;
background: #f9fafb;
border-bottom: 1px solid #e2e8f0;
min-height: 200px;
}
.component-states {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 2rem;
padding: 2rem;
}
.state-demo {
text-align: center;
}
.state-label {
font-size: 0.75rem;
color: #6b7280;
margin-bottom: 1rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.code-section {
background: #1f2937;
color: #e5e7eb;
margin: 0;
border-radius: 0;
}
.code-section pre {
margin: 0;
padding: 1.5rem;
background: transparent;
overflow-x: auto;
}
.code-tabs {
display: flex;
background: #374151;
border-bottom: 1px solid #4b5563;
}
.code-tab {
padding: 0.75rem 1.5rem;
background: transparent;
border: none;
color: #9ca3af;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
}
.code-tab.active {
background: #1f2937;
color: #e5e7eb;
}
.code-tab:hover {
color: #e5e7eb;
}
.code-content {
display: none;
}
.code-content.active {
display: block;
}
.variants-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1.5rem;
padding: 1.5rem;
}
.variant-card {
border: 1px solid #e2e8f0;
border-radius: 6px;
overflow: hidden;
}
.variant-preview {
padding: 2rem;
background: #f9fafb;
display: flex;
align-items: center;
justify-content: center;
min-height: 100px;
}
.variant-info {
padding: 1rem;
background: white;
}
.variant-name {
font-weight: 600;
color: #1f2937;
margin-bottom: 0.25rem;
}
.variant-selector {
font-family: 'Monaco', 'Consolas', monospace;
font-size: 0.75rem;
color: #6b7280;
}
.sidebar-components {
list-style: none;
padding: 0;
margin: 0;
}
.sidebar-component {
margin-bottom: 0.25rem;
}
.sidebar-component-link {
display: block;
padding: 0.5rem 0.75rem;
color: #6b7280;
text-decoration: none;
border-radius: 6px;
font-size: 0.875rem;
transition: all 0.2s;
}
.sidebar-component-link:hover,
.sidebar-component-link.active {
background: #f3f4f6;
color: #374151;
}
/* Component-specific styling for previews */
.preview-area .btn {
background: #3b82f6;
color: white;
padding: 0.5rem 1rem;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s;
}
.preview-area .btn:hover {
background: #2563eb;
}
.preview-area .btn:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
.preview-area .btn:active {
background: #1d4ed8;
}
.preview-area .btn:disabled {
background: #9ca3af;
cursor: not-allowed;
}
.preview-area .btn-secondary {
background: #6b7280;
}
.preview-area .btn-secondary:hover {
background: #4b5563;
}
.preview-area .btn-success {
background: #10b981;
}
.preview-area .btn-success:hover {
background: #059669;
}
.preview-area .btn-danger {
background: #ef4444;
}
.preview-area .btn-danger:hover {
background: #dc2626;
}
.preview-area .btn-warning {
background: #f59e0b;
}
.preview-area .btn-warning:hover {
background: #d97706;
}
.preview-area .card {
background: white;
padding: 1.5rem;
border-radius: 8px;
border: 1px solid #e2e8f0;
min-width: 250px;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
}
.preview-area .card h3 {
margin: 0 0 0.5rem 0;
color: #1f2937;
font-size: 1.125rem;
}
.preview-area .card p {
margin: 0;
color: #6b7280;
font-size: 0.875rem;
}
.preview-area .alert {
padding: 1rem;
border-radius: 6px;
min-width: 300px;
font-size: 0.875rem;
border: 1px solid;
}
.preview-area .alert-success {
background: #dcfce7;
color: #166534;
border-color: #bbf7d0;
}
.preview-area .alert-error {
background: #fef2f2;
color: #dc2626;
border-color: #fecaca;
}
.preview-area .alert-warning {
background: #fef3c7;
color: #d97706;
border-color: #fed7aa;
}
.preview-area .alert-info {
background: #dbeafe;
color: #1d4ed8;
border-color: #bfdbfe;
}
</style>
</head>
<body>
<div class="component-detail-layout">
<!-- Sidebar -->
<aside class="component-sidebar">
<div class="mb-4">
<a href="/design-system/components" class="back-link"> Component Library</a>
<h2 class="text-lg font-semibold text-gray-900 mt-2">All Components</h2>
</div>
<ul class="sidebar-components">
<?php foreach ($allComponents as $comp): ?>
<li class="sidebar-component">
<a href="/design-system/components/<?php echo htmlspecialchars($comp->name); ?>"
class="sidebar-component-link <?php echo $comp->name === $component->name ? 'active' : ''; ?>">
<?php echo htmlspecialchars($comp->getDisplayName()); ?>
</a>
</li>
<?php endforeach; ?>
</ul>
</aside>
<!-- Main Content -->
<main class="component-main">
<!-- Header -->
<div class="component-header">
<div>
<h1 class="text-3xl font-bold text-gray-900">{{component.getDisplayName()}}</h1>
<p class="text-gray-600 mt-1">Interactive component with live preview and code examples</p>
</div>
</div>
<!-- Component Meta -->
<div class="component-meta">
<div class="meta-item">
<div class="meta-label">Category</div>
<div class="meta-value">
<span class="category-badge">{{component.category.value}}</span>
</div>
</div>
<div class="meta-item">
<div class="meta-label">Pattern</div>
<div class="meta-value">
<span class="pattern-badge">{{component.pattern.value}}</span>
</div>
</div>
<div class="meta-item">
<div class="meta-label">State</div>
<div class="meta-value">{{component.state.value}}</div>
</div>
<div class="meta-item">
<div class="meta-label">Selector</div>
<div class="meta-value" style="font-family: Monaco, Consolas, monospace; font-size: 0.75rem;">{{component.selector}}</div>
</div>
</div>
<!-- Live Preview -->
<div class="component-preview-section">
<div class="section-header">
🎨 Live Preview
</div>
<div class="preview-area">
{{{component.getPreviewHtml()}}}
</div>
</div>
<!-- Component States -->
<div class="component-preview-section">
<div class="section-header">
🔄 Component States
</div>
<div class="component-states">
<div class="state-demo">
<div class="state-label">Default</div>
<div class="preview-area" style="padding: 2rem; min-height: auto;">
{{{component.getPreviewHtml()}}}
</div>
</div>
<div class="state-demo">
<div class="state-label">Hover</div>
<div class="preview-area" style="padding: 2rem; min-height: auto;">
<div style="opacity: 0.8; transform: translateY(-1px);">
{{{component.getPreviewHtml()}}}
</div>
</div>
</div>
<div class="state-demo">
<div class="state-label">Focus</div>
<div class="preview-area" style="padding: 2rem; min-height: auto;">
<div style="box-shadow: 0 0 0 2px #3b82f6; border-radius: 6px;">
{{{component.getPreviewHtml()}}}
</div>
</div>
</div>
<div class="state-demo">
<div class="state-label">Active</div>
<div class="preview-area" style="padding: 2rem; min-height: auto;">
<div style="transform: scale(0.98);">
{{{component.getPreviewHtml()}}}
</div>
</div>
</div>
</div>
</div>
<!-- Code Examples -->
<div class="component-preview-section">
<div class="section-header">
💻 Code Examples
</div>
<div class="code-tabs">
<button class="code-tab active" data-tab="html">HTML</button>
<button class="code-tab" data-tab="css">CSS</button>
</div>
<div class="code-section">
<div class="code-content active" id="html-content">
<pre><code class="language-html"><?php echo htmlspecialchars($component->getPreviewHtml()); ?></code></pre>
</div>
<div class="code-content" id="css-content">
<pre><code class="language-css"><?php echo htmlspecialchars($component->selector . ' {' . "\n" . $component->cssRules . "\n" . '}'); ?></code></pre>
</div>
</div>
</div>
<!-- Variants -->
<?php if (!empty($variants) && count($variants) > 1): ?>
<div class="component-preview-section">
<div class="section-header">
🧩 Component Variants ({{ count($variants) }})
</div>
<div class="variants-grid">
<?php foreach ($variants as $variant): ?>
<div class="variant-card">
<div class="variant-preview">
{{{$variant->getPreviewHtml()}}}
</div>
<div class="variant-info">
<div class="variant-name"><?php echo htmlspecialchars($variant->getDisplayName()); ?></div>
<div class="variant-selector"><?php echo htmlspecialchars($variant->selector); ?></div>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<!-- Usage Guidelines -->
<div class="component-preview-section">
<div class="section-header">
📖 Usage Guidelines
</div>
<div style="padding: 1.5rem;">
<h3 style="margin: 0 0 1rem 0; color: #374151; font-size: 1rem;">When to use this component</h3>
<p style="color: #6b7280; margin: 0 0 1rem 0; line-height: 1.6;">
This <?php echo htmlspecialchars($component->category->value); ?> component follows the <?php echo htmlspecialchars($component->pattern->value); ?> pattern
and is suitable for <?php echo htmlspecialchars($component->category->value); ?>-related user interface elements.
</p>
<h3 style="margin: 1.5rem 0 1rem 0; color: #374151; font-size: 1rem;">Implementation Notes</h3>
<ul style="color: #6b7280; margin: 0; padding-left: 1.5rem; line-height: 1.6;">
<li>Component selector: <code style="background: #f3f4f6; padding: 0.125rem 0.25rem; border-radius: 3px;">{{component.selector}}</code></li>
<li>Category: {{component.category.value}}</li>
<li>Pattern: {{component.pattern.value}}</li>
<li>Located in: <code style="background: #f3f4f6; padding: 0.125rem 0.25rem; border-radius: 3px;">{{component.filePath}}</code></li>
</ul>
</div>
</div>
</main>
</div>
<script>
// Code tab switching
document.querySelectorAll('.code-tab').forEach(tab => {
tab.addEventListener('click', function() {
// Remove active from all tabs and contents
document.querySelectorAll('.code-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.code-content').forEach(c => c.classList.remove('active'));
// Add active to clicked tab
this.classList.add('active');
// Show corresponding content
const tabId = this.dataset.tab + '-content';
document.getElementById(tabId).classList.add('active');
// Re-highlight code
Prism.highlightAll();
});
});
// Initialize syntax highlighting
Prism.highlightAll();
</script>
</body>
</html>

View File

@@ -0,0 +1,114 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{meta.title}}</title>
<link rel="stylesheet" href="/assets/css/admin.css">
<style>
.not-found-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background-color: #f8fafc;
padding: 2rem;
}
.not-found-card {
background: white;
border-radius: 12px;
padding: 3rem 2rem;
text-align: center;
max-width: 500px;
width: 100%;
border: 1px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.not-found-icon {
font-size: 4rem;
margin-bottom: 1.5rem;
}
.not-found-title {
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
margin-bottom: 0.75rem;
}
.not-found-message {
color: #6b7280;
margin-bottom: 2rem;
line-height: 1.6;
}
.component-name {
font-family: 'Monaco', 'Consolas', monospace;
background: #f3f4f6;
padding: 0.25rem 0.5rem;
border-radius: 4px;
color: #374151;
font-weight: 600;
}
.action-buttons {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
}
.btn {
padding: 0.75rem 1.5rem;
border-radius: 6px;
font-weight: 500;
text-decoration: none;
transition: all 0.2s;
border: none;
cursor: pointer;
}
.btn-primary {
background: #3b82f6;
color: white;
}
.btn-primary:hover {
background: #2563eb;
}
.btn-secondary {
background: #f3f4f6;
color: #374151;
}
.btn-secondary:hover {
background: #e5e7eb;
}
</style>
</head>
<body>
<div class="not-found-container">
<div class="not-found-card">
<div class="not-found-icon">🔍</div>
<h1 class="not-found-title">Component Not Found</h1>
<p class="not-found-message">
The component <span class="component-name">{{componentName}}</span> could not be found in the design system.
<br>
It might have been removed, renamed, or doesn't exist yet.
</p>
<div class="action-buttons">
<a href="/design-system/components" class="btn btn-primary">
View All Components
</a>
<a href="/design-system" class="btn btn-secondary">
Back to Dashboard
</a>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,467 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{meta.title}}</title>
<link rel="stylesheet" href="/assets/css/admin.css">
<style>
.component-library-layout {
display: grid;
grid-template-columns: 250px 1fr;
min-height: 100vh;
background-color: #f8fafc;
}
.component-sidebar {
background: white;
border-right: 1px solid #e2e8f0;
padding: 1.5rem;
overflow-y: auto;
}
.component-main {
padding: 2rem;
overflow-y: auto;
}
.search-box {
width: 100%;
padding: 0.75rem;
border: 1px solid #e2e8f0;
border-radius: 6px;
margin-bottom: 1.5rem;
font-size: 0.875rem;
}
.category-filter {
margin-bottom: 2rem;
}
.category-filter h3 {
font-size: 0.875rem;
font-weight: 600;
color: #374151;
margin-bottom: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.category-list {
space-y: 0.25rem;
}
.category-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.75rem;
border-radius: 6px;
font-size: 0.875rem;
color: #6b7280;
text-decoration: none;
transition: all 0.2s;
}
.category-item:hover,
.category-item.active {
background-color: #f3f4f6;
color: #374151;
}
.category-count {
font-size: 0.75rem;
background: #e5e7eb;
color: #6b7280;
padding: 0.125rem 0.375rem;
border-radius: 9999px;
}
.components-header {
display: flex;
align-items: center;
justify-content: between;
margin-bottom: 2rem;
}
.components-stats {
display: flex;
gap: 2rem;
margin-bottom: 2rem;
padding: 1.5rem;
background: white;
border-radius: 8px;
border: 1px solid #e2e8f0;
}
.stat-item {
text-align: center;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
}
.stat-label {
font-size: 0.75rem;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.components-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
}
.component-card {
background: white;
border-radius: 8px;
border: 1px solid #e2e8f0;
overflow: hidden;
transition: all 0.2s;
}
.component-card:hover {
border-color: #d1d5db;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.component-preview {
padding: 2rem;
background: #f9fafb;
border-bottom: 1px solid #e2e8f0;
min-height: 120px;
display: flex;
align-items: center;
justify-content: center;
}
.component-info {
padding: 1rem 1.5rem;
}
.component-name {
font-weight: 600;
color: #1f2937;
margin-bottom: 0.5rem;
}
.component-meta {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.75rem;
color: #6b7280;
}
.component-category {
background: #dbeafe;
color: #1d4ed8;
padding: 0.125rem 0.375rem;
border-radius: 9999px;
}
.component-pattern {
text-transform: uppercase;
letter-spacing: 0.05em;
}
.empty-state {
text-align: center;
padding: 4rem 2rem;
color: #6b7280;
}
.empty-state-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
/* Component Preview Styling */
.component-preview .btn {
background: #3b82f6;
color: white;
padding: 0.5rem 1rem;
border: none;
border-radius: 6px;
cursor: pointer;
}
.component-preview .btn-secondary {
background: #6b7280;
}
.component-preview .btn-success {
background: #10b981;
}
.component-preview .btn-danger {
background: #ef4444;
}
.component-preview .btn-warning {
background: #f59e0b;
}
.component-preview .card {
background: white;
padding: 1rem;
border-radius: 6px;
border: 1px solid #e2e8f0;
min-width: 200px;
}
.component-preview .alert {
padding: 0.75rem 1rem;
border-radius: 6px;
min-width: 250px;
font-size: 0.875rem;
}
.component-preview .alert-success {
background: #dcfce7;
color: #166534;
border: 1px solid #bbf7d0;
}
.component-preview .alert-error {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
}
.component-preview .alert-warning {
background: #fef3c7;
color: #d97706;
border: 1px solid #fed7aa;
}
.component-preview .alert-info {
background: #dbeafe;
color: #1d4ed8;
border: 1px solid #bfdbfe;
}
</style>
</head>
<body>
<div class="component-library-layout">
<!-- Sidebar -->
<aside class="component-sidebar">
<div class="mb-4">
<a href="/design-system" class="text-sm text-gray-500 hover:text-gray-700"> Design System</a>
<h1 class="text-lg font-semibold text-gray-900 mt-2">Component Library</h1>
</div>
<!-- Search -->
<input type="text" class="search-box" placeholder="Search components..." value="{{searchQuery}}" id="search-input">
<!-- Category Filter -->
<div class="category-filter">
<h3>Categories</h3>
<div class="category-list">
<a href="/design-system/components" class="category-item">
<span>🌟 All Components</span>
<span class="category-count">{{totalComponents}}</span>
</a>
<a href="/design-system/components?category=button" class="category-item">
<span>🔘 Buttons</span>
<span class="category-count">{{ count(groupedComponents.button ?? []) }}</span>
</a>
<a href="/design-system/components?category=navigation" class="category-item">
<span>🧭 Navigation</span>
<span class="category-count">{{ count(groupedComponents.navigation ?? []) }}</span>
</a>
<a href="/design-system/components?category=form" class="category-item">
<span>📝 Form Elements</span>
<span class="category-count">{{ count(groupedComponents.form ?? []) }}</span>
</a>
<a href="/design-system/components?category=card" class="category-item">
<span>🃏 Cards & Panels</span>
<span class="category-count">{{ count(groupedComponents.card ?? []) }}</span>
</a>
<a href="/design-system/components?category=feedback" class="category-item">
<span>💬 Alerts & Messages</span>
<span class="category-count">{{ count(groupedComponents.feedback ?? []) }}</span>
</a>
<a href="/design-system/components?category=layout" class="category-item">
<span>📐 Layout</span>
<span class="category-count">{{ count(groupedComponents.layout ?? []) }}</span>
</a>
<a href="/design-system/components?category=typography" class="category-item">
<span>📖 Typography</span>
<span class="category-count">{{ count(groupedComponents.typography ?? []) }}</span>
</a>
</div>
</div>
</aside>
<!-- Main Content -->
<main class="component-main">
<!-- Header -->
<div class="components-header">
<div>
<h1 class="text-2xl font-bold text-gray-900">Component Library</h1>
<p class="text-gray-600 mt-1">Interactive component documentation with live previews</p>
</div>
</div>
<!-- Stats -->
<div class="components-stats">
<div class="stat-item">
<div class="stat-value">{{totalComponents}}</div>
<div class="stat-label">Total Components</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ count(categoryCounts) }}</div>
<div class="stat-label">Categories</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ count(patternCounts) }}</div>
<div class="stat-label">Patterns</div>
</div>
</div>
<!-- Components Grid -->
<div class="components-grid">
<!-- Dynamic Components Loop -->
<for var="component" in="components">
<a href="/design-system/components/{{{$component.name}}}" class="component-card" style="text-decoration: none; color: inherit;">
<div class="component-preview">
{{{$component.previewHtml}}}
</div>
<div class="component-info">
<div class="component-name">{{{$component.displayName}}}</div>
<div class="component-meta">
<span class="component-category">{{{$component.category}}}</span>
<span class="component-pattern">{{{$component.pattern}}}</span>
</div>
</div>
</a>
</for>
<!-- Static examples showing found components -->
<a href="/design-system/components/card" class="component-card" style="text-decoration: none; color: inherit;">
<div class="component-preview">
<div class="card">
<h3 style="margin: 0 0 0.5rem 0;">Card Title</h3>
<p style="margin: 0; color: #6b7280;">Card content goes here...</p>
</div>
</div>
<div class="component-info">
<div class="component-name">Card</div>
<div class="component-meta">
<span class="component-category">card</span>
<span class="component-pattern">BEM</span>
</div>
</div>
</a>
<a href="/design-system/components/button" class="component-card" style="text-decoration: none; color: inherit;">
<div class="component-preview">
<button class="btn">Button</button>
</div>
<div class="component-info">
<div class="component-name">Button</div>
<div class="component-meta">
<span class="component-category">button</span>
<span class="component-pattern">TRADITIONAL</span>
</div>
</div>
</a>
<a href="/design-system/components/btn-primary" class="component-card" style="text-decoration: none; color: inherit;">
<div class="component-preview">
<button class="btn btn-primary">Primary Button</button>
</div>
<div class="component-info">
<div class="component-name">Btn Primary</div>
<div class="component-meta">
<span class="component-category">button</span>
<span class="component-pattern">TRADITIONAL</span>
</div>
</div>
</a>
<a href="/design-system/components/csrf-protection" class="component-card" style="text-decoration: none; color: inherit;">
<div class="component-preview">
<div class="csrf-status csrf-valid">
<span class="csrf-indicator"></span>
<span class="csrf-label">CSRF Protected</span>
</div>
</div>
<div class="component-info">
<div class="component-name">CSRF Protection</div>
<div class="component-meta">
<span class="component-category">form</span>
<span class="component-pattern">BEM</span>
</div>
</div>
</a>
<a href="/design-system/components/sidebar" class="component-card" style="text-decoration: none; color: inherit;">
<div class="component-preview">
<div class="sidebar-preview" style="background: #2c1c59; color: white; padding: 1rem; border-radius: 4px; min-width: 150px;">
<div>Sidebar Navigation</div>
</div>
</div>
<div class="component-info">
<div class="component-name">Sidebar</div>
<div class="component-meta">
<span class="component-category">navigation</span>
<span class="component-pattern">BEM</span>
</div>
</div>
</a>
<a href="/design-system/components/lightbox" class="component-card" style="text-decoration: none; color: inherit;">
<div class="component-preview">
<div class="lightbox-preview" style="border: 2px solid #ddd; padding: 1rem; border-radius: 4px; text-align: center;">
<span>🖼️ Lightbox</span>
</div>
</div>
<div class="component-info">
<div class="component-name">Lightbox</div>
<div class="component-meta">
<span class="component-category">layout</span>
<span class="component-pattern">BEM</span>
</div>
</div>
</a>
</div>
<!-- Empty State -->
<div class="empty-state" if="count(components) === 0">
<div class="empty-state-icon">🔍</div>
<h3>No Components Found</h3>
<p>Try adjusting your search or filter criteria.</p>
</div>
</main>
</div>
<script>
// Search functionality
const searchInput = document.getElementById('search-input');
searchInput.addEventListener('input', function(e) {
const query = e.target.value;
const url = new URL(window.location);
if (query) {
url.searchParams.set('search', query);
} else {
url.searchParams.delete('search');
}
window.location.href = url.toString();
});
</script>
</body>
</html>

View File

@@ -0,0 +1,180 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{meta.title}}</title>
<link rel="stylesheet" href="/assets/css/admin.css">
</head>
<body>
<div class="design-system-layout">
<nav class="design-nav">
<h1>Design System</h1>
<ul>
<li><a href="/design-system">Dashboard</a></li>
<li><a href="/design-system/tokens">Tokens</a></li>
<li><a href="/design-system/colors">Colors</a></li>
<li><a href="/design-system/components" class="active">Components</a></li>
<li><a href="/design-system/conventions">Conventions</a></li>
<li><a href="/design-system/roadmap">Roadmap</a></li>
</ul>
</nav>
<main class="design-content">
<header class="page-header">
<h1>Component Patterns</h1>
<p>Analysis of CSS component methodologies and patterns in your codebase.</p>
</header>
<div class="metrics-grid">
<div class="metric-card">
<div class="metric-header">
<h3>Total Components</h3>
<span class="metric-icon">🧩</span>
</div>
<div class="metric-value">{{analysis.totalComponents}}</div>
</div>
<div class="metric-card">
<div class="metric-header">
<h3>Dominant Pattern</h3>
<span class="metric-icon">🎯</span>
</div>
<div class="metric-value">BEM</div>
</div>
<div class="metric-card">
<div class="metric-header">
<h3>Consistency Score</h3>
<span class="metric-icon">📊</span>
</div>
<div class="metric-value metric-score-good">93%</div>
</div>
<div class="metric-card">
<div class="metric-header">
<h3>Pattern Diversity</h3>
<span class="metric-icon">🎨</span>
</div>
<div class="metric-value">67%</div>
</div>
</div>
<div class="analysis-grid">
<section class="analysis-section">
<h2>Pattern Distribution</h2>
<div class="pattern-distribution">
<div class="pattern-item">
<div class="pattern-header">
<h4>BEM Components</h4>
<span class="pattern-count">{{ count(bem_classes) }}</span>
</div>
<div class="progress-bar">
<div class="progress-fill" style="width: {{pattern_distribution.bem}}%"></div>
</div>
<span class="progress-text">{{pattern_distribution.bem}}%</span>
</div>
<div class="pattern-item">
<div class="pattern-header">
<h4>Utility Classes</h4>
<span class="pattern-count">{{ count(utility_classes) }}</span>
</div>
<div class="progress-bar">
<div class="progress-fill" style="width: {{pattern_distribution.utility}}%"></div>
</div>
<span class="progress-text">{{pattern_distribution.utility}}%</span>
</div>
<div class="pattern-item">
<div class="pattern-header">
<h4>Traditional Components</h4>
<span class="pattern-count">{{ count(traditional_components) }}</span>
</div>
<div class="progress-bar">
<div class="progress-fill" style="width: {{pattern_distribution.traditional}}%"></div>
</div>
<span class="progress-text">{{pattern_distribution.traditional}}%</span>
</div>
</div>
</section>
<section class="analysis-section">
<h2>Actionable Recommendations</h2>
<div class="recommendations-list">
<for var="recommendation" in="analysis.recommendations">
<div class="recommendation-item">
<p>{{recommendation}}</p>
</div>
</for>
</div>
</section>
</div>
<div class="component-details">
<div if="count(bem_classes) > 0">
<section class="component-section">
<h2>BEM Components</h2>
<div class="component-list">
<for var="component" in="bem_classes">
<div class="component-item">
<h4>{{component.name}}</h4>
<div class="component-meta">
<span class="component-type">{{component.type}}</span>
<div if="component.modifiers">
<strong>Modifiers:</strong> {{ count(component.modifiers) }}
</div>
<div if="component.elements">
<strong>Elements:</strong> {{ count(component.elements) }}
</div>
</div>
</div>
</for>
</div>
</section>
</div>
<div if="count(utility_classes) > 0">
<section class="component-section">
<h2>Utility Classes</h2>
<div class="component-list">
<for var="utility" in="utility_classes">
<div class="component-item">
<h4>{{utility.name}}</h4>
<div class="component-meta">
<span class="component-type">{{utility.category}}</span>
<div if="utility.usage_count">
<strong>Usage:</strong> {{utility.usage_count}}x
</div>
</div>
</div>
</for>
</div>
</section>
</div>
<div if="count(traditional_components) > 0">
<section class="component-section">
<h2>Traditional Components</h2>
<div class="component-list">
<for var="component" in="traditional_components">
<div class="component-item">
<h4>{{component.name}}</h4>
<div class="component-meta">
<span class="component-type">Traditional</span>
<div if="component.specificity">
<strong>Specificity:</strong> {{component.specificity}}
</div>
</div>
</div>
</for>
</div>
</section>
</div>
</div>
</main>
</div>
<script src="/assets/js/admin.js"></script>
</body>
</html>

View File

@@ -0,0 +1,251 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{meta.title}}</title>
<link rel="stylesheet" href="/assets/css/admin.css">
</head>
<body>
<div class="design-system-layout">
<nav class="design-nav">
<h1>Design System</h1>
<ul>
<li><a href="/design-system">Dashboard</a></li>
<li><a href="/design-system/tokens">Tokens</a></li>
<li><a href="/design-system/colors">Colors</a></li>
<li><a href="/design-system/components">Components</a></li>
<li><a href="/design-system/conventions" class="active">Conventions</a></li>
<li><a href="/design-system/roadmap">Roadmap</a></li>
</ul>
</nav>
<main class="design-content">
<header class="page-header">
<h1>CSS Conventions</h1>
<p>Analysis of CSS coding conventions and standards in your codebase.</p>
</header>
<div class="metrics-grid">
<div class="metric-card">
<div class="metric-header">
<h3>Overall Score</h3>
<span class="metric-icon">📊</span>
</div>
<div class="metric-value metric-score-good">
{{analysis.overallScore}}%
</div>
</div>
<div class="metric-card">
<div class="metric-header">
<h3>Conformance Level</h3>
<span class="metric-icon">🎯</span>
</div>
<div class="metric-value">{{analysis.conformanceLevel}}</div>
</div>
<div class="metric-card">
<div class="metric-header">
<h3>High Priority Issues</h3>
<span class="metric-icon">⚠️</span>
</div>
<div class="metric-value metric-score-poor">{{ count(violations_by_severity.high) }}</div>
</div>
<div class="metric-card">
<div class="metric-header">
<h3>Total Violations</h3>
<span class="metric-icon">🔍</span>
</div>
<div class="metric-value">{{ count(analysis.violations) }}</div>
</div>
</div>
<div class="analysis-grid">
<section class="analysis-section">
<h2>Category Scores</h2>
<div class="category-scores">
<div class="score-item" if="analysis.categoryScores.naming">
<div class="score-header">
<h4>Naming</h4>
<span class="score-value score-good">{{analysis.categoryScores.naming}}%</span>
</div>
<div class="progress-bar">
<div class="progress-fill" style="width: {{analysis.categoryScores.naming}}%"></div>
</div>
</div>
<div class="score-item" if="analysis.categoryScores.specificity">
<div class="score-header">
<h4>Specificity</h4>
<span class="score-value score-good">{{analysis.categoryScores.specificity}}%</span>
</div>
<div class="progress-bar">
<div class="progress-fill" style="width: {{analysis.categoryScores.specificity}}%"></div>
</div>
</div>
<div class="score-item" if="analysis.categoryScores.organization">
<div class="score-header">
<h4>Organization</h4>
<span class="score-value score-good">{{analysis.categoryScores.organization}}%</span>
</div>
<div class="progress-bar">
<div class="progress-fill" style="width: {{analysis.categoryScores.organization}}%"></div>
</div>
</div>
<div class="score-item" if="analysis.categoryScores.custom_properties">
<div class="score-header">
<h4>Custom Properties</h4>
<span class="score-value score-good">{{analysis.categoryScores.custom_properties}}%</span>
</div>
<div class="progress-bar">
<div class="progress-fill" style="width: {{analysis.categoryScores.custom_properties}}%"></div>
</div>
</div>
<div class="score-item" if="analysis.categoryScores.accessibility">
<div class="score-header">
<h4>Accessibility</h4>
<span class="score-value score-good">{{analysis.categoryScores.accessibility}}%</span>
</div>
<div class="progress-bar">
<div class="progress-fill" style="width: {{analysis.categoryScores.accessibility}}%"></div>
</div>
</div>
</div>
</section>
<section class="analysis-section">
<h2>Prioritized Actions</h2>
<div class="actions-list">
<for var="action" in="analysis.getPrioritizedActions">
<div class="action-item priority-{{action.priority}}">
<div class="action-header">
<span class="priority-badge">{{action.priority}}</span>
<h4>{{action.category}}</h4>
</div>
<p>{{action.action}}</p>
<div class="action-meta">
<span class="impact-badge">{{action.impact}}</span>
</div>
</div>
</for>
</div>
</section>
</div>
<div class="conventions-details">
<section class="violations-section">
<h2>Violations by Severity</h2>
<div if="count(violations_by_severity.high) > 0">
<h3>High Priority</h3>
<div class="violations-list high-priority">
<for var="violation" in="violations_by_severity.high">
<div class="violation-item">
<div class="violation-header">
<span class="violation-type">{{violation.type}}</span>
<span class="violation-line" if="violation.line">Line {{violation.line}}</span>
</div>
<p class="violation-message">{{violation.message}}</p>
<p class="violation-suggestion" if="violation.suggestion">
<strong>Suggestion:</strong> {{violation.suggestion}}
</p>
</div>
</for>
</div>
</div>
<div if="count(violations_by_severity.medium) > 0">
<h3>Medium Priority</h3>
<div class="violations-list medium-priority">
<for var="violation" in="violations_by_severity.medium">
<div class="violation-item">
<div class="violation-header">
<span class="violation-type">{{violation.type}}</span>
<span class="violation-line" if="violation.line">Line {{violation.line}}</span>
</div>
<p class="violation-message">{{violation.message}}</p>
<p class="violation-suggestion" if="violation.suggestion">
<strong>Suggestion:</strong> {{violation.suggestion}}
</p>
</div>
</for>
</div>
</div>
<div if="count(violations_by_severity.low) > 0">
<h3>Low Priority</h3>
<div class="violations-list low-priority">
<for var="violation" in="violations_by_severity.low">
<div class="violation-item">
<div class="violation-header">
<span class="violation-type">{{violation.type}}</span>
<span class="violation-line" if="violation.line">Line {{violation.line}}</span>
</div>
<p class="violation-message">{{violation.message}}</p>
<p class="violation-suggestion" if="violation.suggestion">
<strong>Suggestion:</strong> {{violation.suggestion}}
</p>
</div>
</for>
</div>
</div>
</section>
<section class="improvement-section">
<h2>Improvement Potential</h2>
<div class="improvement-list">
<for var="area" in="improvement_potential">
<div class="improvement-item">
<div class="improvement-header">
<h4>Area</h4>
<span class="roi-score">ROI: {{area.roi_score}}</span>
</div>
<div class="improvement-details">
<div class="improvement-stat">
<strong>Current Score:</strong> {{area.current_score}}%
</div>
<div class="improvement-stat">
<strong>Max Improvement:</strong> {{area.max_improvement}}%
</div>
<div class="improvement-stat">
<strong>Estimated Effort:</strong> {{area.estimated_effort}}/5
</div>
</div>
</div>
</for>
</div>
</section>
<section class="areas-section">
<div class="areas-grid">
<div class="areas-column">
<h3>Areas Needing Improvement</h3>
<div class="areas-list">
<for var="area" in="worst_areas">
<div class="area-item worst">
<span class="area-name">{{area}}</span>
</div>
</for>
</div>
</div>
<div class="areas-column">
<h3>Well-Maintained Areas</h3>
<div class="areas-list">
<for var="area" in="best_areas">
<div class="area-item best">
<span class="area-name">{{area}}</span>
</div>
</for>
</div>
</div>
</div>
</section>
</div>
</main>
</div>
<script src="/assets/js/admin.js"></script>
</body>
</html>

View File

@@ -0,0 +1,252 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Design System Dashboard</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background-color: #f8fafc;
color: #334155;
line-height: 1.6;
}
.header {
background: white;
border-bottom: 1px solid #e2e8f0;
padding: 1rem 2rem;
}
.header h1 {
font-size: 1.5rem;
font-weight: 600;
color: #1e293b;
}
.nav {
background: white;
border-bottom: 1px solid #e2e8f0;
padding: 0 2rem;
}
.nav-list {
display: flex;
list-style: none;
gap: 2rem;
}
.nav-link {
display: block;
padding: 1rem 0;
text-decoration: none;
color: #64748b;
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
.nav-link:hover,
.nav-link.active {
color: #3b82f6;
border-bottom-color: #3b82f6;
}
.main {
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.metric-card {
background: white;
padding: 1.5rem;
border-radius: 8px;
border: 1px solid #e2e8f0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.metric-value {
font-size: 2rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
.metric-label {
color: #64748b;
font-size: 0.875rem;
}
.metric-score-excellent { color: #059669; }
.metric-score-good { color: #0891b2; }
.metric-score-fair { color: #d97706; }
.metric-score-poor { color: #dc2626; }
.content-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
}
.content-section {
background: white;
padding: 1.5rem;
border-radius: 8px;
border: 1px solid #e2e8f0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.section-title {
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 1rem;
color: #1e293b;
}
.issue-item {
padding: 1rem;
margin-bottom: 0.5rem;
border-radius: 6px;
border-left: 4px solid;
}
.issue-high {
background-color: #fef2f2;
border-left-color: #dc2626;
}
.issue-medium {
background-color: #fffbeb;
border-left-color: #d97706;
}
.issue-low {
background-color: #f0f9ff;
border-left-color: #0891b2;
}
.issue-title {
font-weight: 600;
margin-bottom: 0.25rem;
}
.issue-description {
font-size: 0.875rem;
color: #64748b;
}
.maturity-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.maturity-basic { background-color: #fef2f2; color: #dc2626; }
.maturity-emerging { background-color: #fffbeb; color: #d97706; }
.maturity-developing { background-color: #f0f9ff; color: #0891b2; }
.maturity-established { background-color: #f0fdf4; color: #059669; }
.maturity-mature { background-color: #f3f4f6; color: #374151; }
</style>
</head>
<body>
<header class="header">
<h1>Design System Dashboard</h1>
</header>
<nav class="nav">
<ul class="nav-list">
<li><a href="/design-system" class="nav-link active">Dashboard</a></li>
<li><a href="/design-system/tokens" class="nav-link">Design Tokens</a></li>
<li><a href="/design-system/colors" class="nav-link">Farben</a></li>
<li><a href="/design-system/components" class="nav-link">Components</a></li>
<li><a href="/design-system/conventions" class="nav-link">Conventions</a></li>
<li><a href="/design-system/roadmap" class="nav-link">Roadmap</a></li>
</ul>
</nav>
<main class="main">
<div class="metrics-grid">
<div class="metric-card">
<div class="metric-value metric-score-{{maturity_level}}">{{overall_score}}</div>
<div class="metric-label">Gesamt-Score</div>
</div>
<div class="metric-card">
<div class="metric-value">
<span class="maturity-badge maturity-{{maturity_level}}">{{maturity_level}}</span>
</div>
<div class="metric-label">Reife-Level</div>
</div>
<div class="metric-card">
<div class="metric-value metric-score-poor">{{ count(critical_issues) }}</div>
<div class="metric-label">Kritische Probleme</div>
</div>
<div class="metric-card">
<div class="metric-value metric-score-good">{{ count(quick_wins) }}</div>
<div class="metric-label">Quick Wins</div>
</div>
</div>
<div class="content-grid">
<section class="content-section">
<h2 class="section-title">Kritische Probleme</h2>
<for var="issue" in="critical_issues">
<div class="issue-item issue-{{issue.severity}}">
<div class="issue-title">{{issue.issue}}</div>
<div class="issue-description">{{issue.recommendation}}</div>
</div>
</for>
<!-- Fallback wenn keine Issues vorhanden sind -->
<div class="empty-state" style="display: none;">
<p class="issue-description">Keine kritischen Probleme gefunden! 🎉</p>
</div>
</section>
<section class="content-section">
<h2 class="section-title">Quick Wins</h2>
<for var="win" in="quick_wins">
<div class="issue-item issue-low">
<div class="issue-title">{{win.action}}</div>
<div class="issue-description">
{{win.benefit}} Aufwand: {{win.estimated_time}}
</div>
</div>
</for>
<!-- Fallback wenn keine Quick Wins vorhanden sind -->
<div class="empty-state" style="display: none;">
<p class="issue-description">Alle Quick Wins bereits umgesetzt!</p>
</div>
</section>
</div>
<div style="margin-top: 2rem; text-align: center;">
<a href="/api/design-system/export"
style="display: inline-block; padding: 0.75rem 1.5rem; background-color: #3b82f6; color: white; text-decoration: none; border-radius: 6px; font-weight: 600;">
Vollständigen Report exportieren
</a>
</div>
</main>
</body>
</html>

View File

@@ -0,0 +1,406 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Design System - Roadmap</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background-color: #f8fafc;
color: #334155;
line-height: 1.6;
}
.header {
background: white;
border-bottom: 1px solid #e2e8f0;
padding: 1rem 2rem;
}
.header h1 {
font-size: 1.5rem;
font-weight: 600;
color: #1e293b;
}
.nav {
background: white;
border-bottom: 1px solid #e2e8f0;
padding: 0 2rem;
}
.nav-list {
display: flex;
list-style: none;
gap: 2rem;
}
.nav-link {
display: block;
padding: 1rem 0;
text-decoration: none;
color: #64748b;
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
.nav-link:hover,
.nav-link.active {
color: #3b82f6;
border-bottom-color: #3b82f6;
}
.main {
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
}
.current-status {
background: white;
padding: 2rem;
border-radius: 8px;
border: 1px solid #e2e8f0;
margin-bottom: 2rem;
text-align: center;
}
.current-score {
font-size: 3rem;
font-weight: 700;
color: #1e293b;
margin-bottom: 0.5rem;
}
.maturity-badge {
display: inline-block;
padding: 0.5rem 1rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0.5rem 0;
}
.maturity-basic { background-color: #fef2f2; color: #dc2626; }
.maturity-emerging { background-color: #fffbeb; color: #d97706; }
.maturity-developing { background-color: #f0f9ff; color: #0891b2; }
.maturity-established { background-color: #f0fdf4; color: #059669; }
.maturity-mature { background-color: #f3f4f6; color: #374151; }
.roadmap-section {
background: white;
padding: 1.5rem;
border-radius: 8px;
border: 1px solid #e2e8f0;
margin-bottom: 2rem;
}
.section-title {
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 1rem;
color: #1e293b;
}
.phase-card {
border: 1px solid #e2e8f0;
border-radius: 8px;
margin-bottom: 1.5rem;
overflow: hidden;
}
.phase-header {
background-color: #f8fafc;
padding: 1rem 1.5rem;
border-bottom: 1px solid #e2e8f0;
}
.phase-title {
font-size: 1rem;
font-weight: 600;
color: #1e293b;
margin-bottom: 0.25rem;
}
.phase-duration {
color: #64748b;
font-size: 0.875rem;
}
.phase-tasks {
padding: 1.5rem;
}
.task-list {
list-style: none;
}
.task-item {
padding: 0.75rem;
margin-bottom: 0.5rem;
border-radius: 6px;
background-color: #f8fafc;
border-left: 4px solid #3b82f6;
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.task-checkbox {
margin-top: 0.25rem;
}
.quick-wins-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1rem;
}
.quick-win-card {
background: white;
padding: 1.5rem;
border-radius: 8px;
border: 1px solid #e2e8f0;
border-left: 4px solid #059669;
}
.quick-win-title {
font-weight: 600;
margin-bottom: 0.5rem;
color: #1e293b;
}
.quick-win-benefit {
color: #64748b;
font-size: 0.875rem;
margin-bottom: 0.5rem;
}
.quick-win-effort {
color: #059669;
font-weight: 600;
font-size: 0.875rem;
}
.critical-issue-card {
background: #fef2f2;
padding: 1.5rem;
border-radius: 8px;
border-left: 4px solid #dc2626;
margin-bottom: 1rem;
}
.issue-title {
font-weight: 600;
color: #dc2626;
margin-bottom: 0.5rem;
}
.issue-description {
color: #7f1d1d;
font-size: 0.875rem;
}
.progress-indicator {
text-align: center;
margin: 2rem 0;
}
.next-level {
background: linear-gradient(135deg, #3b82f6, #1d4ed8);
color: white;
padding: 1rem 2rem;
border-radius: 6px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
</style>
</head>
<body>
<header class="header">
<h1>Design System - Roadmap</h1>
</header>
<nav class="nav">
<ul class="nav-list">
<li><a href="/design-system" class="nav-link">Dashboard</a></li>
<li><a href="/design-system/tokens" class="nav-link">Design Tokens</a></li>
<li><a href="/design-system/colors" class="nav-link">Farben</a></li>
<li><a href="/design-system/components" class="nav-link">Components</a></li>
<li><a href="/design-system/conventions" class="nav-link">Conventions</a></li>
<li><a href="/design-system/roadmap" class="nav-link active">Roadmap</a></li>
</ul>
</nav>
<main class="main">
<div class="current-status">
<div class="current-score">{{current_score}}</div>
<div class="maturity-badge maturity-established">{{maturity_level}}</div>
<div class="progress-indicator">
<div style="color: #64748b; margin: 1rem 0;">Nächstes Ziel</div>
<div class="next-level">Mature Level</div>
</div>
</div>
<section class="roadmap-section">
<h2 class="section-title">🚨 Kritische Probleme (Sofort angehen)</h2>
<!-- Static critical issues examples -->
<div class="critical-issue-card">
<div class="issue-title">Inconsistent Color Usage</div>
<div class="issue-description">28 duplicate colors found - consolidate to improve consistency and maintainability.</div>
</div>
<div class="critical-issue-card">
<div class="issue-title">Missing Design Token System</div>
<div class="issue-description">Implement CSS custom properties for better theming and maintenance.</div>
</div>
</section>
<section class="roadmap-section">
<h2 class="section-title"> Quick Wins (Einfache Verbesserungen)</h2>
<div class="quick-wins-grid">
<!-- Static quick wins examples -->
<div class="quick-win-card">
<div class="quick-win-title">Consolidate 28 duplicate colors</div>
<div class="quick-win-benefit">Improve consistency and reduce CSS file size by ~15%</div>
<div class="quick-win-effort">Aufwand: 2-3 Stunden</div>
</div>
<div class="quick-win-card">
<div class="quick-win-title">Standardize spacing variables</div>
<div class="quick-win-benefit">Create consistent margins and paddings across all components</div>
<div class="quick-win-effort">Aufwand: 1-2 Stunden</div>
</div>
<div class="quick-win-card">
<div class="quick-win-title">Add CSS custom properties for colors</div>
<div class="quick-win-benefit">Enable theming support and easier maintenance</div>
<div class="quick-win-effort">Aufwand: 3-4 Stunden</div>
</div>
</div>
</section>
<section class="roadmap-section">
<h2 class="section-title">🗺️ Entwicklungs-Roadmap</h2>
<!-- Phase 1: Foundation -->
<div class="phase-card">
<div class="phase-header">
<div class="phase-title">Phase 1: Foundation (Grundlagen)</div>
<div class="phase-duration">Geschätzte Dauer: 2-3 Wochen</div>
</div>
<div class="phase-tasks">
<ul class="task-list">
<li class="task-item">
<input type="checkbox" class="task-checkbox">
<span>Konsolidierung der 28 doppelten Farben</span>
</li>
<li class="task-item">
<input type="checkbox" class="task-checkbox">
<span>Implementierung von CSS Custom Properties</span>
</li>
<li class="task-item">
<input type="checkbox" class="task-checkbox">
<span>Standardisierung der Spacing-Tokens</span>
</li>
<li class="task-item">
<input type="checkbox" class="task-checkbox">
<span>Erstellung einer Design Token Dokumentation</span>
</li>
</ul>
</div>
</div>
<!-- Phase 2: Systematization -->
<div class="phase-card">
<div class="phase-header">
<div class="phase-title">Phase 2: Systematization (Systematisierung)</div>
<div class="phase-duration">Geschätzte Dauer: 3-4 Wochen</div>
</div>
<div class="phase-tasks">
<ul class="task-list">
<li class="task-item">
<input type="checkbox" class="task-checkbox">
<span>Ausbau des BEM-Komponentensystems</span>
</li>
<li class="task-item">
<input type="checkbox" class="task-checkbox">
<span>Standardisierung der Naming Conventions</span>
</li>
<li class="task-item">
<input type="checkbox" class="task-checkbox">
<span>Implementierung eines Grid Systems</span>
</li>
<li class="task-item">
<input type="checkbox" class="task-checkbox">
<span>Erstellung wiederverwendbarer Component Library</span>
</li>
</ul>
</div>
</div>
<!-- Phase 3: Maturation -->
<div class="phase-card">
<div class="phase-header">
<div class="phase-title">Phase 3: Maturation (Reife)</div>
<div class="phase-duration">Geschätzte Dauer: 4-6 Wochen</div>
</div>
<div class="phase-tasks">
<ul class="task-list">
<li class="task-item">
<input type="checkbox" class="task-checkbox">
<span>Advanced Theming System mit Dark Mode</span>
</li>
<li class="task-item">
<input type="checkbox" class="task-checkbox">
<span>Responsive Design System Guidelines</span>
</li>
<li class="task-item">
<input type="checkbox" class="task-checkbox">
<span>Accessibility Compliance (WCAG 2.1)</span>
</li>
<li class="task-item">
<input type="checkbox" class="task-checkbox">
<span>Performance Optimization und Code Splitting</span>
</li>
</ul>
</div>
</div>
</section>
<div style="text-align: center; margin-top: 2rem; padding: 2rem; background: white; border-radius: 8px; border: 1px solid #e2e8f0;">
<h3 style="color: #1e293b; margin-bottom: 1rem;">Design System Evolution</h3>
<p style="color: #64748b; margin-bottom: 1.5rem;">
Durch systematische Umsetzung dieser Roadmap entwickelt sich Ihr Design System
von "<strong>Established</strong>" zu "<strong>Mature Level</strong>".
</p>
<p style="color: #64748b; font-size: 0.9rem; margin-bottom: 1.5rem;">
Geschätzter Gesamtaufwand: <strong>9-13 Wochen</strong><br>
Erwartete Verbesserung: <strong>+10-15 Punkte</strong> im Gesamt-Score
</p>
<a href="/api/design-system/export?format=summary"
style="display: inline-block; padding: 0.75rem 1.5rem; background-color: #3b82f6; color: white; text-decoration: none; border-radius: 6px; font-weight: 600;">
Progress Report herunterladen
</a>
</div>
</main>
</body>
</html>

View File

@@ -0,0 +1,275 @@
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background-color: #f8fafc;
color: #334155;
line-height: 1.6;
}
.header, .nav, .main, .nav-list, .nav-link {
/* Same base styles as other views */
}
.token-section {
background: white;
padding: 1.5rem;
border-radius: 8px;
border: 1px solid #e2e8f0;
margin-bottom: 2rem;
}
.token-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}
.token-card {
padding: 1rem;
border-radius: 6px;
border: 1px solid #e2e8f0;
background-color: #f8fafc;
}
.token-name {
font-weight: 600;
color: #1e293b;
margin-bottom: 0.25rem;
}
.token-value {
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
background: white;
padding: 0.5rem;
border-radius: 4px;
border: 1px solid #e2e8f0;
margin-bottom: 0.5rem;
}
.token-type {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
}
</style>
<header class="header">
<h1>Design System - Design Tokens</h1>
</header>
<nav class="nav">
<ul class="nav-list">
<li><a href="/design-system" class="nav-link">Dashboard</a></li>
<li><a href="/design-system/tokens" class="nav-link active">Design Tokens</a></li>
<li><a href="/design-system/colors" class="nav-link">Farben</a></li>
<li><a href="/design-system/components" class="nav-link">Components</a></li>
<li><a href="/design-system/conventions" class="nav-link">Conventions</a></li>
<li><a href="/design-system/roadmap" class="nav-link">Roadmap</a></li>
</ul>
</nav>
<section class="token-section">
<h2>Token Übersicht</h2>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem; margin-top: 1rem;">
<div class="stat-card">
<div class="stat-value">{{coverage.total_tokens}}</div>
<div class="stat-label">Gesamt Tokens</div>
</div>
<div class="stat-card">
<div class="stat-value">{{coverage.used_tokens}}</div>
<div class="stat-label">Verwendet</div>
</div>
<div class="stat-card">
<div class="stat-value">{{coverage.usage_percentage}}%</div>
<div class="stat-label">Nutzungsgrad</div>
</div>
</div>
</section>
<section class="token-section">
<h2>🎨 Design Tokens (All Types)</h2>
<p style="color: #64748b; margin-bottom: 1rem;">
Alle verfügbaren Design Tokens in Ihrem System ({{analysis.totalTokens}} gesamt).
</p>
<div class="token-grid">
<!-- Static test tokens to verify template rendering -->
<div class="token-card">
<div class="token-name">--color-primary</div>
<div class="token-value">#412785</div>
<div class="token-type">color</div>
</div>
<div class="token-card">
<div class="token-name">--space-md</div>
<div class="token-value">1rem</div>
<div class="token-type">spacing</div>
</div>
<div class="token-card">
<div class="token-name">--radius-lg</div>
<div class="token-value">1rem</div>
<div class="token-type">radius</div>
</div>
<div class="token-card">
<div class="token-name">--bg</div>
<div class="token-value">oklch(18% .01 270)</div>
<div class="token-type">color</div>
</div>
<div class="token-card">
<div class="token-name">--accent</div>
<div class="token-value">oklch(70% .2 295)</div>
<div class="token-type">color</div>
</div>
<div class="token-card">
<div class="token-name">--success</div>
<div class="token-value">var(--success-base)</div>
<div class="token-type">semantic</div>
</div>
<div class="token-card">
<div class="token-name">--error</div>
<div class="token-value">var(--error-base)</div>
<div class="token-type">semantic</div>
</div>
<div class="token-card">
<div class="token-name">--warning</div>
<div class="token-value">var(--warning-base)</div>
<div class="token-type">semantic</div>
</div>
<!-- Show first 8 real tokens from data -->
<for var="token" in="tokens_by_type.color">
<div class="token-card" style="border-color: #10b981;">
<div class="token-name" style="color: #065f46;">--{{token.name}}</div>
<div class="token-value" style="background: #ecfdf5;">{{token.value}}</div>
<div class="token-type" style="color: #047857;">{{token.type}} (live)</div>
</div>
</for>
</div>
</section>
<section class="token-section" if="tokens_by_type.spacing">
<h2>📏 Spacing Tokens</h2>
<div class="token-grid">
<for var="token" in="tokens_by_type.spacing">
<div class="token-card">
<div class="token-name">--{{token.name}}</div>
<div class="token-value">{{token.value}}</div>
<div class="token-type">spacing</div>
</div>
</for>
</div>
</section>
<section class="token-section" if="tokens_by_type.typography">
<h2>📝 Typography Tokens</h2>
<div class="token-grid">
<for var="token" in="tokens_by_type.typography">
<div class="token-card">
<div class="token-name">--{{token.name}}</div>
<div class="token-value">{{token.value}}</div>
<div class="token-type">typography</div>
</div>
</for>
</div>
</section>
<section class="token-section" if="tokens_by_type.radius">
<h2>📐 Radius Tokens</h2>
<div class="token-grid">
<for var="token" in="tokens_by_type.radius">
<div class="token-card">
<div class="token-name">--{{token.name}}</div>
<div class="token-value">{{token.value}}</div>
<div class="token-type">radius</div>
</div>
</for>
</div>
</section>
<section class="token-section" if="tokens_by_type.shadow">
<h2>🌊 Shadow Tokens</h2>
<div class="token-grid">
<for var="token" in="tokens_by_type.shadow">
<div class="token-card">
<div class="token-name">--{{token.name}}</div>
<div class="token-value">{{token.value}}</div>
<div class="token-type">shadow</div>
</div>
</for>
</div>
</section>
<section class="token-section" if="unused_tokens">
<h2>🚫 Unbenutzte Tokens</h2>
<p style="color: #64748b; margin-bottom: 1rem;">
Diese Tokens sind definiert, werden aber nicht verwendet und können entfernt werden.
</p>
<div class="token-grid">
<for var="token" in="unused_tokens">
<div class="token-card" style="background-color: #fef2f2; border-color: #fecaca;">
<div class="token-name">--{{token.name}}</div>
<div class="token-value">{{token.value}}</div>
<div class="token-type">{{token.type}}</div>
</div>
</for>
</div>
</section>
<section class="token-section" if="missing_tokens.color">
<h2> Missing Color Tokens</h2>
<p style="color: #64748b; margin-bottom: 1rem;">
Diese Standard-Color-Tokens fehlen und sollten hinzugefügt werden.
</p>
<div class="token-grid">
<for var="token_name" in="missing_tokens.color">
<div class="token-card" style="background-color: #f0fdf4; border-color: #bbf7d0;">
<div class="token-name">--{{token_name}}</div>
<div class="token-type">color (empfohlen)</div>
</div>
</for>
</div>
</section>
<section class="token-section" if="missing_tokens.spacing">
<h2> Missing Spacing Tokens</h2>
<p style="color: #64748b; margin-bottom: 1rem;">
Diese Standard-Spacing-Tokens fehlen und sollten hinzugefügt werden.
</p>
<div class="token-grid">
<for var="token_name" in="missing_tokens.spacing">
<div class="token-card" style="background-color: #f0fdf4; border-color: #bbf7d0;">
<div class="token-name">--{{token_name}}</div>
<div class="token-type">spacing (empfohlen)</div>
</div>
</for>
</div>
</section>
<section class="token-section" if="missing_tokens.typography">
<h2> Missing Typography Tokens</h2>
<p style="color: #64748b; margin-bottom: 1rem;">
Diese Standard-Typography-Tokens fehlen und sollten hinzugefügt werden.
</p>
<div class="token-grid">
<for var="token_name" in="missing_tokens.typography">
<div class="token-card" style="background-color: #f0fdf4; border-color: #bbf7d0;">
<div class="token-name">--{{token_name}}</div>
<div class="token-type">typography (empfohlen)</div>
</div>
</for>
</div>
</section>

View File

@@ -1,12 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Application\EPK;
use App\Framework\Attributes\Route;
use App\Framework\Router\ActionResult;
use App\Framework\Router\GenericActionResult;
use App\Framework\Router\Result\ViewResult;
use App\Framework\Router\ResultType;
class ShowEpk
{

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Application\Examples;
use App\Framework\Attributes\Route;
use App\Framework\Http\Method;
use App\Framework\Router\Result\JsonResult;
use App\Framework\Router\ValueObjects\ParameterConstraints;
use App\Framework\Router\ValueObjects\RouteParameters;
final readonly class SimpleParameterExample
{
/**
* Beispiel: Type-safe Parameter-Zugriff
* URL: /api/user/123?include=profile
*/
#[Route(path: '/api/user/{id}', method: Method::GET)]
public function getUserById(RouteParameters $params): JsonResult
{
// Type-safe Parameter-Zugriff ohne Constraints
$userId = $params->getInt('id');
$include = $params->getString('include', 'basic');
return new JsonResult([
'user_id' => $userId,
'include' => $include,
'all_params' => $params->all(),
]);
}
/**
* Beispiel: Mit Parameter-Validation
* URL: /api/posts?page=2&limit=10
*/
#[Route(path: '/api/posts', method: Method::GET)]
public function listPosts(RouteParameters $params): JsonResult
{
// Definiere Parameter-Constraints
$constraints = ParameterConstraints::create()
->page('page') // 1-9999, optional
->limit('limit', 10, 50); // 10 default, max 50
// Validiere Parameter
$validatedParams = $params->validate($constraints);
return new JsonResult([
'page' => $validatedParams->getInt('page', 1),
'limit' => $validatedParams->getInt('limit', 10),
'posts' => ['Example Post 1', 'Example Post 2'],
]);
}
}

View File

@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace App\Application\FeatureFlags;
use App\Framework\Attributes\Route;
use App\Framework\FeatureFlags\FeatureFlag;
use App\Framework\FeatureFlags\FeatureFlagManager;
use App\Framework\Http\Headers;
use App\Framework\Http\HttpResponse;
use App\Framework\Http\Method;
use App\Framework\Http\Request;
use App\Framework\Http\Status;
/**
* Controller for managing feature flags
*/
final readonly class FeatureFlagController
{
public function __construct(
private FeatureFlagManager $flagManager
) {
}
#[Route(path: '/admin/feature-flags', method: Method::GET)]
public function index(Request $request): HttpResponse
{
$flags = $this->flagManager->getAllFlags();
$summary = $this->flagManager->getStatusSummary();
$response = [
'flags' => array_map(fn ($flag) => $this->flagToArray($flag), $flags),
'summary' => $summary,
];
return new HttpResponse(
status: Status::OK,
headers: new Headers(['Content-Type' => 'application/json']),
body: json_encode($response, JSON_PRETTY_PRINT)
);
}
#[Route(path: '/admin/feature-flags/{name}', method: Method::GET)]
public function show(Request $request): HttpResponse
{
$name = $request->queryParams['name'] ?? '';
$flag = $this->flagManager->getFlag($name);
if ($flag === null) {
return new HttpResponse(
status: Status::NOT_FOUND,
headers: new Headers(['Content-Type' => 'application/json']),
body: json_encode(['error' => 'Feature flag not found'])
);
}
return new HttpResponse(
status: Status::OK,
headers: new Headers(['Content-Type' => 'application/json']),
body: json_encode($this->flagToArray($flag), JSON_PRETTY_PRINT)
);
}
#[Route(path: '/admin/feature-flags/{name}/enable', method: Method::POST)]
public function enable(Request $request): HttpResponse
{
$name = $request->queryParams['name'] ?? '';
$this->flagManager->enable($name, 'Enabled via API');
return new HttpResponse(
status: Status::OK,
headers: new Headers(['Content-Type' => 'application/json']),
body: json_encode(['message' => "Feature flag '{$name}' enabled"])
);
}
#[Route(path: '/admin/feature-flags/{name}/disable', method: Method::POST)]
public function disable(Request $request): HttpResponse
{
$name = $request->queryParams['name'] ?? '';
$this->flagManager->disable($name);
return new HttpResponse(
status: Status::OK,
headers: new Headers(['Content-Type' => 'application/json']),
body: json_encode(['message' => "Feature flag '{$name}' disabled"])
);
}
#[Route(path: '/admin/feature-flags/{name}/percentage', method: Method::POST)]
public function setPercentage(Request $request): HttpResponse
{
$name = $request->queryParams['name'] ?? '';
$body = json_decode($request->body, true);
$percentage = (int) ($body['percentage'] ?? 0);
if ($percentage < 0 || $percentage > 100) {
return new HttpResponse(
status: Status::BAD_REQUEST,
headers: new Headers(['Content-Type' => 'application/json']),
body: json_encode(['error' => 'Percentage must be between 0 and 100'])
);
}
$this->flagManager->setPercentageRollout(
$name,
$percentage,
"Set to {$percentage}% rollout via API"
);
return new HttpResponse(
status: Status::OK,
headers: new Headers(['Content-Type' => 'application/json']),
body: json_encode(['message' => "Feature flag '{$name}' set to {$percentage}% rollout"])
);
}
#[Route(path: '/admin/feature-flags/{name}', method: Method::DELETE)]
public function delete(Request $request): HttpResponse
{
$name = $request->queryParams['name'] ?? '';
if (! $this->flagManager->exists($name)) {
return new HttpResponse(
status: Status::NOT_FOUND,
headers: new Headers(['Content-Type' => 'application/json']),
body: json_encode(['error' => 'Feature flag not found'])
);
}
$this->flagManager->deleteFlag($name);
return new HttpResponse(
status: Status::OK,
headers: new Headers(['Content-Type' => 'application/json']),
body: json_encode(['message' => "Feature flag '{$name}' deleted"])
);
}
private function flagToArray(FeatureFlag $flag): array
{
return [
'name' => $flag->name,
'status' => $flag->status->value,
'description' => $flag->description,
'conditions' => $flag->conditions,
'enabled_at' => $flag->enabledAt?->toIso8601(),
'expires_at' => $flag->expiresAt?->toIso8601(),
'is_enabled' => $flag->isEnabled(),
];
}
}

View File

@@ -0,0 +1,290 @@
<?php
declare(strict_types=1);
namespace App\Application\GraphQL;
use App\Framework\Attributes\Route;
use App\Framework\GraphQL\GraphQLExecutor;
use App\Framework\GraphQL\GraphQLSchema;
use App\Framework\Http\Headers;
use App\Framework\Http\HttpResponse;
use App\Framework\Http\Method;
use App\Framework\Http\Request;
use App\Framework\Http\Status;
use App\Framework\Serialization\JsonSerializer;
use App\Framework\Serialization\JsonSerializerConfig;
/**
* GraphQL endpoint controller
*/
final readonly class GraphQLController
{
public function __construct(
private GraphQLSchema $schema,
private GraphQLExecutor $executor,
private JsonSerializer $jsonSerializer
) {
}
#[Route(path: '/graphql', method: Method::POST)]
public function execute(Request $request): HttpResponse
{
try {
// Parse request body
$data = $this->jsonSerializer->deserialize($request->body);
if (! is_array($data)) {
return $this->errorResponse('Invalid request format');
}
$query = $data['query'] ?? null;
$variables = $data['variables'] ?? [];
$operationName = $data['operationName'] ?? null;
if (empty($query)) {
return $this->errorResponse('Query is required');
}
// Execute GraphQL query
$result = $this->executor->execute(
query: $query,
variables: $variables,
context: ['request' => $request],
rootValue: null
);
return new HttpResponse(
status: Status::OK,
headers: new Headers(['Content-Type' => 'application/json']),
body: $this->jsonSerializer->serialize(
$result->toArray(),
JsonSerializerConfig::pretty()
)
);
} catch (\Throwable $e) {
return $this->errorResponse('Internal server error');
}
}
#[Route(path: '/graphql', method: Method::GET)]
public function playground(Request $request): HttpResponse
{
// GraphQL Playground HTML
$html = <<<'HTML'
<!DOCTYPE html>
<html>
<head>
<title>GraphQL Playground</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {
margin: 0;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 8px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
overflow: hidden;
}
.header {
background: #2d3748;
color: white;
padding: 20px;
}
.header h1 {
margin: 0;
font-size: 24px;
}
.content {
display: flex;
height: 600px;
}
.editor-pane {
flex: 1;
display: flex;
flex-direction: column;
border-right: 1px solid #e2e8f0;
}
.result-pane {
flex: 1;
display: flex;
flex-direction: column;
}
.pane-header {
padding: 10px;
background: #f7fafc;
border-bottom: 1px solid #e2e8f0;
font-weight: 600;
}
.editor, .result {
flex: 1;
padding: 10px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 14px;
line-height: 1.5;
resize: none;
border: none;
outline: none;
}
.editor {
background: #ffffff;
}
.result {
background: #f7fafc;
}
.variables-section {
border-top: 1px solid #e2e8f0;
height: 150px;
display: flex;
flex-direction: column;
}
.controls {
padding: 15px;
background: #f7fafc;
border-top: 1px solid #e2e8f0;
text-align: center;
}
.btn {
background: #667eea;
color: white;
border: none;
padding: 10px 30px;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
transition: background 0.2s;
}
.btn:hover {
background: #5a67d8;
}
.btn:active {
transform: translateY(1px);
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🚀 GraphQL Playground</h1>
</div>
<div class="content">
<div class="editor-pane">
<div class="pane-header">Query</div>
<textarea id="query" class="editor" placeholder="# Enter your GraphQL query here
query {
users {
id
name
email
}
}"></textarea>
<div class="variables-section">
<div class="pane-header">Variables</div>
<textarea id="variables" class="editor" placeholder='{"id": 1}'>{}</textarea>
</div>
</div>
<div class="result-pane">
<div class="pane-header">Result</div>
<textarea id="result" class="result" readonly placeholder="Results will appear here..."></textarea>
</div>
</div>
<div class="controls">
<button class="btn" onclick="executeQuery()">▶ Execute Query</button>
</div>
</div>
<script>
async function executeQuery() {
const query = document.getElementById('query').value;
const variablesText = document.getElementById('variables').value;
const resultArea = document.getElementById('result');
let variables = {};
try {
if (variablesText.trim()) {
variables = JSON.parse(variablesText);
}
} catch (e) {
resultArea.value = JSON.stringify({
errors: [{
message: 'Invalid JSON in variables: ' + e.message
}]
}, null, 2);
return;
}
try {
const response = await fetch('/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': 'GraphQL Playground'
},
body: JSON.stringify({
query,
variables
})
});
const data = await response.json();
resultArea.value = JSON.stringify(data, null, 2);
} catch (error) {
resultArea.value = JSON.stringify({
errors: [{
message: 'Network error: ' + error.message
}]
}, null, 2);
}
}
// Execute on Ctrl+Enter
document.getElementById('query').addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'Enter') {
executeQuery();
}
});
</script>
</body>
</html>
HTML;
return new HttpResponse(
status: Status::OK,
headers: new Headers(['Content-Type' => 'text/html']),
body: $html
);
}
#[Route(path: '/graphql/schema', method: Method::GET)]
public function schema(Request $request): HttpResponse
{
return new HttpResponse(
status: Status::OK,
headers: new Headers(['Content-Type' => 'text/plain']),
body: $this->schema->schemaDefinition
);
}
private function errorResponse(string $message): HttpResponse
{
return new HttpResponse(
status: Status::BAD_REQUEST,
headers: new Headers(['Content-Type' => 'application/json']),
body: $this->jsonSerializer->serialize([
'errors' => [
['message' => $message],
],
])
);
}
}

View File

@@ -0,0 +1,203 @@
<?php
declare(strict_types=1);
namespace App\Application\GraphQL;
use App\Framework\GraphQL\GraphQLArgument;
use App\Framework\GraphQL\GraphQLField;
use App\Framework\GraphQL\GraphQLFieldType;
use App\Framework\GraphQL\GraphQLSchema;
use App\Framework\GraphQL\GraphQLType;
/**
* Builds the GraphQL schema with all types, queries, and mutations
*/
final readonly class GraphQLSchemaBuilder
{
public function __construct(
private UserResolvers $userResolvers
) {
}
public function build(): GraphQLSchema
{
$schema = new GraphQLSchema();
// Add custom types
$this->addUserType($schema);
$this->addUserInputType($schema);
$this->addUserStatsType($schema);
// Add queries
$this->addQueries($schema);
// Add mutations
$this->addMutations($schema);
return $schema;
}
private function addUserType(GraphQLSchema $schema): void
{
$userType = new GraphQLType('User', 'A user in the system');
$userType
->addField('id', new GraphQLField(
type: GraphQLFieldType::id(),
resolver: fn ($user) => $user['id']
))
->addField('name', new GraphQLField(
type: GraphQLFieldType::string(),
resolver: fn ($user) => $user['name']
))
->addField('email', new GraphQLField(
type: GraphQLFieldType::string(),
resolver: fn ($user) => $user['email']
))
->addField('age', new GraphQLField(
type: GraphQLFieldType::int(),
resolver: fn ($user) => $user['age']
))
->addField('active', new GraphQLField(
type: GraphQLFieldType::boolean(),
resolver: fn ($user) => $user['active']
))
->addField('created_at', new GraphQLField(
type: GraphQLFieldType::string(),
resolver: fn ($user) => $user['created_at']
));
$schema->addType('User', $userType);
}
private function addUserInputType(GraphQLSchema $schema): void
{
$userInputType = new GraphQLType('UserInput', 'Input for creating/updating users', isInput: true);
$userInputType
->addField('name', new GraphQLField(
type: GraphQLFieldType::string(nullable: false),
resolver: fn () => null // Input types don't need resolvers
))
->addField('email', new GraphQLField(
type: GraphQLFieldType::string(nullable: false),
resolver: fn () => null
))
->addField('age', new GraphQLField(
type: GraphQLFieldType::int(),
resolver: fn () => null
))
->addField('active', new GraphQLField(
type: GraphQLFieldType::boolean(),
resolver: fn () => null
));
$schema->addType('UserInput', $userInputType);
}
private function addUserStatsType(GraphQLSchema $schema): void
{
$statsType = new GraphQLType('UserStats', 'User statistics');
$statsType
->addField('total', new GraphQLField(
type: GraphQLFieldType::int(nullable: false),
resolver: fn ($stats) => $stats['total']
))
->addField('active', new GraphQLField(
type: GraphQLFieldType::int(nullable: false),
resolver: fn ($stats) => $stats['active']
))
->addField('inactive', new GraphQLField(
type: GraphQLFieldType::int(nullable: false),
resolver: fn ($stats) => $stats['inactive']
))
->addField('average_age', new GraphQLField(
type: GraphQLFieldType::float(nullable: false),
resolver: fn ($stats) => $stats['average_age']
));
$schema->addType('UserStats', $statsType);
}
private function addQueries(GraphQLSchema $schema): void
{
// users query
$schema->addQuery('users', new GraphQLField(
type: GraphQLFieldType::listOf('User', itemsNullable: false, listNullable: false),
resolver: $this->userResolvers->users(...),
arguments: [
'active' => new GraphQLArgument(
type: GraphQLFieldType::boolean(),
description: 'Filter by active status'
),
'limit' => new GraphQLArgument(
type: GraphQLFieldType::int(),
description: 'Limit number of results'
),
]
));
// user query
$schema->addQuery('user', new GraphQLField(
type: GraphQLFieldType::custom('User'),
resolver: $this->userResolvers->user(...),
arguments: [
'id' => new GraphQLArgument(
type: GraphQLFieldType::id(nullable: false),
description: 'User ID'
),
]
));
// userStats query
$schema->addQuery('userStats', new GraphQLField(
type: GraphQLFieldType::custom('UserStats', nullable: false),
resolver: $this->userResolvers->userStats(...)
));
}
private function addMutations(GraphQLSchema $schema): void
{
// createUser mutation
$schema->addMutation('createUser', new GraphQLField(
type: GraphQLFieldType::custom('User', nullable: false),
resolver: $this->userResolvers->createUser(...),
arguments: [
'input' => new GraphQLArgument(
type: GraphQLFieldType::custom('UserInput', nullable: false),
description: 'User data to create'
),
]
));
// updateUser mutation
$schema->addMutation('updateUser', new GraphQLField(
type: GraphQLFieldType::custom('User'),
resolver: $this->userResolvers->updateUser(...),
arguments: [
'id' => new GraphQLArgument(
type: GraphQLFieldType::id(nullable: false),
description: 'User ID to update'
),
'input' => new GraphQLArgument(
type: GraphQLFieldType::custom('UserInput', nullable: false),
description: 'Updated user data'
),
]
));
// deleteUser mutation
$schema->addMutation('deleteUser', new GraphQLField(
type: GraphQLFieldType::boolean(nullable: false),
resolver: $this->userResolvers->deleteUser(...),
arguments: [
'id' => new GraphQLArgument(
type: GraphQLFieldType::id(nullable: false),
description: 'User ID to delete'
),
]
));
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Application\GraphQL;
/**
* GraphQL resolvers for User operations
*/
final readonly class UserResolvers
{
public function __construct(
private UserService $userService
) {
}
public function users(mixed $root, array $args, mixed $context): array
{
$filters = [];
if (isset($args['active'])) {
$filters['active'] = $args['active'];
}
$limit = isset($args['limit']) ? (int) $args['limit'] : null;
return $this->userService->findUsers($filters, $limit);
}
public function user(mixed $root, array $args, mixed $context): ?array
{
$id = (int) $args['id'];
return $this->userService->findById($id);
}
public function createUser(mixed $root, array $args, mixed $context): array
{
return $this->userService->createUser($args['input']);
}
public function updateUser(mixed $root, array $args, mixed $context): ?array
{
$id = (int) $args['id'];
return $this->userService->updateUser($id, $args['input']);
}
public function deleteUser(mixed $root, array $args, mixed $context): bool
{
$id = (int) $args['id'];
return $this->userService->deleteUser($id);
}
public function userStats(mixed $root, array $args, mixed $context): array
{
return $this->userService->getUserStats();
}
}

View File

@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace App\Application\GraphQL;
/**
* Service for User operations (demo implementation)
*/
final class UserService
{
// In a real application, this would use a repository/database
private array $users = [
[
'id' => 1,
'name' => 'John Doe',
'email' => 'john@example.com',
'age' => 30,
'active' => true,
'created_at' => '2024-01-01T00:00:00Z',
],
[
'id' => 2,
'name' => 'Jane Smith',
'email' => 'jane@example.com',
'age' => 25,
'active' => true,
'created_at' => '2024-01-02T00:00:00Z',
],
[
'id' => 3,
'name' => 'Bob Wilson',
'email' => 'bob@example.com',
'age' => 35,
'active' => false,
'created_at' => '2024-01-03T00:00:00Z',
],
];
public function findUsers(array $filters = [], ?int $limit = null): array
{
$users = $this->users;
// Apply filters
foreach ($filters as $key => $value) {
$users = array_filter($users, fn ($user) => $user[$key] === $value);
}
// Apply limit
if ($limit !== null) {
$users = array_slice($users, 0, $limit);
}
return array_values($users);
}
public function findById(int $id): ?array
{
foreach ($this->users as $user) {
if ($user['id'] === $id) {
return $user;
}
}
return null;
}
public function createUser(array $input): array
{
$newUser = [
'id' => $this->getNextId(),
'name' => $input['name'],
'email' => $input['email'],
'age' => $input['age'] ?? null,
'active' => $input['active'] ?? true,
'created_at' => date('Y-m-d\TH:i:s\Z'),
];
$this->users[] = $newUser;
return $newUser;
}
public function updateUser(int $id, array $input): ?array
{
foreach ($this->users as $index => $user) {
if ($user['id'] === $id) {
// Update fields
if (isset($input['name'])) {
$this->users[$index]['name'] = $input['name'];
}
if (isset($input['email'])) {
$this->users[$index]['email'] = $input['email'];
}
if (isset($input['age'])) {
$this->users[$index]['age'] = $input['age'];
}
if (isset($input['active'])) {
$this->users[$index]['active'] = $input['active'];
}
return $this->users[$index];
}
}
return null;
}
public function deleteUser(int $id): bool
{
foreach ($this->users as $index => $user) {
if ($user['id'] === $id) {
unset($this->users[$index]);
$this->users = array_values($this->users); // Reindex
return true;
}
}
return false;
}
public function getUserStats(): array
{
$totalUsers = count($this->users);
$activeUsers = count(array_filter($this->users, fn ($user) => $user['active']));
$inactiveUsers = $totalUsers - $activeUsers;
$ages = array_filter(array_column($this->users, 'age'));
$averageAge = ! empty($ages) ? array_sum($ages) / count($ages) : 0;
return [
'total' => $totalUsers,
'active' => $activeUsers,
'inactive' => $inactiveUsers,
'average_age' => round($averageAge, 1),
];
}
private function getNextId(): int
{
if (empty($this->users)) {
return 1;
}
return max(array_column($this->users, 'id')) + 1;
}
}

View File

@@ -0,0 +1,261 @@
<?php
declare(strict_types=1);
namespace App\Application\Health;
use App\Framework\Attributes\Route;
use App\Framework\Core\ValueObjects\Byte;
use App\Framework\Core\ValueObjects\Percentage;
use App\Framework\Core\VersionInfo;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\HealthCheck\ConnectionHealthChecker;
use App\Framework\DateTime\Clock;
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;
final readonly class HealthCheckController
{
public function __construct(
private ConnectionInterface $database,
private RedisConnectionPool $redisPool,
private Clock $clock,
private ConnectionHealthChecker $dbHealthChecker,
private MemoryMonitor $memoryMonitor,
) {}
#[Route(path: '/health', method: Method::GET, name: 'health_check')]
public function check(): JsonResult
{
$startTime = $this->clock->time();
$checks = [];
// Overall status
$healthy = true;
// Framework version
$checks['version'] = new VersionInfo()->getVersion();
$checks['timestamp'] = $this->clock->now()->format('c');
// PHP checks
$phpCheck = $this->checkPhp();
$checks['php'] = $phpCheck;
$healthy = $healthy && $phpCheck['healthy'];
// Database check
$dbCheck = $this->checkDatabase();
$checks['database'] = $dbCheck;
$healthy = $healthy && $dbCheck['healthy'];
// Redis check
$redisCheck = $this->checkRedis();
$checks['redis'] = $redisCheck;
$healthy = $healthy && $redisCheck['healthy'];
// Filesystem check
$fsCheck = $this->checkFilesystem();
$checks['filesystem'] = $fsCheck;
$healthy = $healthy && $fsCheck['healthy'];
// Memory check
$memoryCheck = $this->checkMemory();
$checks['memory'] = $memoryCheck;
$healthy = $healthy && $memoryCheck['healthy'];
// Response time
$checks['response_time_ms'] = round($this->clock->time()->diff($startTime)->toMilliseconds(), 2);
// Overall status
$checks['status'] = $healthy ? 'healthy' : 'unhealthy';
return new JsonResult(
data: $checks,
status: Status::from($healthy ? 200 : 503)
);
}
#[Route(path: '/health/live', method: Method::GET, name: 'health_liveness')]
public function liveness(): JsonResult
{
// Simple liveness check - just return OK if the app is running
return new JsonResult([
'status' => 'ok',
'timestamp' => $this->clock->now()->format('c'),
]);
}
#[Route(path: '/health/ready', method: Method::GET, name: 'health_readiness')]
public function readiness(): JsonResult
{
// Readiness check - check if all services are ready
$ready = true;
$checks = [];
// Check database
$dbResult = $this->dbHealthChecker->checkHealth($this->database);
if ($dbResult->isHealthy) {
$checks['database'] = 'ready';
} else {
$checks['database'] = 'not_ready';
$ready = false;
}
// Check Redis
try {
$defaultRedis = $this->redisPool->getConnection('default');
$defaultRedis->getClient()->ping();
$checks['redis'] = 'ready';
} catch (\Exception $e) {
$checks['redis'] = 'not_ready';
$ready = false;
}
return new JsonResult(
data: [
'ready' => $ready,
'checks' => $checks,
'timestamp' => $this->clock->now()->format('c'),
],
status: $ready ? Status::OK : Status::SERVICE_UNAVAILABLE
);
}
private function checkPhp(): array
{
return [
'healthy' => true,
'version' => PHP_VERSION,
'extensions' => [
'opcache' => extension_loaded('opcache'),
'apcu' => extension_loaded('apcu'),
'redis' => extension_loaded('redis'),
'pdo' => extension_loaded('pdo'),
'openssl' => extension_loaded('openssl'),
'mbstring' => extension_loaded('mbstring'),
'json' => extension_loaded('json'),
],
'sapi' => PHP_SAPI,
];
}
private function checkDatabase(): array
{
$result = $this->dbHealthChecker->checkHealth($this->database);
$data = [
'healthy' => $result->isHealthy,
'latency_ms' => $result->responseTimeMs,
];
if ($result->message) {
$data['message'] = $result->message;
}
if ($result->exception) {
$data['error'] = $result->exception->getMessage();
}
if (! empty($result->additionalData)) {
$data['additional_data'] = $result->additionalData;
}
return $data;
}
private function checkRedis(): array
{
try {
$defaultRedis = $this->redisPool->getConnection('default');
$redisClient = $defaultRedis->getClient();
$start = $this->clock->time();
$pong = $redisClient->ping();
$latency = round($this->clock->time()->diff($start)->toMilliseconds(), 2);
$info = $redisClient->info('server');
$memoryInfo = $redisClient->info('memory');
$usedMemory = isset($memoryInfo['used_memory'])
? Byte::fromBytes((int) $memoryInfo['used_memory'])
: null;
return [
'healthy' => $pong === 'PONG',
'latency_ms' => $latency,
'version' => $info['redis_version'] ?? 'unknown',
'connected_clients' => $info['connected_clients'] ?? 0,
'used_memory' => $usedMemory?->toHumanReadable() ?? 'unknown',
];
} catch (\Exception $e) {
return [
'healthy' => false,
'error' => 'Connection failed: ' . $e->getMessage(),
];
}
}
private function checkFilesystem(): array
{
$tempDir = sys_get_temp_dir();
$testFile = $tempDir . '/health_check_' . uniqid() . '.tmp';
try {
// Test write
file_put_contents($testFile, 'health check');
// Test read
$content = file_get_contents($testFile);
// Cleanup
unlink($testFile);
// Check disk space
$freeSpace = disk_free_space($tempDir);
$totalSpace = disk_total_space($tempDir);
if ($freeSpace === false || $totalSpace === false) {
throw new \RuntimeException('Unable to determine disk space');
}
$freeBytes = Byte::fromBytes((int)$freeSpace);
$totalBytes = Byte::fromBytes((int)$totalSpace);
$usedBytes = $totalBytes->subtract($freeBytes);
$usagePercent = $usedBytes->percentOf($totalBytes);
return [
'healthy' => true,
'writable' => true,
'temp_dir' => $tempDir,
'disk_usage_percent' => round($usagePercent->getValue(), 2),
'disk_free' => $freeBytes->toHumanReadable(),
'disk_total' => $totalBytes->toHumanReadable(),
];
} catch (\Exception $e) {
return [
'healthy' => false,
'writable' => false,
'error' => 'Filesystem check failed',
];
}
}
private function checkMemory(): array
{
$memoryLimit = $this->memoryMonitor->getMemoryLimit();
$memoryUsage = $this->memoryMonitor->getCurrentMemory();
$memoryPeakUsage = $this->memoryMonitor->getPeakMemory();
$usagePercent = $this->memoryMonitor->getMemoryUsagePercentage();
return [
'healthy' => $usagePercent->greaterThan(Percentage::from(80.0)), // Unhealthy if over 80%
'limit' => $memoryLimit->toHumanReadable(),
'usage' => $memoryUsage->toHumanReadable(),
'peak_usage' => $memoryPeakUsage->toHumanReadable(),
'usage_percent' => round($usagePercent->getValue(), 2),
];
}
}

View File

@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace App\Application\Http;
use App\Framework\Attributes\Route;
use App\Framework\Exception\FrameworkException;
use App\Framework\Http\Batch\BatchProcessor;
use App\Framework\Http\Batch\BatchRequest;
use App\Framework\Http\Headers;
use App\Framework\Http\HttpResponse;
use App\Framework\Http\Method;
use App\Framework\Http\Request;
use App\Framework\Http\Status;
use App\Framework\Serialization\JsonSerializer;
use App\Framework\Serialization\JsonSerializerConfig;
/**
* Controller for handling batch API requests
*/
final readonly class BatchController
{
public function __construct(
private BatchProcessor $batchProcessor,
private JsonSerializer $jsonSerializer
) {
}
#[Route(path: '/api/batch', method: Method::POST)]
public function processBatch(Request $request): HttpResponse
{
try {
// Validate content type
$contentType = $request->headers->getFirst('Content-Type', '');
if (! str_contains($contentType, 'application/json')) {
return $this->errorResponse(
'Content-Type must be application/json',
Status::BAD_REQUEST
);
}
// Parse batch request
$batchData = $this->jsonSerializer->deserialize($request->body);
if (! is_array($batchData)) {
return $this->errorResponse(
'Invalid JSON structure',
Status::BAD_REQUEST
);
}
$batchRequest = BatchRequest::fromArray($batchData);
// Process batch operations
$responses = $this->batchProcessor->process($batchRequest);
// Return batch responses
$responseData = [
'responses' => array_map(fn ($response) => $response->toArray(), $responses),
'total' => count($responses),
'successful' => count(array_filter($responses, fn ($r) => $r->error === null)),
'failed' => count(array_filter($responses, fn ($r) => $r->error !== null)),
];
return new HttpResponse(
status: Status::OK,
headers: new Headers(['Content-Type' => 'application/json']),
body: $this->jsonSerializer->serialize(
$responseData,
JsonSerializerConfig::pretty()
)
);
} catch (FrameworkException $e) {
return $this->errorResponse($e->getMessage(), Status::BAD_REQUEST);
} catch (\Throwable $e) {
return $this->errorResponse(
'Internal server error',
Status::INTERNAL_SERVER_ERROR
);
}
}
#[Route(path: '/api/batch/info', method: Method::GET)]
public function getBatchInfo(Request $request): HttpResponse
{
$info = [
'max_operations' => 100,
'max_concurrent_operations' => 10,
'supported_methods' => ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
'continue_on_error_supported' => true,
'example_request' => [
'operations' => [
[
'id' => 'get-user-1',
'method' => 'GET',
'path' => '/api/users/1',
'headers' => ['Authorization' => 'Bearer token'],
],
[
'id' => 'create-post',
'method' => 'POST',
'path' => '/api/posts',
'headers' => ['Content-Type' => 'application/json'],
'body' => '{"title": "Test Post", "content": "Hello World"}',
],
],
'continue_on_error' => false,
],
];
return new HttpResponse(
status: Status::OK,
headers: new Headers(['Content-Type' => 'application/json']),
body: $this->jsonSerializer->serialize(
$info,
JsonSerializerConfig::pretty()
)
);
}
private function errorResponse(string $message, Status $status): HttpResponse
{
return new HttpResponse(
status: $status,
headers: new Headers(['Content-Type' => 'application/json']),
body: $this->jsonSerializer->serialize(['error' => $message])
);
}
}

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Application\Http\Controllers;
@@ -11,6 +12,7 @@ use App\Framework\Router\Result\WebSocketResult;
final class ChatController
{
/** @var array<string, WebSocketConnection> */
private array $connections = [];
#[Auth]
@@ -18,53 +20,56 @@ final class ChatController
public function chatWebSocket(): WebSocketResult
{
return new WebSocketResult()
->onConnect(function(WebSocketConnection $connection) {
->onConnect(function (WebSocketConnection $connection) {
$this->connections[$connection->getId()] = $connection;
// Willkommensnachricht senden
$connection->sendJson([
'type' => 'system',
'message' => 'Willkommen im Chat!',
'timestamp' => time()
'timestamp' => time(),
]);
// Andere Benutzer benachrichtigen
$this->broadcast([
'type' => 'user_joined',
'message' => 'Ein neuer Benutzer ist dem Chat beigetreten',
'timestamp' => time()
'timestamp' => time(),
], $connection->getId());
})
->onMessage(function(WebSocketConnection $connection, string $message) {
->onMessage(function (WebSocketConnection $connection, string $message) {
$data = json_decode($message, true);
if (!$data || !isset($data['type'])) {
if (! $data || ! isset($data['type'])) {
$connection->sendJson(['error' => 'Invalid message format']);
return;
}
switch ($data['type']) {
case 'chat_message':
$this->handleChatMessage($connection, $data);
break;
case 'ping':
$connection->sendJson(['type' => 'pong']);
break;
default:
$connection->sendJson(['error' => 'Unknown message type']);
}
})
->onClose(function(WebSocketConnection $connection, int $code, string $reason) {
->onClose(function (WebSocketConnection $connection, int $code, string $reason) {
unset($this->connections[$connection->getId()]);
// Andere Benutzer benachrichtigen
$this->broadcast([
'type' => 'user_left',
'message' => 'Ein Benutzer hat den Chat verlassen',
'timestamp' => time()
'timestamp' => time(),
]);
})
->onError(function(WebSocketConnection $connection, \Throwable $error) {
->onError(function (WebSocketConnection $connection, \Throwable $error) {
error_log("WebSocket error: " . $error->getMessage());
$connection->close(1011, 'Internal server error');
})
@@ -75,8 +80,9 @@ final class ChatController
private function handleChatMessage(WebSocketConnection $sender, array $data): void
{
if (!isset($data['message'])) {
if (! isset($data['message'])) {
$sender->sendJson(['error' => 'Message content required']);
return;
}
@@ -84,7 +90,7 @@ final class ChatController
'type' => 'chat_message',
'user_id' => $sender->getId(),
'message' => $data['message'],
'timestamp' => time()
'timestamp' => time(),
];
// Nachricht an alle Verbindungen senden

View File

@@ -1,10 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Application\Http\Controllers;
use App\Framework\Attributes\Route;
use App\Framework\Auth\Auth;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\DateTime\Timer;
use App\Framework\Http\Method;
use App\Framework\Http\SseStream;
use App\Framework\Http\Status;
@@ -16,6 +19,11 @@ use App\Framework\Router\Result\SseResultWithCallback;
*/
final class NotificationController
{
public function __construct(
private readonly Timer $timer
) {
}
/**
* Stellt einen SSE-Stream für allgemeine Benachrichtigungen bereit
*/
@@ -39,7 +47,7 @@ final class NotificationController
'type' => 'info',
'title' => 'Willkommen',
'message' => 'Sie sind jetzt mit Echtzeit-Updates verbunden.',
'timestamp' => time()
'timestamp' => time(),
],
'notification',
'notif-' . uniqid()
@@ -57,7 +65,7 @@ final class NotificationController
{
// SSE-Result mit benutzerdefinierten Headern
$result = new SseResult(Status::OK, 3000, [
'X-User-ID' => (string)$userId
'X-User-ID' => (string)$userId,
]);
// Verbindungsbestätigung mit Benutzer-ID
@@ -65,7 +73,7 @@ final class NotificationController
[
'message' => 'Verbunden mit dem Benutzer-Stream',
'userId' => $userId,
'timestamp' => time()
'timestamp' => time(),
],
'connected',
'conn-user-' . $userId
@@ -95,15 +103,15 @@ final class NotificationController
'type' => 'message',
'title' => 'Neue Nachricht',
'message' => 'Sie haben eine neue Nachricht erhalten',
'timestamp' => time() - 300 // Vor 5 Minuten
'timestamp' => time() - 300, // Vor 5 Minuten
],
[
'id' => 'notif-' . uniqid(),
'type' => 'system',
'title' => 'System-Update',
'message' => 'Das System wurde aktualisiert',
'timestamp' => time() - 3600 // Vor 1 Stunde
]
'timestamp' => time() - 3600, // Vor 1 Stunde
],
];
}
@@ -118,7 +126,7 @@ final class NotificationController
#$result = new SseResultWithCallback(Status::OK, 3000);
// Callback für dynamische Updates festlegen
$callback = function(SseStream $stream) {
$callback = function (SseStream $stream) {
// Simuliere neue Benachrichtigungen (mit 10% Wahrscheinlichkeit)
if (rand(1, 10) === 1) {
$notificationTypes = ['info', 'warning', 'update', 'message'];
@@ -129,13 +137,13 @@ final class NotificationController
'type' => $type,
'title' => 'Neue ' . ucfirst($type) . '-Benachrichtigung',
'message' => 'Dies ist eine dynamisch generierte Benachrichtigung vom Typ ' . $type,
'timestamp' => time()
'timestamp' => time(),
];
$stream->sendJson($notification, 'notification', $notification['id']);
// Kleine Pause nach dem Senden, um das Testszenario zu simulieren
sleep(1);
$this->timer->sleep(Duration::fromSeconds(1));
}
};

View File

@@ -4,92 +4,150 @@ declare(strict_types=1);
namespace App\Application\Http\Controllers;
use App\Application\Service\QrCodeService;
use App\Domain\QrCode\ValueObject\ErrorCorrectionLevel;
use App\Framework\Attributes\Route;
use App\Framework\Auth\Auth;
use App\Framework\Http\Headers;
use App\Framework\Http\HttpResponse;
use App\Framework\Http\Method;
use App\Framework\Http\Status;
use App\Framework\Http\Response;
use App\Framework\Http\Responses\JsonResponse;
use App\Framework\Http\Status;
use App\Framework\QrCode\ErrorCorrectionLevel;
use App\Framework\QrCode\QrCodeGenerator;
use App\Framework\QrCode\QrCodeVersion;
final class QrCodeController
/**
* QR Code API Controller
*
* Provides API endpoints for QR code generation using the Framework QrCode module
*/
final readonly class QrCodeController
{
public function __construct(
private readonly QrCodeService $qrCodeService
private QrCodeGenerator $qrCodeGenerator
) {
}
/**
* Generiert einen QR-Code als SVG
* Generate QR code as SVG
*/
#[Auth]
#[Route(path: '/api/qrcode/svg', method: Method::GET)]
public function generateSvg(): Response
{
$data = $_GET['data'] ?? 'https://example.com';
$errorLevel = $this->getErrorLevel($_GET['error_level'] ?? 'M');
$moduleSize = (int) ($_GET['module_size'] ?? 4);
$margin = (int) ($_GET['margin'] ?? 4);
$foreground = $_GET['foreground'] ?? '#000000';
$background = $_GET['background'] ?? '#FFFFFF';
$errorLevel = $this->parseErrorLevel($_GET['error'] ?? 'M');
$version = $_GET['version'] ?? null;
$config = new QrCodeConfig($moduleSize, $margin, $foreground, $background);
try {
$qrVersion = $version ? new QrCodeVersion((int) $version) : null;
$svg = $this->qrCodeService->generateSvg($data, $errorLevel, $config);
$svg = $this->qrCodeGenerator->generateSvg($data, $errorLevel, $qrVersion);
return new Response(
body: $svg,
status: Status::OK,
headers: ['Content-Type' => 'image/svg+xml']
);
return new HttpResponse(
status : Status::OK,
headers: new Headers(['Content-Type' => 'image/svg+xml']),
body : $svg
);
} catch (\Exception $e) {
return new JsonResponse(
body : ['error' => $e->getMessage()],
status: Status::BAD_REQUEST
);
}
}
/**
* Generiert einen QR-Code als PNG
* Generate QR code as Data URI (base64 encoded SVG)
*/
#[Auth]
#[Route(path: '/api/qrcode/png', method: Method::GET)]
public function generatePng(): Response
#[Route(path: '/api/qrcode/datauri', method: Method::GET)]
public function generateDataUri(): Response
{
$data = $_GET['data'] ?? 'https://example.com';
$errorLevel = $this->getErrorLevel($_GET['error_level'] ?? 'M');
$moduleSize = (int) ($_GET['module_size'] ?? 4);
$margin = (int) ($_GET['margin'] ?? 4);
$errorLevel = $this->parseErrorLevel($_GET['error'] ?? 'M');
$version = $_GET['version'] ?? null;
$config = new QrCodeConfig($moduleSize, $margin);
try {
$qrVersion = $version ? new QrCodeVersion((int) $version) : null;
$png = $this->qrCodeService->generatePng($data, $errorLevel, $config);
$dataUri = $this->qrCodeGenerator->generateDataUri($data, $errorLevel, $qrVersion);
return new Response(
body: $png,
status: Status::OK,
headers: ['Content-Type' => 'image/png']
);
return new JsonResponse(
body : ['dataUri' => $dataUri]
);
} catch (\Exception $e) {
return new JsonResponse(
body : ['error' => $e->getMessage()],
status: Status::BAD_REQUEST
);
}
}
/**
* Generiert einen QR-Code als ASCII-Art
* Analyze data and get QR code recommendations
*/
#[Auth]
#[Route(path: '/api/qrcode/ascii', method: Method::GET)]
public function generateAscii(): Response
#[Route(path: '/api/qrcode/analyze', method: Method::GET)]
public function analyzeData(): Response
{
$data = $_GET['data'] ?? 'https://example.com';
$errorLevel = $this->getErrorLevel($_GET['error_level'] ?? 'M');
$data = $_GET['data'] ?? null;
$ascii = $this->qrCodeService->generateAscii($data, $errorLevel);
if (empty($data)) {
return new JsonResponse(
body : ['error' => 'Data parameter is required'],
status: Status::BAD_REQUEST
);
}
return new Response(
body: "<pre>$ascii</pre>",
status: Status::OK,
headers: ['Content-Type' => 'text/html; charset=utf-8']
);
try {
$analysis = $this->qrCodeGenerator->analyzeData($data);
return new JsonResponse(
body : $analysis,
status: Status::OK
);
} catch (\Exception $e) {
return new JsonResponse(
body : ['error' => $e->getMessage()],
status: Status::BAD_REQUEST
);
}
}
/**
* Konvertiert einen String in ein ErrorCorrectionLevel-Enum
* Generate TOTP QR code specifically optimized for authenticator apps
*/
private function getErrorLevel(string $level): ErrorCorrectionLevel
#[Route(path: '/api/qrcode/totp', method: Method::GET)]
public function generateTotpQrCode(): HttpResponse
{
$totpUri = $_GET['uri'] ?? null;
if (empty($totpUri)) {
return new HttpResponse(
status : Status::BAD_REQUEST,
headers: new Headers(['Content-Type' => 'application/json']),
body : json_encode(['error' => 'TOTP URI parameter is required'])
);
}
try {
$svg = $this->qrCodeGenerator->generateTotpQrCode($totpUri);
return new HttpResponse(
status : Status::OK,
headers: new Headers(['Content-Type' => 'image/svg+xml']),
body : $svg
);
} catch (\Exception $e) {
return new HttpResponse(
status : Status::BAD_REQUEST,
headers: new Headers(['Content-Type' => 'application/json']),
body : json_encode(['error' => $e->getMessage()])
);
}
}
/**
* Parse error correction level from string
*/
private function parseErrorLevel(string $level): ErrorCorrectionLevel
{
return match (strtoupper($level)) {
'L' => ErrorCorrectionLevel::L,

View File

@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Application\Http\Examples;
use App\Framework\Attributes\Route;
use App\Framework\Http\Headers;
use App\Framework\Http\HttpResponse;
use App\Framework\Http\Method;
use App\Framework\Http\Request;
use App\Framework\Http\Status;
use App\Framework\Serialization\JsonSerializer;
use App\Framework\Serialization\JsonSerializerConfig;
/**
* Example controller for demonstrating batch API functionality
*/
final readonly class BatchExampleController
{
public function __construct(
private JsonSerializer $jsonSerializer
) {
}
#[Route(path: '/api/examples/users/{id}', method: Method::GET)]
public function getUser(Request $request): HttpResponse
{
$userId = $request->queryParams['id'] ?? '1';
$user = [
'id' => (int) $userId,
'name' => "User {$userId}",
'email' => "user{$userId}@example.com",
'created_at' => date('Y-m-d H:i:s'),
];
return new HttpResponse(
status: Status::OK,
headers: new Headers(['Content-Type' => 'application/json']),
body: $this->jsonSerializer->serialize($user, JsonSerializerConfig::pretty())
);
}
#[Route(path: '/api/examples/posts', method: Method::POST)]
public function createPost(Request $request): HttpResponse
{
$data = $this->jsonSerializer->deserialize($request->body);
if (! is_array($data) || empty($data['title'])) {
return new HttpResponse(
status: Status::BAD_REQUEST,
headers: new Headers(['Content-Type' => 'application/json']),
body: $this->jsonSerializer->serialize(['error' => 'Title is required'])
);
}
$post = [
'id' => random_int(1000, 9999),
'title' => $data['title'],
'content' => $data['content'] ?? '',
'author_id' => $data['author_id'] ?? 1,
'created_at' => date('Y-m-d H:i:s'),
];
return new HttpResponse(
status: Status::CREATED,
headers: new Headers(['Content-Type' => 'application/json']),
body: $this->jsonSerializer->serialize($post, JsonSerializerConfig::pretty())
);
}
#[Route(path: '/api/examples/posts/{id}', method: Method::PUT)]
public function updatePost(Request $request): HttpResponse
{
$postId = $request->queryParams['id'] ?? '1';
$data = $this->jsonSerializer->deserialize($request->body);
if (! is_array($data)) {
return new HttpResponse(
status: Status::BAD_REQUEST,
headers: new Headers(['Content-Type' => 'application/json']),
body: $this->jsonSerializer->serialize(['error' => 'Invalid JSON data'])
);
}
$post = [
'id' => (int) $postId,
'title' => $data['title'] ?? "Post {$postId}",
'content' => $data['content'] ?? '',
'updated_at' => date('Y-m-d H:i:s'),
];
return new HttpResponse(
status: Status::OK,
headers: new Headers(['Content-Type' => 'application/json']),
body: $this->jsonSerializer->serialize($post, JsonSerializerConfig::pretty())
);
}
#[Route(path: '/api/examples/posts/{id}', method: Method::DELETE)]
public function deletePost(Request $request): HttpResponse
{
$postId = $request->queryParams['id'] ?? '1';
return new HttpResponse(
status: Status::NO_CONTENT,
headers: new Headers([]),
body: ''
);
}
#[Route(path: '/api/examples/slow', method: Method::GET)]
public function slowEndpoint(Request $request): HttpResponse
{
// Simulate slow operation for testing concurrent processing
$delay = (int) ($request->queryParams['delay'] ?? 1);
$maxDelay = min($delay, 5); // Maximum 5 seconds
sleep($maxDelay);
return new HttpResponse(
status: Status::OK,
headers: new Headers(['Content-Type' => 'application/json']),
body: $this->jsonSerializer->serialize([
'message' => 'Slow operation completed',
'delay' => $maxDelay,
'timestamp' => date('Y-m-d H:i:s'),
])
);
}
}

View File

@@ -1,17 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Application\Http;
use App\Framework\Attributes\Route;
use App\Framework\CommandBus\CommandBus;
use App\Framework\Http\HeaderKey;
use App\Framework\Http\Request;
use App\Framework\Http\Method;
use App\Framework\Http\Request;
use App\Framework\Http\Status;
use App\Framework\Router\ActionResult;
use App\Framework\Router\Result\ViewResult;
use App\Framework\Router\Result\Redirect;
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\Smartlinks\Actions\ActionRegistry;
use App\Framework\Smartlinks\Commands\ExecuteSmartlinkCommand;
use App\Framework\Smartlinks\Commands\GenerateSmartlinkCommand;
@@ -27,7 +29,8 @@ final readonly class Smartlink
private ActionRegistry $actionRegistry,
private SmartlinkService $smartlinkService,
private GenerateSmartlinkHandler $handler,
) {}
) {
}
#[Route('/smartlink/{token}', method: Method::GET)]
#[Route('/smartlink/{token}', method: Method::POST)]
@@ -46,7 +49,7 @@ final readonly class Smartlink
// Token validieren
$smartlinkData = $this->smartlinkService->validate($smartlinkToken);
if (!$smartlinkData) {
if (! $smartlinkData) {
return new ViewResult(
template: 'smartlinks-error',
metaData: new MetaData(
@@ -55,7 +58,7 @@ final readonly class Smartlink
),
data: [
'error' => 'Ungültiger oder abgelaufener Link',
'error_code' => 'INVALID_TOKEN'
'error_code' => 'INVALID_TOKEN',
],
status: Status::NOT_FOUND
);
@@ -63,7 +66,7 @@ final readonly class Smartlink
// Action holen
$action = $this->actionRegistry->get($smartlinkData->action);
if (!$action) {
if (! $action) {
return new ViewResult(
template: 'smartlinks-error',
metaData: new MetaData(
@@ -72,7 +75,7 @@ final readonly class Smartlink
),
data: [
'error' => 'Unbekannte Aktion',
'error_code' => 'UNKNOWN_ACTION'
'error_code' => 'UNKNOWN_ACTION',
],
status: Status::BAD_REQUEST
);
@@ -85,7 +88,7 @@ final readonly class Smartlink
'query_params' => $request->queryParameters ?? [],
#'ip_address' => $request->serverEnvironment->ipAddress?->value,
'user_agent' => $request->headers->getFirst(HeaderKey::USER_AGENT),
'headers' => $request->headers
'headers' => $request->headers,
];
// Command ausführen
@@ -93,7 +96,7 @@ final readonly class Smartlink
$result = $this->commandBus->dispatch($command);
// Ergebnis verarbeiten
if (!$result->isSuccess()) {
if (! $result->isSuccess()) {
return new ViewResult(
template: $action->getErrorTemplate(),
metaData: new MetaData(
@@ -103,7 +106,7 @@ final readonly class Smartlink
data: [
'error' => $result->message,
'errors' => $result->errors,
'action' => $action->getName()
'action' => $action->getName(),
],
status: Status::BAD_REQUEST
);
@@ -123,7 +126,7 @@ final readonly class Smartlink
data: [
'result' => $result,
'action' => $action->getName(),
'token' => $token
'token' => $token,
]
);
@@ -137,7 +140,7 @@ final readonly class Smartlink
data: [
'error' => 'Ein Fehler ist aufgetreten',
'error_code' => 'SYSTEM_ERROR',
'debug_message' => $e->getMessage() // Nur für Development
'debug_message' => $e->getMessage(), // Nur für Development
],
status: Status::INTERNAL_SERVER_ERROR
);

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Application\Media;

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Application\Media;
use App\Domain\Media\ImageProcessor;
use App\Domain\Media\ImageRepository;
use App\Domain\Media\ImageVariantRepository;
use FilesystemIterator;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
final readonly class MediaCleanupService
{
public function __construct(
private ImageRepository $imageRepository,
private ImageProcessor $imageProcessor,
private ImageVariantRepository $imageVariantRepository,
) {
}
/**
* Cleanup Media Files that are not saved to Database
*/
public function cleanupUnusedFiles(string $path = '/var/www/html/storage/uploads/2025'): array
{
$deletedFiles = [];
$rii = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS));
/** @var \SplFileInfo $file */
foreach ($rii as $file) {
$variant = $this->imageVariantRepository->findByFilename($file->getFilename());
if ($variant === null) {
$image = $this->imageRepository->findByFilename($file->getFilename());
if ($image === null) {
$success = unlink($file->getPathname());
if ($success) {
$deletedFiles[] = $file->getPathname();
}
}
}
}
return $deletedFiles;
}
/**
* Delete empty folders
*/
public function cleanupEmptyDirectories(string $path = '/var/www/html/storage/uploads/2025'): array
{
$emptyDirs = [];
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST // Ordner erst nach dem Inhalt prüfen
);
foreach ($iterator as $fileInfo) {
if ($fileInfo->isDir()) {
// Verzeichnis öffnen und prüfen, ob es leer ist
$files = array_diff(scandir($fileInfo->getPathname()), ['.', '..']);
if (empty($files)) {
$success = rmdir($fileInfo->getPathname());
if ($success) {
$emptyDirs[] = $fileInfo->getPathname();
}
}
}
}
return $emptyDirs;
}
/**
* Create image variants for all images
*/
public function createImageVariants(): array
{
$createdVariants = [];
$images = $this->imageRepository->findAll();
foreach ($images as $image) {
$variants = $this->imageProcessor->createAllVariants($image);
foreach ($variants as $variant) {
try {
$this->imageVariantRepository->save($variant);
$createdVariants[] = $variant;
} catch (\Throwable $th) {
// Variant already saved, skip
}
}
}
return $createdVariants;
}
/**
* Full cleanup - files, directories and variant creation
*/
public function fullCleanup(string $path = '/var/www/html/storage/uploads/2025'): array
{
return [
'deleted_files' => $this->cleanupUnusedFiles($path),
'deleted_directories' => $this->cleanupEmptyDirectories($path),
'created_variants' => $this->createImageVariants(),
];
}
}

View File

@@ -1,11 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Application\Media;
use App\Domain\Media\ImageRepository;
use App\Domain\Media\ImageVariantRepository;
use App\Framework\Attributes\Route;
use App\Framework\Core\PathProvider;
use App\Framework\Http\HttpRequest;
use App\Framework\Router\Result\FileResult;
final readonly class ShowImage
@@ -14,19 +17,21 @@ final readonly class ShowImage
private PathProvider $pathProvider,
private ImageRepository $imageRepository,
private ImageVariantRepository $imageVariantRepository,
) {}
) {
}
#[Route('/images/{filename}')]
public function __invoke($filename): FileResult
public function __invoke(mixed $filename, HttpRequest $request): FileResult
{
$path = $this->pathProvider->resolvePath('storage/uploads/');
$image = $this->imageRepository->findByFilename($filename);
if($image === null) {
if ($image === null) {
$image = $this->imageVariantRepository->findByFilename($filename);
}
if($image === null) {
if ($image === null) {
throw new \Exception('Image not found');
}
@@ -46,14 +51,17 @@ final readonly class ShowImage
// 1. Last-Modified & ETag (Conditional Requests)
$lastModified = gmdate('D, d M Y H:i:s', filemtime($file)).' GMT';
$eTag = '"'.md5_file($file).'"';
$eTag = '"'.md5_file($file).'"';
header("Last-Modified: $lastModified");
header("ETag: $eTag");
// 304 Not Modified wenn Browser-Cache gültig
$ifNoneMatch = $request->headers->getFirst('If-None-Match');
$ifModifiedSince = $request->headers->getFirst('If-Modified-Since');
if (
(isset($_SERVER['HTTP_IF_NONE_MATCH']) && trim($_SERVER['HTTP_IF_NONE_MATCH']) === $eTag) ||
(isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) && $_SERVER['HTTP_IF_MODIFIED_SINCE'] === $lastModified)
($ifNoneMatch && trim($ifNoneMatch) === $eTag) ||
($ifModifiedSince && $ifModifiedSince === $lastModified)
) {
http_response_code(304);
exit;

View File

@@ -1,11 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Application\Media;
use App\Framework\Attributes\Route;
use App\Framework\Core\PathProvider;
use App\Framework\Http\Request;
use App\Framework\Http\Responses\MediaType;
use App\Framework\Http\Responses\StreamResponse;
use App\Framework\Http\Streaming\MimeTypeDetector;
@@ -15,7 +15,8 @@ final readonly class ShowVideo
{
public function __construct(
private PathProvider $pathProvider,
) {}
) {
}
#[Route('/videos/{filename}')]
public function __invoke(string $filename): StreamResponse

View File

@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App\Application\Metrics;
use App\Framework\Attributes\Route;
use App\Framework\Http\Headers;
use App\Framework\Http\HttpResponse;
use App\Framework\Http\Method;
use App\Framework\Http\Request;
use App\Framework\Http\Status;
use App\Framework\Metrics\Formatters\JsonFormatter;
use App\Framework\Metrics\Formatters\MetricsFormatter;
use App\Framework\Metrics\Formatters\OpenMetricsFormatter;
use App\Framework\Metrics\Formatters\PrometheusFormatter;
use App\Framework\Metrics\Formatters\StatsDFormatter;
use App\Framework\Metrics\MetricsCollector;
/**
* Controller for exposing application metrics in various formats
*/
final readonly class MetricsController
{
/**
* @var array<string, MetricsFormatter>
*/
private array $formatters;
public function __construct(
private MetricsCollector $collector
) {
$this->formatters = [
'prometheus' => new PrometheusFormatter(),
'openmetrics' => new OpenMetricsFormatter(),
'json' => new JsonFormatter(prettyPrint: false),
'json-pretty' => new JsonFormatter(prettyPrint: true),
'statsd' => new StatsDFormatter(),
];
}
#[Route(path: '/metrics', method: Method::GET)]
public function metrics(Request $request): HttpResponse
{
// Determine format from query parameter or Accept header
$format = $this->determineFormat($request);
// Get the appropriate formatter
$formatter = $this->formatters[$format] ?? $this->formatters['prometheus'];
// Collect metrics
$metrics = $this->collector->collect();
// Format and return
return new HttpResponse(
status: Status::OK,
headers: new Headers([
'Content-Type' => $formatter->getContentType(),
'Cache-Control' => 'no-cache, no-store, must-revalidate',
]),
body: $formatter->format($metrics)
);
}
#[Route(path: '/metrics/health', method: Method::GET)]
public function health(Request $request): HttpResponse
{
// Simple health check that confirms metrics system is working
$formatter = new JsonFormatter(prettyPrint: true);
$metrics = $this->collector->collect();
$health = [
'status' => 'healthy',
'metrics_count' => count($metrics->getAllMetrics()),
'timestamp' => $metrics->getCollectedAt()?->toIso8601(),
];
return new HttpResponse(
status: Status::OK,
headers: new Headers([
'Content-Type' => 'application/json',
'Cache-Control' => 'no-cache',
]),
body: json_encode($health, JSON_PRETTY_PRINT)
);
}
private function determineFormat(Request $request): string
{
// Check query parameter first
$queryFormat = $request->queryParams['format'] ?? null;
if ($queryFormat !== null && isset($this->formatters[$queryFormat])) {
return $queryFormat;
}
// Check Accept header
$acceptHeader = $request->headers->getFirst('Accept', 'text/plain');
// Map Accept headers to formats
return match(true) {
str_contains($acceptHeader, 'application/openmetrics-text') => 'openmetrics',
str_contains($acceptHeader, 'application/json') => 'json',
str_contains($acceptHeader, 'text/plain') => 'prometheus',
default => 'prometheus',
};
}
}

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Application\Newsletter\SignUp;

View File

@@ -1,21 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Application\Newsletter\SignUp;
use App\Framework\Attributes\Route;
use App\Framework\CommandBus\CommandBus;
use App\Framework\CommandBus\DefaultCommandBus;
use App\Framework\Http\Method;
use App\Framework\Http\Status;
use App\Framework\Router\Result\ContentNegotiationResult;
use App\Framework\Router\Result\JsonResult;
final readonly class NewsletterSignup
{
public function __construct(
public CommandBus $commandBus,
) {}
) {
}
#[Route(path: '/newsletter/register', method: Method::POST)]
public function __invoke(NewsletterSignupRequest $request): ContentNegotiationResult

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Application\Newsletter\SignUp;
@@ -12,10 +13,11 @@ final readonly class NewsletterSignupHandler
{
public function __construct(
private EventBus $eventBus,
) {}
) {
}
#[CommandHandler]
public function __invoke(SignupUserToNewsletter $command):void
public function __invoke(SignupUserToNewsletter $command): void
{
// RapidMail-Client erstellen und konfigurieren
$client = new RapidMailClient(
@@ -30,7 +32,8 @@ final readonly class NewsletterSignupHandler
ApiConfig::getRapidmailListId()
);
error_log('CommandHandler für: '.$command->email);;
error_log('CommandHandler für: '.$command->email);
;
$this->eventBus->dispatch(new UserWasSignedUp($command->email));
}

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Application\Newsletter\SignUp;
@@ -6,7 +7,6 @@ namespace App\Application\Newsletter\SignUp;
use App\Framework\Http\ControllerRequest;
use App\Framework\Validation\Rules\Email;
use App\Framework\Validation\Rules\IsTrue;
use App\Framework\Validation\Rules\Required;
use App\Framework\Validation\Rules\StringLength;
final readonly class NewsletterSignupRequest implements ControllerRequest
@@ -16,6 +16,7 @@ final readonly class NewsletterSignupRequest implements ControllerRequest
#[StringLength(min: 3, max: 255)]
public string $name;
#[IsTrue]
public bool $consent;
}

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Application\Newsletter\SignUp;
@@ -11,5 +12,6 @@ final readonly class SignupUserToNewsletter
public function __construct(
public string $name,
public string $email,
) {}
) {
}
}

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Application\Newsletter\SignUp;

View File

@@ -0,0 +1,242 @@
<?php
declare(strict_types=1);
namespace App\Application\Performance\Http\Controller;
use App\Framework\Attributes\Route;
use App\Framework\Core\ValueObjects\Byte;
use App\Framework\Http\Method;
use App\Framework\Performance\MemoryMonitor;
use App\Framework\Performance\PerformanceService;
use App\Framework\Router\Result\JsonResult;
final class PerformanceController
{
public function __construct(
private PerformanceService $performanceService,
) {
}
#[Route(path: '/admin/performance/summary', method: Method::GET)]
public function getSummary(): JsonResult
{
if (! $this->performanceService->isEnabled()) {
return new JsonResult([
'success' => false,
'error' => 'Performance monitoring is disabled',
]);
}
return new JsonResult([
'success' => true,
'data' => $this->performanceService->getSummary(),
]);
}
#[Route(path: '/admin/performance/metrics', method: Method::GET)]
public function getMetrics(): JsonResult
{
if (! $this->performanceService->isEnabled()) {
return new JsonResult([
'success' => false,
'error' => 'Performance monitoring is disabled',
]);
}
$category = $_GET['category'] ?? null;
$categoryFilter = $category ? \App\Framework\Performance\PerformanceCategory::tryFrom($category) : null;
return new JsonResult([
'success' => true,
'data' => $this->performanceService->getMetrics($categoryFilter),
]);
}
#[Route(path: '/admin/performance/slowest', method: Method::GET)]
public function getSlowestOperations(): JsonResult
{
if (! $this->performanceService->isEnabled()) {
return new JsonResult([
'success' => false,
'error' => 'Performance monitoring is disabled',
]);
}
$limit = (int) ($_GET['limit'] ?? 10);
return new JsonResult([
'success' => true,
'data' => $this->performanceService->getSlowestOperations($limit),
]);
}
#[Route(path: '/admin/performance/report', method: Method::GET)]
public function getReport(): JsonResult
{
if (! $this->performanceService->isEnabled()) {
return new JsonResult([
'success' => false,
'error' => 'Performance monitoring is disabled',
]);
}
$format = $_GET['format'] ?? 'array';
return new JsonResult([
'success' => true,
'data' => $this->performanceService->generateReport($format),
]);
}
#[Route(path: '/admin/performance/stats', method: Method::GET)]
public function getRequestStats(): JsonResult
{
if (! $this->performanceService->isEnabled()) {
return new JsonResult([
'success' => false,
'error' => 'Performance monitoring is disabled',
]);
}
return new JsonResult([
'success' => true,
'data' => $this->performanceService->getRequestStats(),
]);
}
#[Route(path: '/admin/performance/reset', method: Method::POST)]
public function resetMetrics(): JsonResult
{
if (! $this->performanceService->isEnabled()) {
return new JsonResult([
'success' => false,
'error' => 'Performance monitoring is disabled',
]);
}
$this->performanceService->reset();
return new JsonResult([
'success' => true,
'message' => 'Performance metrics have been reset',
]);
}
#[Route(path: '/admin/performance/memory', method: Method::GET)]
public function getMemoryStats(): JsonResult
{
$memoryMonitor = new MemoryMonitor();
$summary = $memoryMonitor->getSummary();
// Add additional memory information
$data = $summary->toArray();
$data['php_info'] = [
'memory_limit' => [
'raw' => ini_get('memory_limit'),
'bytes' => $memoryMonitor->getMemoryLimit()->toBytes(),
'human' => $memoryMonitor->getMemoryLimit()->toHumanReadable(),
],
'max_execution_time' => ini_get('max_execution_time'),
'upload_max_filesize' => [
'raw' => ini_get('upload_max_filesize'),
'bytes' => Byte::parse(ini_get('upload_max_filesize'))->toBytes(),
'human' => Byte::parse(ini_get('upload_max_filesize'))->toHumanReadable(),
],
'post_max_size' => [
'raw' => ini_get('post_max_size'),
'bytes' => Byte::parse(ini_get('post_max_size'))->toBytes(),
'human' => Byte::parse(ini_get('post_max_size'))->toHumanReadable(),
],
];
return new JsonResult([
'success' => true,
'data' => $data,
]);
}
#[Route(path: '/admin/performance/system', method: Method::GET)]
public function getSystemInfo(): JsonResult
{
$memoryMonitor = new MemoryMonitor();
$systemInfo = [
'php' => [
'version' => PHP_VERSION,
'sapi' => PHP_SAPI,
'os' => PHP_OS,
'architecture' => php_uname('m'),
],
'memory' => $memoryMonitor->getSummary()->toArray(),
'opcache' => function_exists('opcache_get_status') ? opcache_get_status() : null,
'extensions' => [
'apcu' => extension_loaded('apcu'),
'redis' => extension_loaded('redis'),
'imagick' => extension_loaded('imagick'),
'gd' => extension_loaded('gd'),
'curl' => extension_loaded('curl'),
'zip' => extension_loaded('zip'),
],
];
// Add disk space information if available
if (function_exists('disk_free_space') && function_exists('disk_total_space')) {
$rootPath = '/';
$freeBytes = disk_free_space($rootPath);
$totalBytes = disk_total_space($rootPath);
if ($freeBytes !== false && $totalBytes !== false) {
$usedBytes = $totalBytes - $freeBytes;
$systemInfo['disk'] = [
'free' => [
'bytes' => $freeBytes,
'human' => Byte::fromBytes((int) $freeBytes)->toHumanReadable(),
],
'used' => [
'bytes' => $usedBytes,
'human' => Byte::fromBytes((int) $usedBytes)->toHumanReadable(),
],
'total' => [
'bytes' => $totalBytes,
'human' => Byte::fromBytes((int) $totalBytes)->toHumanReadable(),
],
'usage_percentage' => round(($usedBytes / $totalBytes) * 100, 2),
];
}
}
return new JsonResult([
'success' => true,
'data' => $systemInfo,
]);
}
#[Route(path: '/admin/performance/config', method: Method::GET)]
public function getConfig(): JsonResult
{
return new JsonResult([
'success' => true,
'data' => [
'enabled' => $this->performanceService->isEnabled(),
'config' => $this->performanceService->getConfig(),
],
]);
}
#[Route(path: '/admin/performance/export', method: Method::GET)]
public function exportMetrics(): JsonResult
{
if (! $this->performanceService->isEnabled()) {
return new JsonResult([
'success' => false,
'error' => 'Performance monitoring is disabled',
]);
}
return new JsonResult([
'success' => true,
'data' => $this->performanceService->exportMetrics(),
]);
}
}

View File

@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace App\Application\Search;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
use App\Framework\Search\SearchFieldConfig;
use App\Framework\Search\SearchFieldType;
use App\Framework\Search\SearchIndexConfig;
/**
* Request object for creating search indexes
*/
final readonly class CreateIndexRequest
{
/**
* @param array<string, array> $fields
* @param array<string, mixed> $settings
*/
public function __construct(
public string $entityType,
public array $fields,
public array $settings = [],
public bool $enabled = true
) {
}
public static function fromArray(string $entityType, array $data): self
{
$fields = $data['fields'] ?? [];
$settings = $data['settings'] ?? [];
$enabled = $data['enabled'] ?? true;
// Validate required fields
if (empty($fields)) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
'At least one field configuration is required'
);
}
// Validate field configurations
foreach ($fields as $fieldName => $fieldConfig) {
if (! is_array($fieldConfig) || ! isset($fieldConfig['type'])) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
"Invalid field configuration for '{$fieldName}'. Type is required."
);
}
// Validate field type
try {
SearchFieldType::from($fieldConfig['type']);
} catch (\ValueError) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
"Invalid field type '{$fieldConfig['type']}' for field '{$fieldName}'"
);
}
}
return new self(
entityType: $entityType,
fields: $fields,
settings: $settings,
enabled: $enabled
);
}
public function toIndexConfig(): SearchIndexConfig
{
$fieldConfigs = [];
foreach ($this->fields as $fieldName => $fieldData) {
$fieldConfigs[$fieldName] = new SearchFieldConfig(
type: SearchFieldType::from($fieldData['type']),
isSearchable: $fieldData['searchable'] ?? true,
isFilterable: $fieldData['filterable'] ?? true,
isSortable: $fieldData['sortable'] ?? true,
isHighlightable: $fieldData['highlightable'] ?? true,
boost: (float) ($fieldData['boost'] ?? 1.0),
analyzer: $fieldData['analyzer'] ?? null,
format: $fieldData['format'] ?? null,
options: $fieldData['options'] ?? []
);
}
return new SearchIndexConfig(
entityType: $this->entityType,
fields: $fieldConfigs,
settings: $this->settings,
enabled: $this->enabled
);
}
public static function defaultForEntity(string $entityType): self
{
return new self(
entityType: $entityType,
fields: [
'title' => [
'type' => 'text',
'searchable' => true,
'highlightable' => true,
'boost' => 2.0,
],
'content' => [
'type' => 'text',
'searchable' => true,
'highlightable' => true,
'boost' => 1.0,
],
'category' => [
'type' => 'keyword',
'searchable' => false,
'filterable' => true,
'sortable' => true,
'highlightable' => false,
],
'tags' => [
'type' => 'keyword',
'searchable' => true,
'filterable' => true,
'sortable' => false,
'highlightable' => false,
],
'created_at' => [
'type' => 'datetime',
'searchable' => false,
'filterable' => true,
'sortable' => true,
'highlightable' => false,
],
'status' => [
'type' => 'keyword',
'searchable' => false,
'filterable' => true,
'sortable' => true,
'highlightable' => false,
],
]
);
}
public function toArray(): array
{
return [
'entity_type' => $this->entityType,
'fields' => $this->fields,
'settings' => $this->settings,
'enabled' => $this->enabled,
];
}
}

View File

@@ -0,0 +1,515 @@
<?php
declare(strict_types=1);
namespace App\Application\Search;
use App\Framework\Attributes\Route;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
use App\Framework\Http\Method;
use App\Framework\Http\Request;
use App\Framework\Http\Status;
use App\Framework\Router\Result\JsonResult;
use App\Framework\Search\SearchDocument;
use App\Framework\Search\SearchFilter;
use App\Framework\Search\SearchFilterType;
use App\Framework\Search\SearchService;
use App\Framework\Search\SearchSortDirection;
/**
* REST API controller for search functionality
*/
final readonly class SearchController
{
public function __construct(
private SearchService $searchService
) {
}
/**
* Search for documents
* GET /api/search/{entityType}
*/
#[Route(path: '/api/search/{entityType}', method: Method::GET)]
public function search(Request $request): JsonResult
{
try {
$entityType = $request->routeParameters->get('entityType');
$searchRequest = SearchRequest::fromHttpRequest($request);
$queryBuilder = $this->searchService
->for($entityType)
->query($searchRequest->query);
// Apply filters
foreach ($searchRequest->filters as $field => $filterData) {
$filter = $this->createSearchFilter($filterData);
$queryBuilder->filter($field, $filter);
}
// Apply boosts
foreach ($searchRequest->boosts as $field => $boost) {
$queryBuilder->boost($field, $boost);
}
// Apply field restrictions
if (! empty($searchRequest->fields)) {
$queryBuilder->fields($searchRequest->fields);
}
// Apply highlighting
if (! empty($searchRequest->highlight)) {
$queryBuilder->highlight($searchRequest->highlight);
}
// Apply pagination
$queryBuilder->limit($searchRequest->limit)
->offset($searchRequest->offset);
// Apply sorting
if ($searchRequest->sortBy) {
$queryBuilder->sortBy(
$searchRequest->sortBy,
SearchSortDirection::from($searchRequest->sortDirection)
);
} elseif ($searchRequest->sortByRelevance) {
$queryBuilder->sortByRelevance();
}
// Apply advanced options
if ($searchRequest->enableFuzzy) {
$queryBuilder->fuzzy(true);
}
if ($searchRequest->minScore > 0) {
$queryBuilder->minScore($searchRequest->minScore);
}
// Execute search
$result = $queryBuilder->search();
return new JsonResult([
'success' => true,
'data' => [
'hits' => array_map(fn ($hit) => $hit->toArray(), $result->hits),
'total' => $result->total,
'max_score' => $result->maxScore,
'took' => $result->took,
'has_more' => $result->total > ($searchRequest->offset + $searchRequest->limit),
'page' => intval($searchRequest->offset / $searchRequest->limit) + 1,
'per_page' => $searchRequest->limit,
'total_pages' => ceil($result->total / $searchRequest->limit),
],
'aggregations' => $result->aggregations,
]);
} catch (FrameworkException $e) {
return new JsonResult([
'success' => false,
'error' => [
'message' => $e->getMessage(),
'code' => $e->getErrorCode()->value,
'details' => $e->getData(),
],
], Status::BAD_REQUEST);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => [
'message' => 'Internal search error',
'code' => 'SEARCH_ERROR',
],
], Status::INTERNAL_SERVER_ERROR);
}
}
/**
* Index a single document
* POST /api/search/{entityType}/{id}
*/
#[Route(path: '/api/search/{entityType}/{id}', method: Method::POST)]
public function indexDocument(Request $request): JsonResult
{
try {
$entityType = $request->routeParameters->get('entityType');
$id = $request->routeParameters->get('id');
$document = $request->parsedBody->toArray();
if (empty($document)) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
'Document data is required'
);
}
$success = $this->searchService->index($entityType, $id, $document);
if (! $success) {
throw FrameworkException::create(
ErrorCode::DB_QUERY_FAILED,
'Failed to index document'
);
}
return new JsonResult([
'success' => true,
'data' => [
'entity_type' => $entityType,
'id' => $id,
'indexed' => true,
'message' => 'Document indexed successfully',
],
], Status::CREATED);
} catch (FrameworkException $e) {
return new JsonResult([
'success' => false,
'error' => [
'message' => $e->getMessage(),
'code' => $e->getErrorCode()->value,
'details' => $e->getData(),
],
], Status::BAD_REQUEST);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => [
'message' => 'Failed to index document',
'code' => 'INDEX_ERROR',
],
], Status::INTERNAL_SERVER_ERROR);
}
}
/**
* Bulk index multiple documents
* POST /api/search/{entityType}/bulk
*/
#[Route(path: '/api/search/{entityType}/bulk', method: Method::POST)]
public function bulkIndex(Request $request): JsonResult
{
try {
$entityType = $request->routeParameters->get('entityType');
$requestData = $request->parsedBody->toArray();
if (! isset($requestData['documents']) || ! is_array($requestData['documents'])) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
'Documents array is required'
);
}
$documents = [];
foreach ($requestData['documents'] as $docData) {
if (! isset($docData['id']) || ! isset($docData['data'])) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
'Each document must have id and data fields'
);
}
$documents[] = new SearchDocument(
id: $docData['id'],
entityType: $entityType,
data: $docData['data'],
metadata: $docData['metadata'] ?? []
);
}
$result = $this->searchService->bulkIndex($documents);
return new JsonResult([
'success' => true,
'data' => [
'entity_type' => $entityType,
'bulk_result' => $result->toArray(),
],
], $result->isFullySuccessful() ? Status::CREATED : Status::ACCEPTED);
} catch (FrameworkException $e) {
return new JsonResult([
'success' => false,
'error' => [
'message' => $e->getMessage(),
'code' => $e->getErrorCode()->value,
'details' => $e->getData(),
],
], Status::BAD_REQUEST);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => [
'message' => 'Bulk indexing failed',
'code' => 'BULK_INDEX_ERROR',
],
], Status::INTERNAL_SERVER_ERROR);
}
}
/**
* Update a document
* PUT /api/search/{entityType}/{id}
*/
#[Route(path: '/api/search/{entityType}/{id}', method: Method::PUT)]
public function updateDocument(Request $request): JsonResult
{
try {
$entityType = $request->routeParameters->get('entityType');
$id = $request->routeParameters->get('id');
$document = $request->parsedBody->toArray();
if (empty($document)) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
'Document data is required'
);
}
$success = $this->searchService->update($entityType, $id, $document);
if (! $success) {
throw FrameworkException::create(
ErrorCode::DB_QUERY_FAILED,
'Failed to update document'
);
}
return new JsonResult([
'success' => true,
'data' => [
'entity_type' => $entityType,
'id' => $id,
'updated' => true,
'message' => 'Document updated successfully',
],
]);
} catch (FrameworkException $e) {
return new JsonResult([
'success' => false,
'error' => [
'message' => $e->getMessage(),
'code' => $e->getErrorCode()->value,
'details' => $e->getData(),
],
], Status::BAD_REQUEST);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => [
'message' => 'Failed to update document',
'code' => 'UPDATE_ERROR',
],
], Status::INTERNAL_SERVER_ERROR);
}
}
/**
* Delete a document from the search index
* DELETE /api/search/{entityType}/{id}
*/
#[Route(path: '/api/search/{entityType}/{id}', method: Method::DELETE)]
public function deleteDocument(Request $request): JsonResult
{
try {
$entityType = $request->routeParameters->get('entityType');
$id = $request->routeParameters->get('id');
$success = $this->searchService->delete($entityType, $id);
return new JsonResult([
'success' => true,
'data' => [
'entity_type' => $entityType,
'id' => $id,
'deleted' => $success,
'message' => $success ? 'Document deleted successfully' : 'Document not found',
],
]);
} catch (FrameworkException $e) {
return new JsonResult([
'success' => false,
'error' => [
'message' => $e->getMessage(),
'code' => $e->getErrorCode()->value,
'details' => $e->getData(),
],
], Status::BAD_REQUEST);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => [
'message' => 'Failed to delete document',
'code' => 'DELETE_ERROR',
],
], Status::INTERNAL_SERVER_ERROR);
}
}
/**
* Get search engine statistics
* GET /api/search/_stats
*/
#[Route(path: '/api/search/_stats', method: Method::GET)]
public function getStats(Request $request): JsonResult
{
try {
$stats = $this->searchService->getStats();
return new JsonResult([
'success' => true,
'data' => [
'engine_stats' => $stats->toArray(),
'available' => $this->searchService->isAvailable(),
],
]);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => [
'message' => 'Failed to get search statistics',
'code' => 'STATS_ERROR',
],
], Status::INTERNAL_SERVER_ERROR);
}
}
/**
* Get index-specific statistics
* GET /api/search/{entityType}/_stats
*/
#[Route(path: '/api/search/{entityType}/_stats', method: Method::GET)]
public function getIndexStats(Request $request): JsonResult
{
try {
$entityType = $request->routeParameters->get('entityType');
$indexManager = $this->searchService->getIndexManager();
$stats = $indexManager->getIndexStats($entityType);
if (! $stats) {
return new JsonResult([
'success' => false,
'error' => [
'message' => "Index '{$entityType}' not found",
'code' => 'INDEX_NOT_FOUND',
],
], Status::NOT_FOUND);
}
return new JsonResult([
'success' => true,
'data' => [
'index_stats' => $stats->toArray(),
'entity_type' => $entityType,
],
]);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => [
'message' => 'Failed to get index statistics',
'code' => 'INDEX_STATS_ERROR',
],
], Status::INTERNAL_SERVER_ERROR);
}
}
/**
* Create or update an index
* PUT /api/search/{entityType}/_index
*/
#[Route(path: '/api/search/{entityType}/_index', method: Method::PUT)]
public function createIndex(Request $request): JsonResult
{
try {
$entityType = $request->routeParameters->get('entityType');
$requestData = $request->parsedBody->toArray();
$createIndexRequest = CreateIndexRequest::fromArray($entityType, $requestData);
$indexManager = $this->searchService->getIndexManager();
$success = $indexManager->createIndex($entityType, $createIndexRequest->toIndexConfig());
if (! $success) {
throw FrameworkException::create(
ErrorCode::DB_QUERY_FAILED,
'Failed to create search index'
);
}
return new JsonResult([
'success' => true,
'data' => [
'entity_type' => $entityType,
'created' => true,
'message' => 'Search index created successfully',
],
], Status::CREATED);
} catch (FrameworkException $e) {
return new JsonResult([
'success' => false,
'error' => [
'message' => $e->getMessage(),
'code' => $e->getErrorCode()->value,
'details' => $e->getData(),
],
], Status::BAD_REQUEST);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => [
'message' => 'Failed to create index',
'code' => 'CREATE_INDEX_ERROR',
],
], Status::INTERNAL_SERVER_ERROR);
}
}
/**
* Delete an index
* DELETE /api/search/{entityType}/_index
*/
#[Route(path: '/api/search/{entityType}/_index', method: Method::DELETE)]
public function deleteIndex(Request $request): JsonResult
{
try {
$entityType = $request->routeParameters->get('entityType');
$indexManager = $this->searchService->getIndexManager();
$success = $indexManager->deleteIndex($entityType);
return new JsonResult([
'success' => true,
'data' => [
'entity_type' => $entityType,
'deleted' => $success,
'message' => $success ? 'Index deleted successfully' : 'Index not found',
],
]);
} catch (\Exception $e) {
return new JsonResult([
'success' => false,
'error' => [
'message' => 'Failed to delete index',
'code' => 'DELETE_INDEX_ERROR',
],
], Status::INTERNAL_SERVER_ERROR);
}
}
private function createSearchFilter(array $filterData): SearchFilter
{
$type = SearchFilterType::from($filterData['type'] ?? 'equals');
$value = $filterData['value'] ?? null;
return new SearchFilter($type, $value);
}
}

View File

@@ -0,0 +1,224 @@
<?php
declare(strict_types=1);
namespace App\Application\Search;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
use App\Framework\Http\Request;
/**
* Represents a search request with validation
*/
final readonly class SearchRequest
{
/**
* @param array<string, array> $filters
* @param array<string, float> $boosts
* @param array<string> $fields
* @param array<string> $highlight
*/
public function __construct(
public string $query,
public array $filters = [],
public array $boosts = [],
public array $fields = [],
public array $highlight = [],
public int $limit = 20,
public int $offset = 0,
public ?string $sortBy = null,
public string $sortDirection = 'asc',
public bool $sortByRelevance = true,
public bool $enableHighlighting = true,
public bool $enableFuzzy = false,
public float $minScore = 0.0
) {
}
public static function fromHttpRequest(Request $request): self
{
$query = $request->query;
// Parse search query
$searchQuery = $query->get('q', '*');
// Parse filters
$filters = [];
if ($query->has('filters')) {
$filtersParam = $query->get('filters');
if (is_string($filtersParam)) {
$filters = json_decode($filtersParam, true) ?? [];
} elseif (is_array($filtersParam)) {
$filters = $filtersParam;
}
}
// Parse individual filter parameters (filter[field][type]=value)
foreach ($query->toArray() as $key => $value) {
if (preg_match('/^filter\[([^]]+)](?:\[([^]]+)])?$/', $key, $matches)) {
$field = $matches[1];
$type = $matches[2] ?? 'equals';
if (! isset($filters[$field])) {
$filters[$field] = [];
}
$filters[$field]['type'] = $type;
$filters[$field]['value'] = $value;
}
}
// Parse boosts
$boosts = [];
if ($query->has('boosts')) {
$boostsParam = $query->get('boosts');
if (is_string($boostsParam)) {
$boosts = json_decode($boostsParam, true) ?? [];
} elseif (is_array($boostsParam)) {
$boosts = $boostsParam;
}
}
// Parse fields restriction
$fields = [];
if ($query->has('fields')) {
$fieldsParam = $query->get('fields');
if (is_string($fieldsParam)) {
$fields = array_map('trim', explode(',', $fieldsParam));
} elseif (is_array($fieldsParam)) {
$fields = $fieldsParam;
}
}
// Parse highlight fields
$highlight = [];
if ($query->has('highlight')) {
$highlightParam = $query->get('highlight');
if (is_string($highlightParam)) {
$highlight = array_map('trim', explode(',', $highlightParam));
} elseif (is_array($highlightParam)) {
$highlight = $highlightParam;
}
} elseif ($query->getBool('enable_highlighting', true)) {
// Default highlighting for text fields
$highlight = ['title', 'content', 'description'];
}
// Parse pagination
$limit = min(100, max(1, $query->getInt('limit', 20)));
$offset = max(0, $query->getInt('offset', 0));
// Alternative pagination via page parameter
if ($query->has('page')) {
$page = max(1, $query->getInt('page', 1));
$perPage = min(100, max(1, $query->getInt('per_page', 20)));
$offset = ($page - 1) * $perPage;
$limit = $perPage;
}
// Parse sorting
$sortBy = $query->get('sort_by');
$sortDirection = strtolower($query->get('sort_direction', 'asc'));
$sortByRelevance = $query->getBool('sort_by_relevance', ! $sortBy);
// Validate sort direction
if (! in_array($sortDirection, ['asc', 'desc'])) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
'Invalid sort direction. Must be "asc" or "desc"'
);
}
// Parse advanced options
$enableHighlighting = $query->getBool('enable_highlighting', true);
$enableFuzzy = $query->getBool('enable_fuzzy', false);
$minScore = max(0.0, $query->getFloat('min_score', 0.0));
return new self(
query: $searchQuery,
filters: $filters,
boosts: $boosts,
fields: $fields,
highlight: $highlight,
limit: $limit,
offset: $offset,
sortBy: $sortBy,
sortDirection: $sortDirection,
sortByRelevance: $sortByRelevance,
enableHighlighting: $enableHighlighting,
enableFuzzy: $enableFuzzy,
minScore: $minScore
);
}
public function validate(): void
{
if (empty(trim($this->query))) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
'Search query cannot be empty'
);
}
if ($this->limit < 1 || $this->limit > 100) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
'Limit must be between 1 and 100'
);
}
if ($this->offset < 0) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
'Offset cannot be negative'
);
}
if ($this->minScore < 0) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
'Minimum score cannot be negative'
);
}
// Validate filters
foreach ($this->filters as $field => $filterData) {
if (! is_array($filterData) || ! isset($filterData['type'])) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
"Invalid filter format for field '{$field}'"
);
}
}
// Validate boosts
foreach ($this->boosts as $field => $boost) {
if (! is_numeric($boost) || $boost < 0) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
"Invalid boost value for field '{$field}'. Must be a positive number"
);
}
}
}
public function toArray(): array
{
return [
'query' => $this->query,
'filters' => $this->filters,
'boosts' => $this->boosts,
'fields' => $this->fields,
'highlight' => $this->highlight,
'limit' => $this->limit,
'offset' => $this->offset,
'sort_by' => $this->sortBy,
'sort_direction' => $this->sortDirection,
'sort_by_relevance' => $this->sortByRelevance,
'enable_highlighting' => $this->enableHighlighting,
'enable_fuzzy' => $this->enableFuzzy,
'min_score' => $this->minScore,
];
}
}

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